leaf_core/wysiwyg.rs
1//! The WYSIWYG view: render the document with its markup *resolved*, not shown —
2//! headings and code tagged with a typographic role (a frontend sizes or colours
3//! them; see [`crate::style`]), `**bold**` as real bold, `# ` / `**` / `` ` ``
4//! delimiters hidden — while keeping every visible glyph tied back to the source
5//! byte it came from.
6//!
7//! That back-reference (`Glyph::src`) is what lets a caret still work: the caret
8//! stays a source offset (shared with the source view), but the [`VisualMap`]
9//! converts between an offset and a screen `(row, col)`, so cursor drawing,
10//! mouse clicks, and vertical motion all operate in *visible* space.
11//!
12//! Left and Right instead walk the map's caret *stops* in document order. On
13//! ordinary prose that's the same journey — the stops are laid out left to right
14//! — and it steps over the hidden delimiters either way. They part company only
15//! in a table, where the text is arranged in two dimensions and a cell wrapped
16//! within its column continues *below* rather than to the right. Following the
17//! document is what a caret means there.
18//!
19//! Text is walked from the AST (`str` nodes carry exact spans, and their text is
20//! the verbatim source slice), so a Markdown and a Djot file that parse alike
21//! render — and map — identically.
22
23use std::cell::{Cell, RefCell};
24use std::collections::HashMap;
25use std::ops::Range;
26use std::sync::OnceLock;
27
28use twig::{Alignment, ContainerOrigin, DirectiveForm, Editor, FlatNode, Kind, QueryMatch};
29use unicode_segmentation::UnicodeSegmentation;
30use unicode_width::UnicodeWidthStr;
31
32use crate::style::{
33 Align, Baseline, FaceId, FaceRef, FaceTable, FontSize, LineHeight, MarkColor, Role, Style,
34 TextColor, Token,
35};
36
37/// One rendered character plus the source byte offset it originates from.
38/// Synthetic glyphs (a list bullet, a quote gutter) point at their block's
39/// start, so clicking one lands the caret at the start of that block.
40#[derive(Clone)]
41pub struct Glyph {
42 pub ch: char,
43 pub style: Style,
44 pub src: usize,
45 /// Whether the caret may *rest* on this glyph. Decoration — a table border
46 /// or a cell's alignment padding — is visible but isn't text, so the caret
47 /// steps over it instead of into it. It also can't be a stop even in
48 /// principle: a run of decoration shares one `src`, and a caret can only
49 /// move by changing offset, so resting on it would pin horizontal motion.
50 /// A click still maps through `src`, which is why decoration points at the
51 /// text it decorates.
52 ///
53 /// Real text is a stop once per *grapheme cluster*, on the glyph that opens
54 /// it: the continuation glyphs of an emoji or an accented letter are drawn,
55 /// but standing between them is standing inside a character.
56 pub stop: bool,
57}
58
59impl Glyph {
60 /// The character to draw: [`ch`](Self::ch), except a list item's indent
61 /// on its later rows ([`Role::ListIndent`]), which is spelled with the
62 /// marker's characters for its width and drawn blank.
63 pub fn drawn(&self) -> char {
64 if self.style.role == Role::ListIndent {
65 ' '
66 } else {
67 self.ch
68 }
69 }
70}
71
72/// One visual line. `end_src` is the source offset a caret sits at when placed
73/// at the line's end (past its last glyph) — the anchor for end-of-line and
74/// click-past-content.
75///
76/// `Clone` so a block's rows can be cached and re-emitted at a shifted offset
77/// across an edit — see [`BlockCache`].
78#[derive(Clone)]
79pub struct VRow {
80 pub glyphs: Vec<Glyph>,
81 pub end_src: usize,
82 /// A row that is drawn but holds no caret: a table's `├───┼───┤` rules, and
83 /// the blank gap a block boundary is spelled with. Vertical motion steps
84 /// over it, `pos_of_offset` never resolves onto it, and its stops (it has
85 /// none) and `end_src` stay out of the map's stop table.
86 ///
87 /// Emptiness isn't the test — an empty paragraph is a blank row too, and a
88 /// real caret stop. The test is whether the row is somewhere text can go.
89 pub decoration: bool,
90 /// This row is one line of a fenced or indented code block. Set on every row
91 /// the `"code_block"` arm emits — including its blank lines, which carry no
92 /// glyph to tell them apart otherwise. A frontend draws its own chrome (a
93 /// border and a tinted background) around each maximal run of these, and
94 /// scrolls them horizontally instead of wrapping; see
95 /// [`VisualMap::code_blocks`]. Survives the row shuffling of [`BlockCache`]
96 /// reuse and [`build_spliced`] because it rides on the row, not on a
97 /// row-index span the way a table's picture does.
98 pub code: bool,
99 /// A fenced code block's info string (its language), carried on the *first*
100 /// row of the block so it survives row reuse the way [`code`](Self::code)
101 /// does. `None` on every other row, and on an indented block (which has no
102 /// fence to label). A frontend paints it as a small label on the block's box
103 /// and edits it through a prompt — see [`CodeBlockInfo::lang`]. It's a plain
104 /// display string, not a source slice, so it needs no offset shifting; the
105 /// label re-derives from twig on the next build.
106 pub code_lang: Option<String>,
107 /// This row belongs to a `:::name{.class}` directive container — twig's
108 /// generic fenced-div block, whose meaning is entirely up to the host app
109 /// (diaryx's `:::vis{.audience}` visibility blocks, say). Set on every row
110 /// the `"directive"` arm emits, the same way [`code`](Self::code) marks a
111 /// code block's rows, so a frontend can draw a tinted panel around each
112 /// maximal run of these.
113 pub directive: bool,
114 /// A directive container's space-joined attrs — dot-prefixed classes
115 /// (`.public .family` → `"public family"`) unioned with bare pandoc-style
116 /// words (`public family`, no leading dot — diaryx's other `:::vis{...}`
117 /// convention), carried on the block's *first* row only — the
118 /// [`code_lang`](Self::code_lang) pattern. `None` on every other row, and
119 /// when the directive carries no such attrs. A frontend paints it as a
120 /// small label on the block's panel; it's a plain display string, not a
121 /// source slice, so it rides row reuse untouched.
122 pub directive_label: Option<String>,
123 /// Set on the single placeholder row a block-level image renders to, carrying
124 /// the image's destination and alt text; `None` on every other row. The row's
125 /// glyphs are the default `🖼 alt` label (which a plain surface paints as-is);
126 /// an image-capable frontend reads this to paint the real picture instead,
127 /// skipping the row named by [`MediaInfo::rows_span`]. Like
128 /// [`code_lang`](Self::code_lang) it's plain display strings, not source
129 /// slices, so it rides row reuse and needs no offset shifting; the map's
130 /// [`media`](VisualMap::media) side-table is derived from it once the rows
131 /// are final, the same way [`code_blocks`](VisualMap::code_blocks) is.
132 pub media: Option<MediaMark>,
133 /// Set on the **first** row of a task list item, carrying whether its box is
134 /// ticked; `None` on every other row, including a plain `list_item`'s. The
135 /// row's glyphs already draw the box as `☐ `/`☑ ` in the marker's place, so a
136 /// plain surface needs nothing further; a GUI reads this to paint a real
137 /// checkbox widget and to know which way it is facing.
138 ///
139 /// A `bool` rather than a source span, for the reason
140 /// [`code_lang`](Self::code_lang) is a plain string: it rides [`BlockCache`]
141 /// reuse and [`build_spliced`] untouched, needing no offset shifting. To
142 /// *toggle* the box, a frontend maps its click to a source offset the way it
143 /// maps any other — the marker's glyphs carry the item's own `src` — and
144 /// hands that to [`crate::Doc::toggle_task_at`].
145 pub task: Option<bool>,
146 /// Set on the single placeholder row a **leaf** directive (`::name{…}`)
147 /// renders to, carrying its name and attributes; `None` on every other row.
148 /// The container form isn't this — it wraps real blocks and marks each of
149 /// them [`directive`](Self::directive) instead. Like [`media`](Self::media)
150 /// it's plain display strings, so it rides row reuse untouched, and the map's
151 /// [`directives`](VisualMap::directives) side-table is derived from it once
152 /// the rows are final.
153 pub leaf_directive: Option<DirectiveMark>,
154 /// The heading level (1–6) of the block this row belongs to, on every row a
155 /// `heading` emits (a long one wraps to several) and `None` everywhere else.
156 ///
157 /// A frontend that sizes a whole line — a proportional renderer giving the
158 /// row a bigger line box — needs the level *per row*, and the glyphs can't
159 /// always supply it: an empty heading (`# ` with nothing typed after it,
160 /// which is what the toolbar's H1 leaves on a blank line) has no glyph to
161 /// carry a [`Role::Heading`] at all, so a glyph scan called it body text and
162 /// the line drew at body height until the first character landed. Riding the
163 /// row says it once, for the empty case and the wrapped case alike.
164 ///
165 /// Per-*glyph* styling still comes from [`Role::Heading`] on the glyphs; this
166 /// is the row-level fact, and the two agree wherever a heading has content —
167 /// same `u8` level, clamped the same way [`heading_style`] clamps it.
168 pub heading: Option<u8>,
169 /// How this row's block is aligned across the measure — the author's
170 /// `class="center"`, on every row the block emits and `None` for the
171 /// theme's default, which is left.
172 ///
173 /// A *row* fact and not a glyph one for [`heading`](Self::heading)'s reason,
174 /// and more sharply: alignment is a property of the *line*, not of the
175 /// letters on it, so an empty paragraph the author has just centred has to
176 /// carry it with no glyph to hang it on. It rides the row like a plain
177 /// `Copy` flag, so [`BlockCache`] reuse and [`build_spliced`] carry it
178 /// untouched.
179 ///
180 /// Read from the paragraph's or heading's own attributes and from those of
181 /// every `div` around it, the nearest winning — so `<div class="center">`
182 /// around three paragraphs centres all three, which is what the author of
183 /// that HTML meant.
184 pub align: Option<Align>,
185 /// How far apart this row's block sets its lines, as a multiple of the
186 /// theme's own line height — the author's `data-line-height`, on every row
187 /// the block emits and `None` for the theme's spacing.
188 ///
189 /// A frontend that lays rows out in pixels scales the row's height by
190 /// [`LineHeight::as_f32`]; one that draws a row per terminal line ignores
191 /// it, the way it ignores a heading's size. Read at the same two levels
192 /// [`align`](Self::align) is, and one of the menu's three names or the
193 /// exact ratio the author asked for.
194 pub line_height: Option<LineHeight>,
195 /// What this row divides, on the blank rows a block boundary is *drawn* with
196 /// and `None` on every other row — including the navigable blank lines of
197 /// preserve-soft flow, which are somewhere text can go rather than a gap
198 /// between blocks. So `boundary.is_some()` is exactly "this row is a drawn
199 /// block boundary", the [`decoration`](Self::decoration) rows that come from
200 /// [`Builder::emit_separators_before`].
201 ///
202 /// It exists because a boundary's *height* is a frontend decision but its
203 /// *kind* is not. Typography spaces a boundary by what it separates — the
204 /// margin above a heading is wider than the one between two paragraphs, so
205 /// the heading groups with the text it introduces — and a frontend that has
206 /// only rows to look at has to re-derive the structure by sniffing glyph
207 /// roles. Three frontends sniffing separately is three chances to disagree
208 /// about the same document. Core already knows, having just walked the AST
209 /// to emit this row, so it says so once here and each frontend multiplies by
210 /// its own spacing.
211 pub boundary: Option<Boundary>,
212 /// The offsets on this row where an inline mark's *content* ends under a
213 /// hidden closing delimiter — the end of the `d` in `**bold**`, one byte
214 /// before the `**` that draws nothing. Each is a caret stop with no glyph
215 /// of its own: the caret standing there is drawn where the next glyph is,
216 /// but typing there extends the mark, where typing past the delimiter
217 /// leaves it. See [`VisualMap::mark_ends`] for the rule.
218 ///
219 /// Source offsets, so [`shift_row`] moves them with the glyphs; empty on
220 /// decoration rows and on every row no mark closes on.
221 pub mark_ends: Vec<usize>,
222 /// The formulas this row stands in for, in glyph order: one [`MathMark`]
223 /// per inline atom on the row, or the single block mark on the
224 /// placeholder row a display formula renders to. Empty on every other
225 /// row, and on the revealed line, where a formula is its TeX and no
226 /// picture stands for it.
227 ///
228 /// Plain strings and a glyph *index* rather than a source offset, for
229 /// [`media`](Self::media)'s reason: the mark rides [`BlockCache`] reuse and
230 /// [`build_spliced`] untouched, and the glyph it names carries the offset.
231 /// The map's [`math`](VisualMap::math) side-table is derived from these
232 /// once the rows are final.
233 pub math: Vec<MathMark>,
234}
235
236/// One formula a row stands in for — what a picture-capable frontend typesets
237/// and draws in place of the glyph or the rows that hold its spot. See
238/// [`VRow::math`] and, for the frontend's view of the same thing,
239/// [`MathInfo`].
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct MathMark {
242 /// The TeX between the delimiters, verbatim — a display block's keeps its
243 /// newlines. What `leaf-math` typesets.
244 pub tex: String,
245 /// Display style (`$$…$$`) rather than text style (`$…$`). True for every
246 /// block mark, and for a `$$…$$` written inside a line of prose, which is
247 /// display style set inline.
248 pub display: bool,
249 /// The index into [`VRow::glyphs`] of the atom this mark stands behind —
250 /// the one [`Role::Math`] glyph an inline formula renders to. `None` for
251 /// a block mark, whose placeholder is the whole row.
252 pub glyph: Option<usize>,
253 /// How many rows a block formula reserves — the label row plus the blank
254 /// fillers under it, the [`MediaMark::rows`] recipe, and from the same
255 /// door: a terminal frontend that has typeset and measured the picture
256 /// reports its height through [`crate::Doc::set_math_rows`]. `1` for an
257 /// inline atom, which reserves nothing.
258 pub rows: usize,
259}
260
261/// What a drawn block boundary separates: the kinds of the blocks it falls
262/// between — the pair a frontend spaces by.
263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
264pub struct Boundary {
265 pub above: BlockClass,
266 pub below: BlockClass,
267}
268
269/// The block kinds core tells apart when it walks a document — the vocabulary
270/// [`Boundary`] is spelled in. A statement about *structure*, not about how any
271/// of it should look: what a frontend does with "this gap sits above a heading"
272/// is entirely the frontend's.
273///
274/// `Class` rather than `Kind` because [`twig::BlockKind`] already means
275/// something else in this crate's public surface — the *command* vocabulary
276/// (`Paragraph | Heading(n)`) a toolbar passes to [`Doc::set_block`](crate::Doc::set_block).
277/// This is the reverse direction: what a block already *is*, read back off a
278/// rendered row.
279///
280/// [`BlockClass::Other`] is the honest answer for a node kind core doesn't
281/// separate out, so adding one here is additive for every frontend: nothing has
282/// to change until it wants to space that kind differently.
283#[derive(Clone, Copy, Debug, PartialEq, Eq)]
284pub enum BlockClass {
285 Paragraph,
286 Heading,
287 /// A whole list. Its *items* are [`BlockClass::ListItem`]; note that core
288 /// draws no boundary row between two items of one list, tight or loose, so
289 /// an item↔item pair never reaches a frontend.
290 List,
291 ListItem,
292 Quote,
293 Code,
294 Table,
295 /// A block-level image, video, or audio.
296 ///
297 /// Never reached through [`from_node_kind`](BlockClass::from_node_kind): a
298 /// block picture is not a node of its own — [`Builder::media_only`] promotes
299 /// the paragraph (or `<picture>`/`<video>` container) wrapping it — so the
300 /// walk only ever sees the wrapper's kind. [`label_media_boundaries`] reads
301 /// it back off the finished rows instead, after the fact.
302 Media,
303 /// A display formula on lines of its own — a paragraph holding nothing but
304 /// a `$$…$$`. Never reached through [`from_node_kind`](BlockClass::from_node_kind)
305 /// for [`Media`](BlockClass::Media)'s reason: the walk sees the wrapping
306 /// paragraph, and [`label_media_boundaries`] relabels the gaps around the
307 /// placeholder once the rows are final.
308 Math,
309 /// A `:::name{.class}` directive container.
310 Directive,
311 Rule,
312 Footnote,
313 Other,
314}
315
316impl BlockClass {
317 /// Classify a twig node kind — the same vocabulary [`Builder::block`]
318 /// matches on, so the two can't drift about what a block is. Both the
319 /// whole-arena walk (which has [`FlatNode`]s) and the incremental top-level
320 /// walk (which has only a query match's kind) reach it by this one door.
321 pub fn from_node_kind(kind: &Kind) -> BlockClass {
322 match kind {
323 Kind::Para => BlockClass::Paragraph,
324 Kind::Heading => BlockClass::Heading,
325 Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
326 Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
327 Kind::BlockQuote => BlockClass::Quote,
328 Kind::CodeBlock => BlockClass::Code,
329 Kind::Table => BlockClass::Table,
330 Kind::Image => BlockClass::Media,
331 // twig 2.8 folded `div`/`span`/`directive`/`element` into one
332 // `container` kind, so a `:::note` panel and a promoted `<video>`
333 // arrive here indistinguishable — telling them apart needs the
334 // node's `origin`, and the incremental walk has only this kind.
335 // `Directive` is the right answer for the case that motivates the
336 // class (nothing else draws a tinted panel) and a harmless one for
337 // the rest: `BlockClass` is descriptive and core never branches on
338 // it. The one case where it was actively wrong — a promoted
339 // `<video>`, which would have been handed to a frontend as something
340 // to draw a fenced-div panel around — is corrected by
341 // [`label_media_boundaries`] once the rows are final, along the same
342 // door as a block image. Anything else that must be exact reads
343 // [`container_is_directive`] off a real node.
344 Kind::Container => BlockClass::Directive,
345 Kind::ThematicBreak => BlockClass::Rule,
346 Kind::Footnote => BlockClass::Footnote,
347 _ => BlockClass::Other,
348 }
349 }
350}
351
352/// The name and attributes a leaf directive's placeholder row carries, so a
353/// frontend that knows the host app's vocabulary can paint the real thing —
354/// an embedded page for diaryx's `::embed{src=…}`, a generated table of
355/// contents for a `::toc`, and the plain `⧉ name` label for one it doesn't
356/// know. The peer of [`MediaMark`], and plain strings for the same reason: they
357/// survive the row shuffling of [`BlockCache`] reuse and [`build_spliced`].
358#[derive(Clone, Debug, PartialEq, Eq)]
359pub struct DirectiveMark {
360 /// The directive's type — `embed`, `toc`, `vis` — with no leading colons.
361 /// Core is agnostic of what it means: the vocabulary is the host app's.
362 pub name: String,
363 /// Its `{…}` attributes as `(key, value)` pairs in source order. A bare
364 /// attribute (`{public}`) has a `None` value, the way twig reports it.
365 pub attrs: Vec<(String, Option<String>)>,
366 /// The directive's `[label]` text, flattened from its inline children, or
367 /// empty when it has none. Also what the placeholder label shows.
368 pub label: String,
369 /// How many visual rows this directive reserves — the label row plus blank
370 /// filler rows below it, so a frontend painting something real has the
371 /// vertical room. `1` is the bare placeholder, and what every directive
372 /// gets until a frontend that reserves rows — a terminal, which draws the
373 /// host's lines over them — reports a height back through
374 /// [`crate::Doc::set_directive_rows`], keyed by [`key`](Self::key). A
375 /// pixel-laid-out GUI sets its own height regardless.
376 pub rows: usize,
377}
378
379impl DirectiveMark {
380 /// What [`crate::Doc::set_directive_rows`] knows this directive by.
381 pub fn key(&self) -> DirectiveKey {
382 DirectiveKey::new(&self.name, &self.label, &self.attrs)
383 }
384}
385
386/// What a directive's reported height is keyed by: everything a host draws it
387/// from — its name, its label and its attributes — and nothing about where it
388/// stands. The peer of a picture's destination, which keys
389/// [`crate::Doc::set_media_rows`]: two directives spelled alike draw alike and
390/// so stand as tall, and the height survives an edit above them, which a key
391/// by position would not.
392///
393/// An attribute's value is flattened, a bare attribute to `""`, because that is
394/// the shape both bindings hand a host (`DirectiveAttr`, a string value) and so
395/// the shape a host's report comes back in; the difference between `{wide}` and
396/// `{wide=""}` is not one a drawing is expected to make.
397#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
398pub struct DirectiveKey {
399 pub name: String,
400 pub label: String,
401 pub attrs: Vec<(String, String)>,
402}
403
404impl DirectiveKey {
405 /// The key for a directive named `name` with `label` (`""` for none) and
406 /// `attrs` in source order.
407 pub fn new(name: &str, label: &str, attrs: &[(String, Option<String>)]) -> Self {
408 Self {
409 name: name.to_string(),
410 label: label.to_string(),
411 attrs: attrs
412 .iter()
413 .map(|(k, v)| (k.clone(), v.clone().unwrap_or_default()))
414 .collect(),
415 }
416 }
417}
418
419/// What a block-level media placeholder actually is, so a frontend knows which
420/// widget to build over the reserved rows: a raster, a movie player, or a
421/// transport with no picture at all. Core classifies and stops there — it opens
422/// nothing, so this is a statement about the *markup*, not about a file it has
423/// verified exists or can decode.
424#[derive(Clone, Copy, Debug, PartialEq, Eq)]
425pub enum MediaKind {
426 /// A `` / `<img>` / `<picture>` — a still picture.
427 Image,
428 /// An HTML `<video>`. Markdown and Djot spell no video of their own, so this
429 /// only ever arrives through `html_elements` promotion (or a `::video{…}`
430 /// directive a host app maps itself, which core reports as a directive).
431 Video,
432 /// An HTML `<audio>` — a transport with no picture, so a frontend gives it a
433 /// fixed control height rather than measuring an aspect ratio.
434 Audio,
435}
436
437/// Which of the two caret homes a block media or a block leaf directive has —
438/// see [`VisualMap::block_media_stop`] and [`VisualMap::block_directive_stop`].
439#[derive(Clone, Copy, Debug, PartialEq, Eq)]
440pub enum MediaStop {
441 /// The stop in front of the picture. What is typed here belongs above it.
442 Before,
443 /// The stop just past it. What is typed here belongs below it.
444 After,
445}
446
447impl MediaKind {
448 /// The emoji a plain surface prefixes the placeholder label with — the
449 /// `🖼`/`🎬`/`🔊` that makes the row read as *a thing* rather than as text.
450 fn sigil(self) -> char {
451 match self {
452 MediaKind::Image => '🖼',
453 MediaKind::Video => '🎬',
454 MediaKind::Audio => '🔊',
455 }
456 }
457}
458
459/// The destination and label a block-level media placeholder row carries, so a
460/// capable frontend can resolve and paint the real thing. Plain strings (no
461/// source offsets), so they survive the row shuffling of [`BlockCache`] reuse
462/// and [`build_spliced`] untouched — see [`VRow::media`].
463#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct MediaMark {
465 /// Whether this is a picture, a movie, or a sound — which widget the
466 /// frontend builds over the reserved rows.
467 pub kind: MediaKind,
468 /// The media's link destination — a path, URL, or `data:` URI, verbatim from
469 /// the AST. A frontend resolves a relative path against the document's
470 /// directory itself; core holds no I/O.
471 ///
472 /// Empty is possible and legal for a `<video>`/`<audio>`, which may carry no
473 /// `src` of its own and name its candidates in child `<source>`s instead —
474 /// unlike an `<img>`, whose `src` *is* the picture. A frontend with an empty
475 /// destination takes its URL from [`sources`](MediaMark::sources).
476 pub destination: String,
477 /// A `<picture>`'s theme/media alternatives, in document order, when this
478 /// block image came from one; empty for a plain `` / bare `<img>`. Each
479 /// is a `<source>`'s media query + candidate URL(s); a frontend that knows its
480 /// theme picks the first whose media matches and falls back to [`destination`]
481 /// (the `<img>`). Core keeps them verbatim and picks nothing — it has no theme.
482 ///
483 /// [`destination`]: MediaMark::destination
484 pub sources: Vec<MediaSource>,
485 /// The media's alt text (its rendered inline children, flattened), or empty
486 /// when it has none. Also what the placeholder label shows. For a `<video>`/
487 /// `<audio>` this is the element's own text content — the "your browser does
488 /// not support…" fallback, which doubles as its accessible name.
489 pub alt: String,
490 /// A `<video poster="…">`'s still frame, verbatim, or empty when there is
491 /// none (and always empty for an image or audio). It is an *image*
492 /// destination, so a frontend already able to draw a picture can show it
493 /// before the movie loads — or in place of one it can't play at all.
494 pub poster: String,
495 /// How many visual rows this media reserves — the placeholder label row plus
496 /// the blank filler rows below it, so a frontend that paints a real raster has
497 /// the vertical room to draw it. `1` is the bare placeholder (a frontend that
498 /// can't draw pictures, or an image it couldn't resolve). A terminal frontend
499 /// asks for as many rows as the fitted picture is tall; the pixel-laid-out GUI
500 /// ignores this and sets its own row height, so it always leaves it `1`. The
501 /// count comes from the frontend (via [`crate::Doc::set_media_rows`]) because
502 /// core does no I/O and can't measure the image itself. See [`VRow::media`].
503 pub rows: usize,
504}
505
506/// One `<source>` under a `<picture>`, `<video>`, or `<audio>`: a candidate URL
507/// plus whichever of the two things HTML lets a `<source>` be chosen by — a
508/// media query (`<picture>`) or a MIME type (`<video>`/`<audio>`). Verbatim from
509/// the AST: core carries the alternatives and resolves none of them, having
510/// neither a theme nor a codec list to judge them by.
511///
512/// The two spellings are normalised onto one field. `<picture>` writes
513/// `srcset`, `<video>`/`<audio>` write `src`; both land in
514/// [`srcset`](MediaSource::srcset), since a frontend wants the URL either way
515/// and only `<picture>` ever uses the descriptor syntax.
516#[derive(Clone, Debug, PartialEq, Eq)]
517pub struct MediaSource {
518 /// The `<source media="…">` query, verbatim (`"(prefers-color-scheme: dark)"`),
519 /// or empty for a `<source>` with no `media` (an unconditional override, and
520 /// the norm for `<video>`/`<audio>`, which pick by codec rather than theme).
521 pub media: String,
522 /// The candidate URL(s): a `<picture>`'s `srcset` verbatim — one URL, or a
523 /// comma-separated candidate list with `1x`/`2x`/width descriptors — or a
524 /// `<video>`/`<audio>` `<source>`'s plain `src`. A frontend takes the first
525 /// URL token; the theme and codec cases both only ever need that.
526 pub srcset: String,
527 /// The `<source type="…">` MIME type (`"video/webm"`), verbatim, or empty
528 /// when the `<source>` declares none. How a `<video>`/`<audio>` frontend
529 /// picks a candidate it can actually decode; a `<picture>`'s sources
530 /// normally leave it empty and are chosen by [`media`](MediaSource::media).
531 pub mime: String,
532}
533
534/// The rendered document plus the offset⇄position mapping the caret rides on.
535#[derive(Clone, Default)]
536pub struct VisualMap {
537 /// The document's **default monospace rendering** — one [`VRow`] of glyphs
538 /// per visual line, tables spelled with box-drawing borders (`│ ─ ┌┬┐…`) and
539 /// cells padded to whole character-cell columns. Any monospace surface can
540 /// draw these verbatim, so a consumer gets a working view for free: the TUI
541 /// paints them as-is, and a five-line plain-text dump would too.
542 ///
543 /// It's a *default*, not the only truth. A frontend with its own geometry —
544 /// a proportional GUI — lays text out in its own units, and for a table
545 /// skips the box-drawn rows named by [`TableInfo::rows_span`] and draws from
546 /// the structural [`TableInfo`] instead. The box glyphs live here rather than
547 /// in a frontend precisely because they *are* a renderable default: unlike a
548 /// colour (a role each surface must map to its own palette — see
549 /// [`crate::style`]), `┌─┐` is finished text that needs no interpretation.
550 pub rows: Vec<VRow>,
551 /// The first source offset that is actually rendered — the caret floor for
552 /// the WYSIWYG view. Non-zero when a leading `metadata` block (YAML/TOML
553 /// frontmatter) is skipped: the frontmatter is preserved in the source and
554 /// editable in the source view, but hidden and unreachable here, so the
555 /// caret and selection can't wander into it (and copy won't grab it).
556 pub content_start: usize,
557 /// Every offset the caret may rest at, ascending and deduplicated: each
558 /// row's stop glyphs plus the row's own end (the "after the last character"
559 /// spot every line needs). Decoration contributes nothing.
560 ///
561 /// Left/Right read this instead of walking the grid, because the grid isn't
562 /// laid out in offset order: a table with wrapped cells puts column 1's
563 /// second line *below* column 2's first, so "the next stop rightward" and
564 /// "the next stop in the document" part ways. Following the document is what
565 /// a caret means — and on every row that *is* in order the two agree anyway,
566 /// so nothing else has to change.
567 stops: Vec<usize>,
568 /// The caret's second home at the end of every hidden inline mark: the
569 /// offset where the mark's content ends, one byte before its closing
570 /// delimiter — ascending and deduplicated, from every row's
571 /// [`VRow::mark_ends`].
572 ///
573 /// With delimiters hidden, `**bold** tail` draws one spot after the `d`
574 /// and the source has two offsets for it: the content end (inside the
575 /// mark, where typing extends the bold) and the byte past the `**` (where
576 /// typing leaves it). Only the second is a glyph's offset, so only it was
577 /// a stop, and a caret asked to rest at the first was snapped a whole
578 /// character back onto the `d` — a drag over `bold` came back one letter
579 /// short. The delete and backspace paths already settle the caret on the
580 /// content end as its natural home there
581 /// ([`crate::Doc::settle_inside_close_delims`]); this makes it one the
582 /// caret can be placed at and step onto too.
583 ///
584 /// Kept apart from [`stops`](Self::stops) rather than merged in, because
585 /// the two lists answer different questions. A stop with no glyph is
586 /// invisible to a walk that pairs stops with characters — a system text
587 /// input counting `position(from:offset:)` steps against the text it was
588 /// shown would drift a character at every mark — and to word motion, which
589 /// classifies a stop by the source byte under it (a `*`). So
590 /// [`stop_after`](Self::stop_after) and its kin walk the glyph stops alone,
591 /// and only the places a caret *rests* — snapping, resting checks, and
592 /// Left/Right — read both.
593 mark_ends: Vec<usize>,
594 /// Every table in the document, in order, described structurally rather than
595 /// drawn — see [`TableInfo`] for why both exist.
596 pub tables: Vec<TableInfo>,
597 /// Every fenced/indented code block, in order, as the range of [`rows`] it
598 /// occupies — a frontend draws one bordered, tinted box around each and
599 /// scrolls it horizontally rather than wrapping. Derived from the per-row
600 /// [`VRow::code`] flag once the rows are final (so it survives incremental
601 /// row reuse), the same way [`collect_stops`] derives the stop table.
602 ///
603 /// [`rows`]: VisualMap::rows
604 pub code_blocks: Vec<CodeBlockInfo>,
605 /// Every block-level image in the document, in order — one per placeholder
606 /// row a frontend replaces with a real picture. Derived from the per-row
607 /// [`VRow::media`] mark once the rows are final (so it survives incremental
608 /// row reuse), the same way [`code_blocks`](VisualMap::code_blocks) is
609 /// derived from [`VRow::code`].
610 pub media: Vec<MediaInfo>,
611 /// Every **leaf** directive in the document, in order — one per placeholder
612 /// row a frontend may replace with whatever the host app's vocabulary makes
613 /// of it. Derived from the per-row [`VRow::leaf_directive`] mark once the
614 /// rows are final, exactly as [`media`](VisualMap::media) is.
615 pub directives: Vec<DirectiveInfo>,
616 /// Every formula standing as a picture in this map, in row order — each
617 /// inline atom and each display block's placeholder. A frontend that
618 /// paints pictures typesets each one's TeX and draws it over the glyph or
619 /// the rows named. Derived from the per-row [`VRow::math`] marks once the
620 /// rows are final, exactly as [`media`](VisualMap::media) is.
621 ///
622 /// A formula on the revealed line is not here: there it is its own TeX,
623 /// drawn as code, and nothing stands in for it.
624 pub math: Vec<MathInfo>,
625 /// Every named font family this map's glyphs are set in, by the
626 /// [`FaceId`] they carry — the side table that lets [`Style`] stay `Copy`
627 /// while a family name stays a `String`.
628 ///
629 /// Not derived from the rows the way [`code_blocks`](Self::code_blocks) is,
630 /// because the name is not on the rows: it is interned as the walker meets
631 /// the attribute. So each of the three build paths assembles it from what
632 /// it actually walked — a fresh build from its own walk, a cached build
633 /// from each block's walk or the names its cache entry stored, a splice
634 /// from the previous map's table plus the one block it re-rendered. A
635 /// [`FaceId`] is derived from the name rather than being an index, which is
636 /// what makes those three agree glyph for glyph; see the type's note.
637 faces: FaceTable,
638 /// The visible text tabulated over the stops — see [`Spelling`]. Derived
639 /// on the first lookup that asks for it rather than by the build paths,
640 /// since a map that is only ever drawn never needs it, and a splice
641 /// would otherwise have to patch it the way it patches the stops.
642 spelling: OnceLock<Spelling>,
643}
644
645/// The visible text ([`VisualMap::visible_text`]) as a table over the stops:
646/// for stop `i`, the character the text spells it with — `None` for the
647/// `'\n'` of a stop that draws no glyph — and the UTF-16 length of the text
648/// before it. A UTF-16 index and a stop then find each other by binary
649/// search, where they used to find each other by walking every character of
650/// the document from the top: a frontend converts an `NSRange` end per
651/// selection change and per misspelled word, and each conversion was a whole
652/// document's work.
653///
654/// Built once per map and never maintained. The stops are private and fixed
655/// for the map's life; the rows only lend the character each stop draws, and
656/// a frontend that reshapes the rows after the build (the terminal inserts
657/// blank filler rows) neither changes those characters nor asks this.
658#[derive(Clone, Default)]
659struct Spelling {
660 /// Parallel to [`VisualMap::stops`].
661 chars: Vec<Option<char>>,
662 /// One longer than `chars`: `utf16[i]` is the UTF-16 length of the text
663 /// before stop `i`, and the last entry the length of the whole text.
664 utf16: Vec<usize>,
665}
666
667impl VisualMap {
668 pub fn num_rows(&self) -> usize {
669 self.rows.len()
670 }
671
672 /// The family name a glyph's [`FaceRef::Named`] stands for, or `None` for
673 /// an id from another map — which a frontend draws in the theme's body
674 /// face, as it draws a family it cannot resolve.
675 pub fn face_name(&self, id: FaceId) -> Option<&str> {
676 self.faces.name(id)
677 }
678
679 /// Every named family this map draws — how a frontend warms a font cache
680 /// before it lays a frame out. See [`faces`](Self::faces).
681 ///
682 /// [`faces`]: VisualMap::faces
683 pub fn faces(&self) -> &FaceTable {
684 &self.faces
685 }
686
687 /// The width of `row` in display columns — the rightmost column its caret
688 /// can occupy, and so what a goal column is clamped to on the way in.
689 pub fn row_width(&self, row: usize) -> usize {
690 self.rows.get(row).map_or(0, |r| r.width())
691 }
692
693 /// The screen `(row, col)` for a source offset — where to draw the caret:
694 /// the *nearest* stop at or past `off`. Snaps a hidden offset (inside a
695 /// delimiter) to the next visible glyph, and never resolves onto decoration
696 /// (a table border, a cell's padding), which is drawn but holds no caret.
697 ///
698 /// "Nearest" rather than "the first one found" because a table's wrapped
699 /// cells put rows slightly out of offset order: scanning top to bottom, the
700 /// second line of column 1 comes *after* the first line of column 2 but
701 /// holds smaller offsets. Where rows are in order the two rules agree.
702 ///
703 /// A soft wrap is the one place two rows want the same offset: the row above
704 /// ends where the row below opens, the space the wrap ate being drawn on the
705 /// row above and the offset past it being the row below's first character.
706 /// It resolves *downstream*, to the row that character is on — the row
707 /// above's last column is a phantom, a place the caret can be drawn but
708 /// never sent, and resolving upstream into it is what pinned Down at the
709 /// first wrap of a paragraph: it aimed at the row below's column 0, landed
710 /// on the offset it already had, and read that back as the row above's end.
711 ///
712 /// The walk is over the rows, not their text: a row before `off` is
713 /// dismissed by looking at where it opens and where it reaches — its first
714 /// stop and its last — rather than at every glyph between, so the cost of
715 /// a lookup mid-document is a few hundred rows' worth of two glyphs each,
716 /// and the one row that holds the answer is the only one read through.
717 /// It used to read every row through, and worse: the row's end candidate
718 /// was built eagerly, and building it measured the row's width, which
719 /// segments the row's whole text into grapheme clusters — so every row
720 /// before `off` paid a full cluster walk to learn it held nothing, and a
721 /// document of long paragraphs paid milliseconds per caret placement.
722 ///
723 /// Not a binary search, though the rows' opening stops ascend. A table's
724 /// wrapped cells put several rows before the one an offset opens on that
725 /// can still hold it, and `end_src` does not ascend across those rows at
726 /// all, so a search would need a prefix table over the rows — and
727 /// [`rows`](Self::rows) is public, reshaped by a frontend after the build
728 /// (the terminal inserts filler rows under a heading), which is exactly
729 /// what a table over it could not survive. The walk needs no table.
730 pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
731 // (src, row, the glyph — or `None` for the caret past the row's end)
732 let mut best: Option<(usize, usize, Option<usize>)> = None;
733 for (r, row) in self.rows.iter().enumerate() {
734 if row.decoration {
735 continue;
736 }
737 // A row's *first* stop never decreases from one row to the next —
738 // true even across a table's wrapped cells, since a cell's lines run
739 // downward. So once a row opens past the best found so far, no later
740 // row can beat it and the scan stays proportional to `off`.
741 let open = row
742 .glyphs
743 .iter()
744 .find(|g| g.stop)
745 .map_or(row.end_src, |g| g.src);
746 if best.is_some_and(|b| open > b.0) {
747 break;
748 }
749 // Offsets ascend *within* a row, so its last stop or its end is as
750 // far as it reaches: a row that reaches short of `off` has nothing
751 // to offer, and says so from its two ends.
752 let reach = row
753 .glyphs
754 .iter()
755 .rev()
756 .find(|g| g.stop)
757 .map_or(row.end_src, |g| g.src.max(row.end_src));
758 if reach < off {
759 continue;
760 }
761 // And so its first stop at or past `off` is the best it has.
762 let cand = row
763 .glyphs
764 .iter()
765 .enumerate()
766 .find(|(_, g)| g.stop && g.src >= off)
767 .map(|(i, g)| (g.src, r, Some(i)))
768 .or_else(|| (row.end_src >= off).then_some((row.end_src, r, None)));
769 // `<=`, so a tie goes to the later row: the only offset two rows
770 // both hold is a wrap boundary, and it belongs to the row below.
771 if let Some(c) = cand
772 && best.is_none_or(|b| c.0 <= b.0)
773 {
774 best = Some(c);
775 }
776 }
777 match best {
778 Some((_, r, Some(i))) => (r, self.rows[r].col_of_glyph(i)),
779 Some((_, r, None)) => (r, self.rows[r].width()),
780 None => {
781 let r = self.last_stop_row();
782 (r, self.row_width(r))
783 }
784 }
785 }
786
787 /// The rows a source range occupies, inclusive: `(first, last)`.
788 ///
789 /// A *different question* from [`pos_of_offset`](Self::pos_of_offset), which
790 /// is why it can't be spelled with two calls to it. That one answers "where
791 /// does the caret go", and for a caret its forward snap is right — an offset
792 /// inside a hidden delimiter has no column of its own, so the caret belongs
793 /// at the next visible glyph, wherever that turns out to be. This one asks
794 /// "which rows does this block cover", and there the snap is a trap: a
795 /// footnote whose body *ends* in a link (`[^2]: [title](url)`) has a last
796 /// byte inside the hidden destination, so `pos_of_offset(end - 1)` walked
797 /// clean off the note's row and landed on the next note's — and a peek
798 /// slicing `first..=last` out of the frame drew two notes where the reader
799 /// asked for one. Every block ending in a link, an image, or any trailing
800 /// hidden markup had the same fault; only a block ending in visible text
801 /// (which is what the tests happened to use) did not.
802 ///
803 /// `row.end_src` is no help either: it is where the *rendered* text of a row
804 /// ends, not how far into the source the block reaches, and redefining it
805 /// would move every end-of-line caret.
806 ///
807 /// So the last row is found by asking which rows *open* before the range
808 /// does, rather than by mapping its last byte: a row belongs to the range
809 /// when its first caret stop lies before `range.end`. Decoration is skipped
810 /// (a drawn gap between blocks is not part of either), and the answer is
811 /// never shorter than one row — a range whose every byte is hidden still
812 /// covers the row it started on.
813 pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
814 if self.rows.is_empty() {
815 return (0, 0);
816 }
817 let first = self.pos_of_offset(range.start).0;
818 let mut last = first;
819 for (r, row) in self.rows.iter().enumerate().skip(first) {
820 if row.decoration {
821 continue;
822 }
823 let open = row
824 .glyphs
825 .iter()
826 .find(|g| g.stop)
827 .map_or(row.end_src, |g| g.src);
828 if open >= range.end {
829 // A row's first stop never decreases from one row to the next —
830 // the invariant `pos_of_offset` breaks on, true even across a
831 // table's wrapped cells — so nothing below can be in range.
832 break;
833 }
834 last = r;
835 }
836 (first, last)
837 }
838
839 /// The source offset of the task checkbox drawn at `(row, col)`, or `None`
840 /// when that cell holds no box — the hit-test a frontend runs on a click
841 /// before treating it as a tick rather than a caret placement.
842 ///
843 /// Only the box's own cells answer. Clicking an item's *text* places the
844 /// caret like any other click, so the box is a target aimed at rather than
845 /// something tripped over while editing — which is also why this is a
846 /// separate question from [`offset_of_pos`](Self::offset_of_pos) instead of
847 /// a flag on the offset it returns.
848 pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
849 let r = self.rows.get(row)?;
850 self.task_box_at_glyph(row, r.glyph_at_col(col)?)
851 }
852
853 /// [`task_box_at`](Self::task_box_at) keyed by glyph index rather than
854 /// display column — for a frontend that shapes its own rows (the GUI) and so
855 /// resolves a click to a glyph before it ever has a column.
856 pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
857 let r = self.rows.get(row)?;
858 r.task?;
859 let g = r.glyphs.get(glyph)?;
860 (g.style.role == Role::ListMarker).then_some(g.src)
861 }
862
863 /// The source offset for a screen `(row, col)` — where a click or a
864 /// visual-space move lands the caret. Clicking decoration maps through its
865 /// `src`, which points at the text it decorates, so a click on a border or
866 /// on a cell's padding lands in that cell.
867 ///
868 /// The inverse of [`pos_of_offset`](Self::pos_of_offset), which it has to
869 /// agree with: `col` is a display column, and the one it names may be the
870 /// far cell of a wide glyph — [`VRow::glyph_at_col`] is where that lands.
871 pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
872 let Some(r) = self.rows.get(row) else {
873 // A click or drag below the last row — a short document with empty
874 // space under it, dragged into to extend a selection. Land on the
875 // document's last caret stop (its end), not offset 0: jumping the
876 // caret to the top is the wrong direction, and 0 isn't even a stop
877 // when the document opens on hidden frontmatter or a `# ` marker, so
878 // returning it would leave the caret where it draws in one place and
879 // types in another (`move_to` would then clamp it onto the unhomeable
880 // frontmatter floor). `None` only for a document with no stops at all
881 // (empty), where the caret has nowhere to be but 0.
882 return self.stops.last().copied().unwrap_or(0);
883 };
884 match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
885 // A glyph that holds no caret is clickable, but where it points
886 // isn't always somewhere the caret can be: the blank gap between two
887 // paragraphs stands at an offset that belongs to neither of them,
888 // and the tail of a grapheme cluster stands inside a character.
889 // Land on the nearest real stop instead of handing back an offset
890 // that looks like the gap but types into the paragraph above.
891 Some(g) if !g.stop => self.nearest_stop(g.src),
892 Some(g) => g.src,
893 // A row's end is a stop by construction — unless the row is
894 // decoration, which contributes none.
895 None if r.decoration => self.nearest_stop(r.end_src),
896 None => r.end_src,
897 }
898 }
899
900 /// Which of a block media's two caret homes `off` is, or `None` for every
901 /// other offset in the document.
902 ///
903 /// [`block_media`](Builder::block_media) gives a block-level image, video, or
904 /// audio exactly two stops — one in front of it and one just past it — and
905 /// nothing inside the markup. Both are ordinary offsets to everything else in
906 /// core, but they are the two places where inserting text would *dissolve the
907 /// picture*: `` with anything typed against it is no longer a block
908 /// image but a paragraph with an inline one, and the frontend that was
909 /// painting a photo there paints a text run instead. A caller that is about to
910 /// insert asks this so it can open a paragraph first — see
911 /// [`Doc::insert`](crate::Doc::insert).
912 ///
913 /// An *inline* image reports `None`: it has no placeholder row and no stops of
914 /// its own, and typing beside one is ordinary editing.
915 ///
916 /// Answers with the media's own source span as well, since a caller that has
917 /// to keep the picture whole usually has to address it — [`Doc::backspace`]
918 /// takes the picture out in one piece rather than nibbling a byte off its
919 /// markup, which is the same dissolution from the other side.
920 ///
921 /// [`Doc::backspace`]: crate::Doc::backspace
922 pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
923 self.media
924 .iter()
925 .find_map(|m| self.placeholder_stop(m.rows_span.start, off))
926 }
927
928 /// Which of a block leaf directive's two caret homes `off` is, or `None`
929 /// for every other offset — [`block_media_stop`](Self::block_media_stop)
930 /// for the [`directives`](Self::directives), for the same reason.
931 ///
932 /// A leaf directive is drawn on the picture's recipe (`⧉ label`, one stop
933 /// in front of it and one just past it), and dissolves the same way: text
934 /// typed against `::embed{src=x}` either end is a paragraph of raw source
935 /// (`X::embed{src=x}`, `::embed{src=x}X`), and djot's empty `::: name`
936 /// fence either stops opening or stops closing. `::page-break` is one of
937 /// these. Answers with the directive's own span, as its peer does.
938 pub fn block_directive_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
939 self.directives
940 .iter()
941 .find_map(|d| self.placeholder_stop(d.rows_span.start, off))
942 }
943
944 /// Whether `off` is one of the two stops of the placeholder row `row` —
945 /// a block picture's or a leaf directive's, which share the recipe — and
946 /// the span the placeholder stands for.
947 fn placeholder_stop(&self, row: usize, off: usize) -> Option<(MediaStop, Range<usize>)> {
948 let row = self.rows.get(row)?;
949 // Every glyph of the `🖼 alt` / `⧉ label` label maps to the block's
950 // start offset; the row's end is past its markup. Read the start off
951 // the label rather than the first glyph, which on a quoted or listed
952 // picture is the block prefix and points at the gutter.
953 let start = row
954 .glyphs
955 .iter()
956 .find(|g| g.style.role == Role::Image)
957 .map(|g| g.src)?;
958 if off == start {
959 return Some((MediaStop::Before, start..row.end_src));
960 }
961 if off == row.end_src {
962 return Some((MediaStop::After, start..row.end_src));
963 }
964 None
965 }
966
967 /// Whether `off` is a table's trailing caret stop — the one home past a
968 /// table's last cell, at the block's own end ([`TableInfo::end_src`]).
969 ///
970 /// The table's peer of [`block_media_stop`](Self::block_media_stop)'s
971 /// `After`: text inserted at that offset joins the table's last source
972 /// line, and a line glued under a table is a row of it (`| 1 | 2 |x`), so
973 /// a caller about to insert there opens a paragraph first — see
974 /// [`Doc::insert`](crate::Doc::insert). Nothing else about the offset is
975 /// special: it is where Down from the last row lands and where a click in
976 /// the blank space under a trailing table lands.
977 pub fn table_end_stop(&self, off: usize) -> bool {
978 self.tables.iter().any(|t| t.end_src == off)
979 }
980
981 /// Snap `off` to the nearest caret stop — the funnel a frontend that
982 /// hit-tests pixels straight to a source offset must run its result through.
983 /// A click or drag can land in the blank gap a paragraph break is drawn with,
984 /// or inside a hidden delimiter; both are offsets the caret can't rest at, so
985 /// resting there would draw the caret in one place and type in another. This
986 /// settles it on a real caret home instead. Idempotent on an offset that is
987 /// already a stop — the `(row, col)` click path already snaps this way inside
988 /// [`offset_of_pos`](Self::offset_of_pos), and this gives the pixel path the
989 /// same guarantee. Returns `off` unchanged only for an empty document (no
990 /// stops at all).
991 pub fn snap_to_stop(&self, off: usize) -> usize {
992 self.nearest_stop(off)
993 }
994
995 /// The caret stop nearest `off`, preferring the one before it when `off`
996 /// falls exactly between two. Returns `off` unchanged if there are no stops
997 /// at all (an empty document). A mark's content end counts: it is a place
998 /// the caret rests, and the one a drag ending on a marked word means.
999 fn nearest_stop(&self, off: usize) -> usize {
1000 let before = Self::last_at_or_before(&self.stops, off)
1001 .max(Self::last_at_or_before(&self.mark_ends, off));
1002 let after = match (
1003 Self::first_at_or_after(&self.stops, off),
1004 Self::first_at_or_after(&self.mark_ends, off),
1005 ) {
1006 (Some(a), Some(b)) => Some(a.min(b)),
1007 (a, b) => a.or(b),
1008 };
1009 match (before, after) {
1010 (Some(b), Some(a)) if off - b <= a - off => b,
1011 (_, Some(a)) => a,
1012 (Some(b), None) => b,
1013 (None, None) => off,
1014 }
1015 }
1016
1017 /// The glyph stop nearest `off` — [`nearest_stop`](Self::nearest_stop)
1018 /// for a walk that pairs stops with characters, which a mark's content
1019 /// end has none of. A caret resting on one resolves to the glyph stop
1020 /// drawn at the same spot, the one just past the hidden delimiter, so the
1021 /// text a system input is shown from there and the steps it counts agree.
1022 pub fn snap_to_glyph_stop(&self, off: usize) -> usize {
1023 if self.mark_ends.binary_search(&off).is_ok()
1024 && let Some(next) = Self::first_at_or_after(&self.stops, off)
1025 {
1026 return next;
1027 }
1028 let before = Self::last_at_or_before(&self.stops, off);
1029 let after = Self::first_at_or_after(&self.stops, off);
1030 match (before, after) {
1031 (Some(b), Some(a)) if off - b <= a - off => b,
1032 (_, Some(a)) => a,
1033 (Some(b), None) => b,
1034 (None, None) => off,
1035 }
1036 }
1037
1038 /// The last of `sorted` at or before `off`, if any.
1039 fn last_at_or_before(sorted: &[usize], off: usize) -> Option<usize> {
1040 let i = sorted.partition_point(|&s| s <= off);
1041 i.checked_sub(1).map(|i| sorted[i])
1042 }
1043
1044 /// The first of `sorted` at or after `off`, if any.
1045 fn first_at_or_after(sorted: &[usize], off: usize) -> Option<usize> {
1046 let i = sorted.partition_point(|&s| s < off);
1047 sorted.get(i).copied()
1048 }
1049
1050 /// The next place the caret rests past `off` — the next glyph stop or the
1051 /// next mark's content end, whichever comes first. What Right walks:
1052 /// leaving `**bold**` from the `d` is two presses, one onto the end of the
1053 /// bold (still bold, the toolbar lit) and one past its delimiter, at the
1054 /// same spot on screen. [`stop_after`](Self::stop_after) is the walk that
1055 /// skips the first, for every caller that pairs stops with characters.
1056 pub fn caret_stop_after(&self, off: usize) -> Option<usize> {
1057 match (
1058 self.stop_after(off),
1059 Self::first_at_or_after(&self.mark_ends, off + 1),
1060 ) {
1061 (Some(a), Some(b)) => Some(a.min(b)),
1062 (a, b) => a.or(b),
1063 }
1064 }
1065
1066 /// The previous place the caret rests before `off` — the mirror of
1067 /// [`caret_stop_after`](Self::caret_stop_after), what Left walks.
1068 pub fn caret_stop_before(&self, off: usize) -> Option<usize> {
1069 self.stop_before(off).max(
1070 off.checked_sub(1)
1071 .and_then(|o| Self::last_at_or_before(&self.mark_ends, o)),
1072 )
1073 }
1074
1075 /// Whether the caret can occupy `row` at all: decoration rows (a table's
1076 /// border rules) are stepped over by vertical motion.
1077 pub fn row_is_navigable(&self, row: usize) -> bool {
1078 self.rows.get(row).is_some_and(|r| !r.decoration)
1079 }
1080
1081 /// The first offset the caret can rest at on `row` — its first stop, or the
1082 /// row's own end when it holds no text (an empty paragraph). `None` for a
1083 /// decoration row, which holds no caret at all.
1084 ///
1085 /// Not `offset_of_pos(row, 0)`: column 0 of a quoted or listed row is the
1086 /// gutter, and a gutter's `src` points at the *block* it opens, so the stop
1087 /// nearest it is the one on the block's first row rather than on this one.
1088 /// Which is right for a click — the gutter decorates the whole block — and
1089 /// wrong for Home, whose whole question is where *this* row starts.
1090 pub fn row_start(&self, row: usize) -> Option<usize> {
1091 let r = self.rows.get(row).filter(|r| !r.decoration)?;
1092 Some(
1093 r.glyphs
1094 .iter()
1095 .find(|g| g.stop)
1096 .map_or(r.end_src, |g| g.src),
1097 )
1098 }
1099
1100 /// The last row the caret can rest on — the fallback when an offset is past
1101 /// everything rendered (a table's bottom border must not swallow the caret).
1102 fn last_stop_row(&self) -> usize {
1103 (0..self.rows.len())
1104 .rev()
1105 .find(|&r| self.row_is_navigable(r))
1106 .unwrap_or(0)
1107 }
1108
1109 /// The nearest row above `row` the caret can occupy, skipping decoration.
1110 pub fn navigable_above(&self, row: usize) -> Option<usize> {
1111 (0..row.min(self.rows.len()))
1112 .rev()
1113 .find(|&r| self.row_is_navigable(r))
1114 }
1115
1116 /// The nearest row below `row` the caret can occupy, skipping decoration.
1117 pub fn navigable_below(&self, row: usize) -> Option<usize> {
1118 ((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
1119 }
1120
1121 /// The caret stop just before `off` — one press of Left. `None` at the
1122 /// first stop in the document.
1123 ///
1124 /// Runs of decoration (a table border, a cell's alignment padding) are
1125 /// stepped over in a single press: they hold no stop, so they aren't in the
1126 /// table to land on.
1127 pub fn stop_before(&self, off: usize) -> Option<usize> {
1128 let i = self.stops.partition_point(|&s| s < off);
1129 i.checked_sub(1).map(|i| self.stops[i])
1130 }
1131
1132 /// The caret stop just after `off` — one press of Right. `None` at the last
1133 /// stop in the document.
1134 pub fn stop_after(&self, off: usize) -> Option<usize> {
1135 let i = self.stops.partition_point(|&s| s <= off);
1136 self.stops.get(i).copied()
1137 }
1138
1139 /// The first caret stop at or past `off` — where the caret at a hidden
1140 /// offset is *drawn*, and so where a rightward walk over the rendered text
1141 /// starts from.
1142 pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
1143 let i = self.stops.partition_point(|&s| s < off);
1144 self.stops.get(i).copied()
1145 }
1146
1147 /// The last caret stop at or before `off` — where a leftward walk starts
1148 /// from. Snapping the way the walk is headed, rather than always forward,
1149 /// is what keeps a leftward motion from ever moving the caret right.
1150 pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
1151 let i = self.stops.partition_point(|&s| s <= off);
1152 i.checked_sub(1).map(|i| self.stops[i])
1153 }
1154
1155 /// Whether the caret may rest at `off` — the invariant every motion in this
1156 /// view has to leave standing. A glyph stop, a row's end, or a hidden
1157 /// mark's content end ([`mark_ends`](Self::mark_ends)).
1158 pub fn is_stop(&self, off: usize) -> bool {
1159 self.stops.binary_search(&off).is_ok() || self.mark_ends.binary_search(&off).is_ok()
1160 }
1161
1162 /// The visible text a caret crosses walking rightward from `from` up to
1163 /// (but not including) `to` — `UITextInput.text(in:)`'s `[from, to)` in
1164 /// *this* view. A hidden inline-mark delimiter (`**`, `` ` ``, `_`, an
1165 /// escape backslash) never got a glyph in the first place — see
1166 /// [`push_text`]/[`synth`] — so it contributes nothing; what's left is
1167 /// what's drawn on screen for that span, one character per caret stop.
1168 ///
1169 /// **Exactly one character per stop** is the contract, and it is the
1170 /// system text input's, not a nicety: `UITextInput`'s tokenizer reads a
1171 /// window of this text around a tap, indexes into it by the integer
1172 /// `offset(from:to:)` reports (`distance_offset` in `leaf-ffi`, a count of
1173 /// [`stop_after`](Self::stop_after) hops), finds a word boundary at some
1174 /// character index, and hands the delta back through
1175 /// `position(from:offset:)`, which hops stops again. If the text ever
1176 /// spends a character on something that is not a stop, or a stop on
1177 /// nothing, every index past that point is off by one and the word the
1178 /// reader double-tapped comes back shifted — into the header row of a
1179 /// table, or one letter short. So a stop that draws a glyph is spelled
1180 /// as that glyph, and a stop that draws none is spelled `'\n'`:
1181 ///
1182 /// - a row's own end stop ([`VRow::end_src`]) — the caret home past a
1183 /// paragraph's, heading's, list item's, or code line's last glyph. This
1184 /// is also what keeps two blocks' words apart: without it the last word
1185 /// of one paragraph and the first of the next read as one run of
1186 /// letters (`"…edb\n\nhello\n"` came back as `"edbhello"`), and the
1187 /// tokenizer selected across the boundary. A list item's end is a
1188 /// row end like any other, though no blank gap row follows it.
1189 /// - a table cell's end, which [`push_table_row`] draws as the gutter
1190 /// space before the next `│` so the caret has somewhere to stand past
1191 /// the cell's last character. To a reader of *this* text a cell ends a
1192 /// line: spelled as a space, a touch surface that lands a tap at a
1193 /// word's end past the space that follows it stepped into the next
1194 /// cell — or the next row, from the last column.
1195 ///
1196 /// A hidden mark's content end ([`mark_ends`](Self::mark_ends)) is a place
1197 /// the caret rests but not a stop the walks above count, so it has no
1198 /// character here either; `from` is snapped to the glyph stop drawn at
1199 /// the same spot first, exactly as [`snap_to_glyph_stop`] does for those
1200 /// walks. `to` is left as given, so a stop landing exactly on it is still
1201 /// excluded — the same half-open range `distance_offset`'s loop counts.
1202 ///
1203 /// [`push_table_row`]: Builder::push_table_row
1204 /// [`snap_to_glyph_stop`]: Self::snap_to_glyph_stop
1205 pub fn visible_text(&self, from: usize, to: usize) -> String {
1206 self.visible_items(from, to)
1207 .into_iter()
1208 .map(|(_, ch)| ch.unwrap_or('\n'))
1209 .collect()
1210 }
1211
1212 /// The UTF-16 length of `visible_text(from, to)` — what an `NSRange`
1213 /// location into that text is, without building the string.
1214 ///
1215 /// AppKit's `NSTextInputClient` and `NSAccessibility` speak in UTF-16
1216 /// units of *the text as the system sees it*, which for leaf is the visible
1217 /// text — delimiters hidden. A frontend reporting its selection to the
1218 /// system converts each end with this and gets back an index into the
1219 /// string `visible_text(0, end)` returns, which is exactly what the system
1220 /// will index into.
1221 pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
1222 let (lo, hi) = self.visible_span(from, to);
1223 let utf16 = &self.spelling().utf16;
1224 utf16[hi] - utf16[lo]
1225 }
1226
1227 /// The inverse of `visible_utf16_len(0, ·)`: the source offset of the
1228 /// visible character a UTF-16 index into `visible_text(0, to)` lands on.
1229 ///
1230 /// An index inside a surrogate pair resolves to the character that owns
1231 /// it; one at or past the end of the text returns `None`, so a caller can
1232 /// substitute the document's end stop. The `\n` a row's or a cell's end
1233 /// is spelled with resolves to that end stop — a caret home, so a caller
1234 /// placing a caret there needs no snap.
1235 pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
1236 let (lo, hi) = self.visible_span(0, to);
1237 let utf16 = &self.spelling().utf16;
1238 // The stop whose text ends past the index is the one it lands on; a
1239 // stop whose text ends at or before it lies wholly before it.
1240 let target = utf16[lo] + index;
1241 let i = lo + utf16[lo + 1..=hi].partition_point(|&end| end <= target);
1242 (i < hi).then(|| self.stops[i])
1243 }
1244
1245 /// The items `visible_text` spells, in order — one per caret stop in
1246 /// `[from, to)`, keyed by the stop's source offset: the glyph it draws
1247 /// (`Some`), or `None` for a stop with no character of its own, which the
1248 /// text spells `'\n'`. See [`visible_text`](Self::visible_text) for which
1249 /// stops those are and why.
1250 fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
1251 let (lo, hi) = self.visible_span(from, to);
1252 self.stops[lo..hi]
1253 .iter()
1254 .copied()
1255 .zip(self.spelling().chars[lo..hi].iter().copied())
1256 .collect()
1257 }
1258
1259 /// The stops `visible_text(from, to)` spells, as a range into the stop
1260 /// table: from the glyph stop `from` snaps to, up to but excluding the
1261 /// first stop at or past `to`.
1262 fn visible_span(&self, from: usize, to: usize) -> (usize, usize) {
1263 let from = self.snap_to_glyph_stop(from);
1264 let lo = self.stops.partition_point(|&s| s < from);
1265 // The document's last stop is the end of the text, not a character in
1266 // it: `distance_offset` has no hop past it to pair one with.
1267 let last = self.stops.len().saturating_sub(1);
1268 let hi = self.stops.partition_point(|&s| s < to).min(last).max(lo);
1269 (lo, hi)
1270 }
1271
1272 /// The [`Spelling`] of this map's stops, tabulated on first use.
1273 fn spelling(&self) -> &Spelling {
1274 self.spelling.get_or_init(|| {
1275 // The glyph each stop draws — the first at its offset in row
1276 // order, since a media row's label glyphs all share the media's
1277 // offset and a wrapped line's end is the next line's first glyph.
1278 // Row order only follows source order outside a table's wrapped
1279 // cells (see `pos_of_offset`), which is why each glyph looks its
1280 // stop up rather than the two being walked side by side.
1281 //
1282 // Within a row offsets ascend, so a cursor into the stop table
1283 // walks forward with the row's glyphs and the whole pass is a
1284 // read of the glyphs plus one search per row — a keystroke's
1285 // first conversion pays for this, so it is kept to that. The
1286 // search is repeated only if a row's offsets step back, which
1287 // none does; the walk is correct either way.
1288 let stops = &self.stops;
1289 let mut chars: Vec<Option<char>> = vec![None; stops.len()];
1290 for row in self.rows.iter().filter(|r| !r.decoration) {
1291 let mut i = 0;
1292 let mut prev = usize::MAX;
1293 for g in row.glyphs.iter().filter(|g| g.stop) {
1294 if prev == usize::MAX || g.src < prev {
1295 i = stops.partition_point(|&s| s < g.src);
1296 } else {
1297 while i < stops.len() && stops[i] < g.src {
1298 i += 1;
1299 }
1300 }
1301 prev = g.src;
1302 if i < stops.len() && stops[i] == g.src && chars[i].is_none() {
1303 chars[i] = Some(g.ch);
1304 }
1305 }
1306 }
1307 // A cell's end stop has a glyph (the gutter space) but is spelled
1308 // as a line end; the structural grid is where the cells' offsets
1309 // live.
1310 for cell in self
1311 .tables
1312 .iter()
1313 .flat_map(|t| t.grid.iter())
1314 .flat_map(|r| r.cells.iter())
1315 {
1316 if let Ok(i) = self.stops.binary_search(&cell.end) {
1317 chars[i] = None;
1318 }
1319 }
1320 let mut utf16 = Vec::with_capacity(chars.len() + 1);
1321 utf16.push(0);
1322 for ch in &chars {
1323 let before = *utf16.last().unwrap_or(&0);
1324 utf16.push(before + ch.map_or(1, char::len_utf16));
1325 }
1326 Spelling { chars, utf16 }
1327 })
1328 }
1329}
1330
1331/// Collect the caret stops of a laid-out grid: every stop glyph's offset plus
1332/// every row's end, ascending and deduplicated. Duplicates are the norm rather
1333/// than the exception — a wrapped line's end is the same offset as the next
1334/// line's first glyph — and collapsing them is what makes one press of Left or
1335/// Right cross exactly one stop.
1336fn collect_stops(rows: &[VRow]) -> Vec<usize> {
1337 let mut stops: Vec<usize> = rows
1338 .iter()
1339 .filter(|r| !r.decoration)
1340 .flat_map(|r| {
1341 r.glyphs
1342 .iter()
1343 .filter(|g| g.stop)
1344 .map(|g| g.src)
1345 .chain(std::iter::once(r.end_src))
1346 })
1347 .collect();
1348 stops.sort_unstable();
1349 stops.dedup();
1350 stops
1351}
1352
1353/// Collect every row's [`VRow::mark_ends`] into one ascending, deduplicated
1354/// table — the peer of [`collect_stops`] for the caret's second home at the
1355/// end of a hidden mark. A mark that closes at a row's end coincides with the
1356/// row's own end stop; that offset is in both tables, and harmlessly so.
1357fn collect_mark_ends(rows: &[VRow]) -> Vec<usize> {
1358 let mut ends: Vec<usize> = rows
1359 .iter()
1360 .filter(|r| !r.decoration)
1361 .flat_map(|r| r.mark_ends.iter().copied())
1362 .collect();
1363 ends.sort_unstable();
1364 ends.dedup();
1365 ends
1366}
1367
1368/// Group the rows tagged [`VRow::code`] into one [`CodeBlockInfo`] per maximal
1369/// run — the block-level view a frontend needs to box and scroll each code
1370/// block. Two code blocks are always parted by the blank separator row a block
1371/// boundary is spelled with (never itself a code row), so a contiguous run is
1372/// exactly one block. Derived from the final rows rather than tracked through
1373/// the builder so it comes out right no matter how [`build_cached`] and
1374/// [`build_spliced`] shuffle rows around.
1375fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
1376 let mut blocks = Vec::new();
1377 let mut start: Option<usize> = None;
1378 for (i, row) in rows.iter().enumerate() {
1379 match (row.code, start) {
1380 (true, None) => start = Some(i),
1381 (false, Some(s)) => {
1382 blocks.push(CodeBlockInfo {
1383 rows_span: s..i,
1384 lang: rows[s].code_lang.clone(),
1385 });
1386 start = None;
1387 }
1388 _ => {}
1389 }
1390 }
1391 if let Some(s) = start {
1392 blocks.push(CodeBlockInfo {
1393 rows_span: s..rows.len(),
1394 lang: rows[s].code_lang.clone(),
1395 });
1396 }
1397 blocks
1398}
1399
1400/// The value of `node`'s `key` attribute, if it carries one *with* a value. A
1401/// bare attribute (`controls`, `muted`) has a `None` value and so reads as
1402/// absent here — a caller wanting presence-not-value tests the list directly.
1403/// Shared by the media element and `<source>` readers.
1404fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
1405 node.attrs
1406 .iter()
1407 .find(|(k, _)| k == key)
1408 .and_then(|(_, v)| v.clone())
1409}
1410
1411/// Collect one [`MediaInfo`] per row carrying a [`VRow::media`] mark — the
1412/// block-level view a frontend needs to replace each placeholder row with a real
1413/// picture. The mark rides the block's *first* row and names how many rows the
1414/// media reserves ([`MediaMark::rows`]); the rows below it are blank
1415/// [`decoration`](VRow::decoration) fillers that hold the vertical space and no
1416/// caret. So the span runs from the marked row across those fillers. Derived from
1417/// the final rows rather than tracked through the builder so it survives however
1418/// [`build_cached`] and [`build_spliced`] shuffle rows around.
1419fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
1420 rows.iter()
1421 .enumerate()
1422 .filter_map(|(i, row)| {
1423 row.media.as_ref().map(|m| MediaInfo {
1424 rows_span: i..i + m.rows.max(1),
1425 kind: m.kind,
1426 destination: m.destination.clone(),
1427 sources: m.sources.clone(),
1428 alt: m.alt.clone(),
1429 poster: m.poster.clone(),
1430 })
1431 })
1432 .collect()
1433}
1434
1435/// Re-label the drawn block boundaries either side of a block-level media
1436/// placeholder, so the pair a frontend spaces by names the picture.
1437///
1438/// [`BlockClass::from_node_kind`] classifies the node the walk is standing on,
1439/// and a block image is never a node of its own: [`Builder::media_only`] promotes
1440/// its *wrapper* — a `paragraph`, or the `container` a `<video>`/`<audio>`
1441/// arrives as — so the gap above a picture reported [`BlockClass::Paragraph`] and
1442/// the gap above a movie reported [`BlockClass::Directive`], the class a frontend
1443/// paints a tinted panel for. [`BlockClass::Media`] was unreachable in
1444/// consequence: the vocabulary named a kind no frontend could ever be told about.
1445///
1446/// Done as a pass over the finished rows rather than inside the walk because
1447/// only the rows know. The incremental top-level walk carries no node arena at
1448/// all (`nodes: &[]`) and can classify by kind alone, so teaching the wrapper
1449/// promotion to the whole-arena walk would label the full and incremental builds
1450/// differently — the exact drift that walk's own comment forbids. Both builds
1451/// emit the same [`VRow::media`] marks, so both reach the same answer here. The
1452/// [`media_spans`] / [`code_block_spans`] pattern.
1453///
1454/// One gap can be spelled with several rows — [`Builder::emit_separators_before`]
1455/// draws the row that closes the block above and the row that opens the block
1456/// below, with any extra blank source lines navigable between them — and gives
1457/// every one of them the same [`Boundary`]. So the walk crosses those navigable
1458/// blanks and relabels the whole run, stopping at the first row that is neither.
1459fn label_media_boundaries(rows: &mut [VRow]) {
1460 // A display formula's placeholder is promoted from its paragraph exactly
1461 // as a picture is, and its gaps are relabelled the same way, as `Math`.
1462 let spans: Vec<(Range<usize>, BlockClass)> = rows
1463 .iter()
1464 .enumerate()
1465 .filter_map(|(i, row)| {
1466 if let Some(m) = &row.media {
1467 Some((i..i + m.rows.max(1), BlockClass::Media))
1468 } else {
1469 row.math
1470 .iter()
1471 .find(|m| m.glyph.is_none())
1472 .map(|m| (i..i + m.rows.max(1), BlockClass::Math))
1473 }
1474 })
1475 .collect();
1476 // A row inside one gap: a drawn boundary to relabel, or one of the navigable
1477 // blank lines sitting between two drawn ones. Anything else ends the run.
1478 fn in_gap(row: &VRow) -> bool {
1479 row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
1480 }
1481 for (span, class) in spans {
1482 for i in (0..span.start).rev() {
1483 if !in_gap(&rows[i]) {
1484 break;
1485 }
1486 if let Some(b) = rows[i].boundary.as_mut() {
1487 b.below = class;
1488 }
1489 }
1490 for row in rows.iter_mut().skip(span.end) {
1491 if !in_gap(row) {
1492 break;
1493 }
1494 if let Some(b) = row.boundary.as_mut() {
1495 b.above = class;
1496 }
1497 }
1498 }
1499}
1500
1501/// Collect one [`DirectiveInfo`] per row carrying a [`VRow::leaf_directive`]
1502/// mark — the block-level view a frontend needs to replace each placeholder row
1503/// with whatever the directive means to it. The peer of [`media_spans`], derived
1504/// from the final rows for the same reason: it survives however [`build_cached`]
1505/// and [`build_spliced`] shuffle rows around.
1506fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
1507 rows.iter()
1508 .enumerate()
1509 .filter_map(|(i, row)| {
1510 row.leaf_directive.as_ref().map(|m| DirectiveInfo {
1511 rows_span: i..i + m.rows.max(1),
1512 name: m.name.clone(),
1513 attrs: m.attrs.clone(),
1514 label: m.label.clone(),
1515 })
1516 })
1517 .collect()
1518}
1519
1520/// Collect one [`MathInfo`] per [`VRow::math`] mark — the peer of
1521/// [`media_spans`] and [`directive_spans`], derived from the final rows for the
1522/// same reason. A block mark spans its fillers; an atom spans its own row.
1523fn math_spans(rows: &[VRow]) -> Vec<MathInfo> {
1524 rows.iter()
1525 .enumerate()
1526 .flat_map(|(i, row)| {
1527 row.math.iter().map(move |m| {
1528 let src = match m.glyph {
1529 Some(g) => row.glyphs.get(g).map_or(row.end_src, |g| g.src),
1530 None => row
1531 .glyphs
1532 .iter()
1533 .find(|g| g.style.role == Role::Math)
1534 .map_or(row.end_src, |g| g.src),
1535 };
1536 MathInfo {
1537 rows_span: i..i + m.rows.max(1),
1538 row: i,
1539 glyph: m.glyph,
1540 tex: m.tex.clone(),
1541 display: m.display,
1542 src,
1543 }
1544 })
1545 })
1546 .collect()
1547}
1548
1549/// The source range of a fenced code block's info string — everything on the
1550/// opening line past the fence (`` ```rust `` → the `rust`). `block_start` is the
1551/// code block node's `span.start`. `None` for an indented code block, which
1552/// opens with no fence to carry one. The range is empty for a fence written
1553/// bare (`` ``` `` alone), which is exactly where a language would be inserted.
1554///
1555/// A block inside a quote or a list item starts at its line's start, with the
1556/// container's marker in front of the fence — `> ```rust`, `- ```rust` — so
1557/// the marker is skipped first, and the fence's three-space indent allowance
1558/// is measured from where the container's content begins: past the marker on
1559/// the fence's own line, or, on a continuation line, from the content column
1560/// of the list item above. That is what keeps an *indented* code block inside
1561/// a list item answering `None`.
1562///
1563/// Shared by the WYSIWYG builder (to label the box) and [`crate::Doc`] (to edit
1564/// the label through a prompt), so the two agree on where the language lives.
1565pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
1566 let rest = source.get(block_start..)?;
1567 let line_len = rest.find('\n').unwrap_or(rest.len());
1568 let line = &rest[..line_len];
1569 let (depth, quoted) = strip_quote_markers(line);
1570 let mut content = quoted;
1571 let mut listed = false;
1572 while let Some(n) = list_marker_len(&line[content..], 3) {
1573 content += n;
1574 listed = true;
1575 }
1576 let lead = leading_spaces(&line[content..]);
1577 // No marker of its own: a continuation line, whose leading spaces include
1578 // the enclosing item's indent. Only when the block owns its line — a span
1579 // that starts mid-line has had its container prefix measured off already.
1580 let at_line_start = block_start == 0 || source.as_bytes()[block_start - 1] == b'\n';
1581 let base = if listed || !at_line_start {
1582 0
1583 } else {
1584 item_content_column(&source[..block_start], depth, lead)
1585 };
1586 // A fence may be indented up to three spaces; past that it opens with a run
1587 // of the same fence character.
1588 if lead - base > 3 {
1589 return None;
1590 }
1591 let at = content + lead;
1592 let fence = line[at..].chars().next()?;
1593 if fence != '`' && fence != '~' {
1594 return None; // an indented block, not a fenced one
1595 }
1596 let fence_len = line[at..].chars().take_while(|&c| c == fence).count();
1597 let info_start = block_start + at + fence_len;
1598 Some(info_start..block_start + line_len)
1599}
1600
1601/// The spaces a line opens with.
1602fn leading_spaces(s: &str) -> usize {
1603 s.bytes().take_while(|&b| b == b' ').count()
1604}
1605
1606/// A line's block-quote markers — each `>` behind up to three spaces, with the
1607/// one space after it — as the quote depth and the byte offset past them.
1608fn strip_quote_markers(line: &str) -> (usize, usize) {
1609 let b = line.as_bytes();
1610 let (mut depth, mut i) = (0, 0);
1611 loop {
1612 let j = i + leading_spaces(&line[i..]);
1613 if j - i > 3 || b.get(j) != Some(&b'>') {
1614 return (depth, i);
1615 }
1616 depth += 1;
1617 i = j + 1 + usize::from(b.get(j + 1) == Some(&b' '));
1618 }
1619}
1620
1621/// The length of a list marker opening `s` — its indent (at most `max_lead`
1622/// spaces), a bullet or an ordinal, and the one space the item's content
1623/// starts after — or `None` when `s` opens with none.
1624fn list_marker_len(s: &str, max_lead: usize) -> Option<usize> {
1625 let b = s.as_bytes();
1626 let lead = leading_spaces(s);
1627 if lead > max_lead {
1628 return None;
1629 }
1630 let mark = match b.get(lead)? {
1631 b'-' | b'*' | b'+' => 1,
1632 c if c.is_ascii_digit() => {
1633 let digits = b[lead..].iter().take_while(|c| c.is_ascii_digit()).count();
1634 if digits > 9 || !matches!(b.get(lead + digits), Some(b'.' | b')')) {
1635 return None;
1636 }
1637 digits + 1
1638 }
1639 _ => return None,
1640 };
1641 match b.get(lead + mark) {
1642 Some(b' ') => Some(lead + mark + 1),
1643 None => Some(lead + mark),
1644 _ => None,
1645 }
1646}
1647
1648/// The content column of the list item a continuation line indented `lead`
1649/// spaces (past `depth` quote markers) sits in: the nearest item above whose
1650/// content starts at or before `lead`. `0` when a line at the margin, or a
1651/// change of quote depth, says there is no such item.
1652fn item_content_column(before: &str, depth: usize, lead: usize) -> usize {
1653 for line in before.strip_suffix('\n').unwrap_or(before).rsplit('\n') {
1654 let (d, q) = strip_quote_markers(line);
1655 let s = &line[q..];
1656 if s.trim().is_empty() {
1657 continue;
1658 }
1659 if d != depth {
1660 return 0;
1661 }
1662 let mut col = 0;
1663 while let Some(n) = list_marker_len(&s[col..], usize::MAX) {
1664 col += n;
1665 }
1666 if col > 0 && col <= lead {
1667 return col;
1668 }
1669 if col == 0 && leading_spaces(s) == 0 {
1670 return 0;
1671 }
1672 }
1673 0
1674}
1675
1676/// A fenced code block's language for display: its info string, trimmed, or
1677/// `None` when there's no fence or the fence carries no language. The trimmed
1678/// text is what a frontend labels the box with; [`code_info_span`] is what an
1679/// edit replaces.
1680pub fn code_language(source: &str, block_start: usize) -> Option<String> {
1681 let span = code_info_span(source, block_start)?;
1682 let text = source.get(span)?.trim();
1683 (!text.is_empty()).then(|| text.to_string())
1684}
1685
1686/// A horizontal rule's dash count when the map isn't wrapping to a column grid
1687/// (the GUI, which wraps at pixel width): a fixed, sane width the frontend can
1688/// paint or re-wrap, instead of a runaway count from an unbounded wrap width.
1689const UNWRAPPED_RULE_WIDTH: usize = 40;
1690
1691/// The one glyph an inline formula renders to on a surface that paints
1692/// pictures in a line — what a plain surface handed such a map would show.
1693/// One column wide, so a column-wrapped build's arithmetic still holds; the
1694/// frontend that asked for atoms draws the picture as wide as it is.
1695const MATH_ATOM: char = '∑';
1696
1697/// What the surface a map is built for can paint, and how tall its pictures
1698/// came out — the things about a frontend that change the rows core lays
1699/// down, gathered from the frontend by [`crate::Doc`] and threaded into every
1700/// build. The defaults are the plain surface: nothing painted in a line, every
1701/// picture a one-row placeholder, which is what every test that passes
1702/// `Surface::default()` gets and what every build got before there was one.
1703#[derive(Clone, Debug, Default, PartialEq, Eq)]
1704pub struct Surface {
1705 /// How many visual rows each block image reserves, keyed by its
1706 /// destination — the frontend's per-image height, set through
1707 /// [`crate::Doc::set_media_rows`] so [`Builder::block_media`] can size the
1708 /// placeholder without core doing any I/O. A destination absent from the
1709 /// map (or a `0`/`1` entry) reserves the bare one-row placeholder.
1710 pub media_rows: HashMap<String, usize>,
1711 /// The same for each display formula, keyed by its TeX verbatim (the
1712 /// [`MathMark::tex`] a frontend was handed) — set through
1713 /// [`crate::Doc::set_math_rows`] once the frontend has typeset and
1714 /// measured the picture.
1715 pub math_rows: HashMap<String, usize>,
1716 /// The same for each leaf directive, keyed by its [`DirectiveKey`] — set
1717 /// through [`crate::Doc::set_directive_rows`] by a frontend that draws a
1718 /// host's lines for the directive over reserved rows, as a terminal does.
1719 pub directive_rows: HashMap<DirectiveKey, usize>,
1720 /// Whether the surface can paint a picture *inside* a line of text, so an
1721 /// inline formula may render to one atom glyph it draws over
1722 /// ([`MathInfo`]). A pixel-laid-out frontend says yes; a terminal cannot
1723 /// composite an image over one cell, and leaves this off to keep an inline
1724 /// formula as the code-styled TeX it always showed. Off by default.
1725 pub inline_pictures: bool,
1726}
1727
1728/// The one source line rendering its markup raw — the caret's, when the mode
1729/// or the content asks for it — and how much of the markup that means. See
1730/// [`crate::Doc::reveal_line`], which decides both.
1731///
1732/// Two grades because two things ask. Under
1733/// [`MarkupMode::Full`](crate::MarkupMode::Full) *every* delimiter on the line
1734/// comes back (`markup: true`). In the two hidden modes a formula still
1735/// reveals — its content is its TeX and not its picture, so hiding is not
1736/// enough — and the line is threaded through with `markup: false`: a math node
1737/// meeting it shows its source, and an emphasis meeting it hides its
1738/// asterisks as it always did.
1739#[derive(Clone, Debug, PartialEq, Eq)]
1740pub struct Reveal {
1741 /// The source byte range of the line, newline excluded — empty but present
1742 /// on a blank line.
1743 pub line: Range<usize>,
1744 /// Whether ordinary inline markup reveals on it too, or only math.
1745 pub markup: bool,
1746}
1747
1748impl Reveal {
1749 /// The caret's line under `MarkupMode::Full`: everything on it reveals.
1750 pub fn full(line: Range<usize>) -> Self {
1751 Reveal { line, markup: true }
1752 }
1753
1754 /// The caret's line in a hidden mode, where only a formula reveals.
1755 pub fn math(line: Range<usize>) -> Self {
1756 Reveal {
1757 line,
1758 markup: false,
1759 }
1760 }
1761
1762 /// Whether `span` meets this line — the test every arm that reveals runs.
1763 ///
1764 /// Touching at an endpoint counts: an emphasis ending exactly where the
1765 /// line does is on that line, and a zero-length reveal range (the caret
1766 /// alone on a blank line) still meets a node that starts there. The test
1767 /// is deliberately generous — the failure it avoids is revealing one
1768 /// delimiter of a pair while hiding the other, which looks like corruption
1769 /// rather than like markup.
1770 fn meets(&self, span: &Range<usize>) -> bool {
1771 span.start <= self.line.end && self.line.start <= span.end
1772 }
1773}
1774
1775/// Render the document to a [`VisualMap`]. `wrap` is the column budget for
1776/// word-wrapping (`Some` for the monospace TUI), or `None` to emit one row per
1777/// block — the GUI does its own proportional pixel wrapping over these rows.
1778/// Text and offsets come from the AST (`str` nodes carry the verbatim source
1779/// slice and an exact span), so the original source string isn't needed here.
1780pub fn build(
1781 nodes: &[FlatNode],
1782 source: &str,
1783 wrap: Option<usize>,
1784 preserve_soft: bool,
1785 surface: &Surface,
1786 reveal: Option<Reveal>,
1787) -> VisualMap {
1788 let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
1789 return VisualMap::default();
1790 };
1791 let top = top_level(nodes, doc);
1792 let mut b = Builder {
1793 nodes,
1794 source,
1795 wrap: wrap.map(|w| w.max(8)),
1796 rows: Vec::new(),
1797 tables: Vec::new(),
1798 last_off: 0,
1799 stepped_over: 0,
1800 surface,
1801 break_glyph: Cell::new(' '),
1802 preserve_soft,
1803 reveal: reveal.clone(),
1804 pending_mark_ends: RefCell::new(Vec::new()),
1805 pending_math: RefCell::new(Vec::new()),
1806 saw_math: Cell::new(false),
1807 presentation: Presentation::default(),
1808 faces: RefCell::new(FaceTable::default()),
1809 };
1810 // The hidden frontmatter's end is the baseline for the leading and trailing
1811 // blank rows and for the caret floor — see [`hidden_prefix_end`].
1812 // `top_level` has already dropped every `metadata` child, so read it off
1813 // the arena.
1814 let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
1815 let last_drawn = b.top_blocks(&top, hidden_end);
1816 b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
1817 let content_start = floor_of(
1818 &b.rows,
1819 top.first().map_or(hidden_end, |&i| nodes[i].span.start),
1820 );
1821 let stops = collect_stops(&b.rows);
1822 let mark_ends = collect_mark_ends(&b.rows);
1823 label_media_boundaries(&mut b.rows);
1824 let code_blocks = code_block_spans(&b.rows);
1825 let media = media_spans(&b.rows);
1826 let directives = directive_spans(&b.rows);
1827 let math = math_spans(&b.rows);
1828 VisualMap {
1829 rows: b.rows,
1830 content_start,
1831 stops,
1832 mark_ends,
1833 tables: b.tables,
1834 code_blocks,
1835 media,
1836 directives,
1837 math,
1838 faces: b.faces.into_inner(),
1839 spelling: OnceLock::new(),
1840 }
1841}
1842
1843/// Like [`build`], but reuses a persistent [`BlockCache`] so an edit re-renders
1844/// only the top-level blocks whose source bytes changed *and* marshals only
1845/// those blocks from twig instead of the whole arena.
1846///
1847/// `top` is the document's top-level blocks — twig's `child_spans` of the doc
1848/// root: `(node_id, kind, span)` for each, in order. `fetch_subtree(node_id)`
1849/// marshals one block's subtree (local-indexed, root at 0) and is called *only*
1850/// for a block that missed the cache, i.e. one that actually changed. So a
1851/// keystroke marshals one small subtree, not ~20k nodes. The result is
1852/// byte-for-byte identical to [`build`] on the same document (the
1853/// `build_cached_matches_build` test pins this); [`build`] stays the cache-free,
1854/// whole-arena reference. This is the entry point [`crate::Doc`] uses.
1855// One builder, and every one of these is a distinct input to the same layout
1856// pass — a struct of them would be built at the one call site and unpacked
1857// here, which is the same arguments with an extra name in the way.
1858#[allow(clippy::too_many_arguments)]
1859pub fn build_cached(
1860 top: &[QueryMatch],
1861 source: &str,
1862 wrap: Option<usize>,
1863 preserve_soft: bool,
1864 surface: &Surface,
1865 reveal: Option<Reveal>,
1866 cache: &mut BlockCache,
1867 mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1868) -> VisualMap {
1869 let wrap = wrap.map(|w| w.max(8));
1870
1871 // Wrapping is a function of the width, so a width change makes every cached
1872 // row's wrap wrong: start the cache over.
1873 if cache.wrap != Some(wrap) {
1874 cache.entries.clear();
1875 cache.wrap = Some(wrap);
1876 }
1877 cache.generation = cache.generation.wrapping_add(1);
1878
1879 // Frontmatter (a leading `metadata` block) is document metadata, not prose:
1880 // hidden in the rich view exactly as [`Builder::blocks`] skips it.
1881 let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1882
1883 // The outer builder only accumulates rows/tables and spells block boundaries
1884 // — both a function of the source and `last_off`, never of a node array — so
1885 // it carries an empty `nodes`. Each changed block is rendered by a *fresh*
1886 // builder over that block's subtree.
1887 let mut b = Builder {
1888 nodes: &[],
1889 source,
1890 wrap,
1891 rows: Vec::new(),
1892 tables: Vec::new(),
1893 last_off: 0,
1894 stepped_over: 0,
1895 surface,
1896 break_glyph: Cell::new(' '),
1897 preserve_soft,
1898 reveal: reveal.clone(),
1899 pending_mark_ends: RefCell::new(Vec::new()),
1900 pending_math: RefCell::new(Vec::new()),
1901 saw_math: Cell::new(false),
1902 presentation: Presentation::default(),
1903 faces: RefCell::new(FaceTable::default()),
1904 };
1905
1906 // Record the per-block row decomposition as we go, so a later
1907 // [`build_spliced`] can patch one block without rebuilding the map.
1908 let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
1909 // The document's face table, assembled block by block: from the walk on a
1910 // miss, from what the entry stored on a hit. The outer builder walks no
1911 // attributes of its own (it spells boundaries), so it never adds to it.
1912 let mut faces = FaceTable::default();
1913 let mut all_shift_safe = true;
1914 // The class of the last block that drew anything: what the next separator
1915 // closes, and what the trailing blank lines close at the end. A hidden block
1916 // (a comment) never becomes it — see [`Builder::block_or_hidden`], whose
1917 // step-over this loop repeats for the incremental walk.
1918 let mut above: Option<BlockClass> = None;
1919 let hidden_end = hidden_prefix_end(
1920 source,
1921 top.iter()
1922 .filter(|m| m.kind == Kind::Metadata)
1923 .map(|m| m.span.end)
1924 .next_back(),
1925 );
1926 for block in &blocks {
1927 let start = block.span.start;
1928 let before_sep = b.rows.len();
1929 // This walker has no node arena at all (see the `nodes: &[]` above),
1930 // but a top-level query match carries its kind — the same string
1931 // `BlockClass::from_node_kind` classifies for the whole-arena walk, so
1932 // the incremental and full builds label a boundary identically.
1933 let below = BlockClass::from_node_kind(&block.kind);
1934 if let Some(above) = above {
1935 b.emit_separators_before(start, &[], true, Boundary { above, below });
1936 } else {
1937 let from = b.leading_from(hidden_end);
1938 b.emit_leading_blank_lines(from, start, below);
1939 }
1940 let after_sep = b.rows.len();
1941 let bytes = block_bytes(source, &block.span);
1942 let hash = block_hash(bytes);
1943 // How this block meets the reveal line, if at all — part of its cache
1944 // key, since the same bytes render differently on the caret's line.
1945 let rkey = reveal_key(&reveal, &block.span);
1946
1947 // Hit: clone the block's rows shifted to its current offset and restore
1948 // the (shifted) `last_off` so the next separator lands right — no marshal.
1949 // Only shift-safe blocks are ever cached, so a hit is safe by construction.
1950 let mut has_math = false;
1951 if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
1952 faces.merge(&hit.faces);
1953 has_math = hit.has_math;
1954 let delta = start as isize - hit.built_start as isize;
1955 for row in &hit.rows {
1956 b.rows.push(shift_row(row, delta));
1957 }
1958 b.last_off = (hit.last_off as isize + delta) as usize;
1959 // `0` is a block that stepped over nothing, and no answer to shift.
1960 if hit.stepped_over > 0 {
1961 let stepped = (hit.stepped_over as isize + delta) as usize;
1962 b.stepped_over = b.stepped_over.max(stepped);
1963 }
1964 } else {
1965 // Miss: marshal just this block's subtree and render it. A subtree is
1966 // self-contained with local ids (root at 0) and absolute spans, so a
1967 // fresh builder over it produces the same rows the whole-arena path
1968 // would. An empty subtree (twig couldn't hand it back) renders nothing.
1969 let subtree = fetch_subtree(block.node_id);
1970 if !subtree.is_empty() {
1971 let mut sub = Builder {
1972 nodes: &subtree,
1973 source,
1974 wrap,
1975 rows: Vec::new(),
1976 tables: Vec::new(),
1977 last_off: 0,
1978 stepped_over: 0,
1979 surface,
1980 break_glyph: Cell::new(' '),
1981 preserve_soft,
1982 reveal: reveal.clone(),
1983 pending_mark_ends: RefCell::new(Vec::new()),
1984 pending_math: RefCell::new(Vec::new()),
1985 saw_math: Cell::new(false),
1986 presentation: Presentation::default(),
1987 faces: RefCell::new(FaceTable::default()),
1988 };
1989 sub.block(0, &[], &[]);
1990 // A block that drew nothing is stepped over, not stood on: its
1991 // `last_off` is its own end, so the separator after it counts
1992 // from there. The sub-builder started at 0 and never moved, and
1993 // 0 is where the next separator would otherwise count from —
1994 // every line of the document, as a blank row each.
1995 let last_off = if sub.rows.is_empty() {
1996 block.span.end
1997 } else {
1998 sub.last_off
1999 };
2000 let stepped_over = sub.stepped_over;
2001 b.stepped_over = b.stepped_over.max(stepped_over);
2002 // The names this block's glyph ids stand for. They go into the
2003 // document's table *and* into the cache entry, because a hit
2004 // re-emits these rows without walking an attribute again.
2005 let block_faces = sub.faces.into_inner();
2006 faces.merge(&block_faces);
2007 has_math = sub.saw_math.get();
2008 // Cache only a block that is table-free AND renders inside its own
2009 // span: those two are the conditions for reuse-by-shift to be
2010 // correct. A block failing either is re-rendered every build (a
2011 // fresh render always matches a fresh whole-document build).
2012 if sub.tables.is_empty() {
2013 if rows_within(&sub.rows, &block.span) {
2014 cache.store(
2015 hash,
2016 bytes,
2017 start,
2018 sub.rows.clone(),
2019 last_off,
2020 stepped_over,
2021 rkey,
2022 has_math,
2023 block_faces,
2024 );
2025 }
2026 b.rows.extend(sub.rows);
2027 } else {
2028 // A table block is never cached; rebase its row-index
2029 // bookkeeping onto the combined row vector and append.
2030 let base = b.rows.len();
2031 for t in &mut sub.tables {
2032 t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
2033 }
2034 b.rows.extend(sub.rows);
2035 b.tables.extend(sub.tables);
2036 }
2037 b.last_off = last_off;
2038 }
2039 }
2040 let content_rows = b.rows.len() - after_sep;
2041 let sep_rows = if content_rows == 0 {
2042 // Hidden: take back the separator drawn for it, so what stands
2043 // either side meets across one boundary. Its layout entry stays, at
2044 // no rows, so the splice arithmetic still counts one entry per block.
2045 b.rows.truncate(before_sep);
2046 // A cache hit restored the stored `last_off` above; an empty subtree
2047 // (twig couldn't hand it back) restored nothing. Either way the walk
2048 // stands past the block.
2049 b.last_off = b.last_off.max(block.span.end);
2050 b.stepped_over = b.stepped_over.max(block.span.end);
2051 0
2052 } else {
2053 above = Some(BlockClass::from_node_kind(&block.kind));
2054 all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
2055 after_sep - before_sep
2056 };
2057 layout_blocks.push(BlockLayout {
2058 span: block.span.clone(),
2059 kind: block.kind.clone(),
2060 sep_rows,
2061 content_rows,
2062 has_math,
2063 });
2064 }
2065
2066 let before_trailing = b.rows.len();
2067 b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
2068 let trailing_rows = b.rows.len() - before_trailing;
2069
2070 // Evict every entry no block reused this build, so the cache tracks the
2071 // current document instead of growing without bound over a session.
2072 let g = cache.generation;
2073 cache.entries.retain(|_, bucket| {
2074 bucket.retain(|e| e.generation == g);
2075 !bucket.is_empty()
2076 });
2077
2078 cache.layout = Layout {
2079 blocks: layout_blocks,
2080 trailing_rows,
2081 built_len: source.len(),
2082 has_tables: !b.tables.is_empty(),
2083 all_shift_safe,
2084 reveal: reveal.clone(),
2085 };
2086
2087 // The first rendered offset is the first non-metadata block's start — the
2088 // analogue of [`first_content_offset`] for the top-level list. With nothing
2089 // but frontmatter it's the end of that frontmatter, and 0 for an empty
2090 // document ([`hidden_prefix_end`]).
2091 let content_start = floor_of(&b.rows, blocks.first().map_or(hidden_end, |m| m.span.start));
2092 let stops = collect_stops(&b.rows);
2093 let mark_ends = collect_mark_ends(&b.rows);
2094 label_media_boundaries(&mut b.rows);
2095 let code_blocks = code_block_spans(&b.rows);
2096 let media = media_spans(&b.rows);
2097 let directives = directive_spans(&b.rows);
2098 let math = math_spans(&b.rows);
2099 VisualMap {
2100 rows: b.rows,
2101 content_start,
2102 stops,
2103 mark_ends,
2104 tables: b.tables,
2105 code_blocks,
2106 media,
2107 directives,
2108 math,
2109 faces,
2110 spelling: OnceLock::new(),
2111 }
2112}
2113
2114/// The fast path for a single-block edit: patch the previous [`VisualMap`] in
2115/// place rather than reassembling it. Returns `Some(new_map)` when it applies,
2116/// or `None` to tell the caller to fall back to [`build_cached`] (always
2117/// correct). Consumes `prev` either way — on `None` the caller rebuilds from
2118/// scratch and doesn't need it.
2119///
2120/// It applies only when `dirty` (twig's dirty byte range) falls inside exactly
2121/// one top-level block AND the block structure around it is unchanged — verified
2122/// by matching the new `top` list against the previous [`Layout`] block for
2123/// block: kinds unchanged, spans before the edit identical, spans after it
2124/// shifted by the byte delta, count unchanged. Any deviation — a block split or
2125/// merged, a fence opened to swallow later blocks, a table anywhere, a
2126/// multi-block edit — fails the match and returns `None`. That check is what
2127/// makes the byte-range trustworthy: twig's dirty range is exact about *bytes*
2128/// but silent about *reparse*, and the structural match catches the reparse
2129/// effects it can't see.
2130///
2131/// When it applies, the unchanged prefix rows move verbatim, the suffix rows
2132/// shift by the delta *in place* (integer adds, no glyph copy), and only the one
2133/// dirty block is re-marshalled and re-rendered; stops splice the same way by
2134/// offset. So the cost is O(rows after the edit), and nothing before the edit is
2135/// touched. The hash-keyed entry cache is left alone — a later [`build_cached`]
2136/// will miss on the changed block, re-render it, and evict the stale entry, so
2137/// chained splices neither corrupt nor grow it.
2138// One builder, and every one of these is a distinct input to the same layout
2139// pass — a struct of them would be built at the one call site and unpacked
2140// here, which is the same arguments with an extra name in the way.
2141#[allow(clippy::too_many_arguments)]
2142pub fn build_spliced(
2143 prev: VisualMap,
2144 source: &str,
2145 wrap: Option<usize>,
2146 preserve_soft: bool,
2147 top: &[QueryMatch],
2148 dirty: Range<usize>,
2149 surface: &Surface,
2150 reveal: Option<Reveal>,
2151 cache: &mut BlockCache,
2152 mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
2153) -> Option<VisualMap> {
2154 let wrap = wrap.map(|w| w.max(8));
2155 // A width change invalidates every cached row — a full rebuild's job.
2156 if cache.wrap != Some(wrap) {
2157 return None;
2158 }
2159 // So does a moved reveal line, and for the same reason: this path reuses
2160 // every row outside the dirty block, and those rows encode which line was
2161 // showing its raw markup when they were built. Typing almost always moves
2162 // the caret, so under `MarkupMode::Full` this bails to `build_cached` on
2163 // most keystrokes — still block-cached, so only the edited block and the
2164 // revealed one actually re-render.
2165 if cache.layout.reveal != reveal {
2166 return None;
2167 }
2168 // Take the previous layout; on any bail below the caller rebuilds it (and the
2169 // map) via `build_cached`, so leaving it empty is fine. A table or a block
2170 // that renders outside its span (a degenerate inline span) makes shifting
2171 // unsound, so those force the full-rebuild path.
2172 let prev_layout = std::mem::take(&mut cache.layout);
2173 if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
2174 return None;
2175 }
2176 // The layout addresses `prev` by row index, so it is only usable against the
2177 // map it was built from. A frontend is free to hold the map it was handed and
2178 // present it differently — leaf-ratatui splices blank filler rows under an
2179 // oversized heading so the raster has somewhere to stand — and if one of those
2180 // comes back here the row arithmetic below lands on the wrong rows: the
2181 // re-rendered block is laid over a filler and the rows it really occupied
2182 // survive into the suffix, stranding a stale copy of the edited line and
2183 // pushing everything after it one row down, once per keystroke. A row count
2184 // that doesn't match what this layout describes is the tell, and the honest
2185 // answer is the full rebuild.
2186 let described_rows = prev_layout
2187 .blocks
2188 .iter()
2189 .map(|pl| pl.sep_rows + pl.content_rows)
2190 .sum::<usize>()
2191 + prev_layout.trailing_rows;
2192 if described_rows != prev.rows.len() {
2193 return None;
2194 }
2195
2196 let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
2197 if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
2198 return None;
2199 }
2200 let delta = source.len() as isize - prev_layout.built_len as isize;
2201
2202 // The single block whose NEW span contains the whole dirty range. A dirty
2203 // range straddling a block boundary (or a separator) finds none → bail.
2204 let k = blocks
2205 .iter()
2206 .position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
2207
2208 // Structural match: every OTHER block is unchanged — same kind throughout,
2209 // span identical before the edit and shifted by `delta` after it. A mismatch
2210 // means the reparse reshaped the block structure, which only a full rebuild
2211 // renders correctly.
2212 for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
2213 if m.kind != pl.kind {
2214 return None;
2215 }
2216 if i == k {
2217 continue;
2218 }
2219 let want = if i < k {
2220 pl.span.clone()
2221 } else {
2222 (pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
2223 };
2224 if m.span != want {
2225 return None;
2226 }
2227 }
2228 // The dirty block itself: start unchanged (the edit is inside it, past its
2229 // start), end moved by exactly the delta.
2230 let pk_start = prev_layout.blocks[k].span.start;
2231 let pk_end = prev_layout.blocks[k].span.end;
2232 let pk_sep = prev_layout.blocks[k].sep_rows;
2233 let pk_content = prev_layout.blocks[k].content_rows;
2234 if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
2235 {
2236 return None;
2237 }
2238
2239 // Re-render the dirty block from its subtree. A table makes the splice
2240 // bookkeeping unsafe, so bail if one appears.
2241 let subtree = fetch_subtree(blocks[k].node_id);
2242 if subtree.is_empty() {
2243 return None;
2244 }
2245 let mut sub = Builder {
2246 nodes: &subtree,
2247 source,
2248 wrap,
2249 rows: Vec::new(),
2250 tables: Vec::new(),
2251 last_off: 0,
2252 stepped_over: 0,
2253 surface,
2254 break_glyph: Cell::new(' '),
2255 preserve_soft,
2256 reveal: reveal.clone(),
2257 pending_mark_ends: RefCell::new(Vec::new()),
2258 pending_math: RefCell::new(Vec::new()),
2259 saw_math: Cell::new(false),
2260 presentation: Presentation::default(),
2261 faces: RefCell::new(FaceTable::default()),
2262 };
2263 sub.block(0, &[], &[]);
2264 // A table, or content that renders outside the block's span (a degenerate
2265 // inline span), makes the shift bookkeeping unsound — fall back.
2266 if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
2267 return None;
2268 }
2269 // The face table starts from the previous map's, because every row this
2270 // path keeps was built against it and its glyphs' ids still mean what they
2271 // meant. The re-rendered block adds whatever it met. An id the edit took
2272 // the last glyph of stays in the table, naming nothing — the price of not
2273 // walking the rows this path exists to avoid walking.
2274 let mut faces = prev.faces;
2275 faces.merge(&sub.faces.into_inner());
2276 let sub_saw_math = sub.saw_math.get();
2277 let new_content = sub.rows;
2278 let new_content_len = new_content.len();
2279 let new_stops = collect_stops(&new_content);
2280 let new_mark_ends = collect_mark_ends(&new_content);
2281
2282 // Row span of the dirty block's CONTENT. Its leading separator stays in the
2283 // prefix: the gap before block k is unchanged, since k's start didn't move.
2284 let content_start_row: usize = prev_layout.blocks[..k]
2285 .iter()
2286 .map(|pl| pl.sep_rows + pl.content_rows)
2287 .sum::<usize>()
2288 + pk_sep;
2289 let content_end_row = content_start_row + pk_content;
2290
2291 // Splice rows: [prefix | new content | suffix + delta]. The prefix moves
2292 // untouched; the suffix shifts in place — integer adds, no glyph copy.
2293 let mut rows = prev.rows;
2294 let mut suffix = rows.split_off(content_end_row);
2295 rows.truncate(content_start_row);
2296 for row in &mut suffix {
2297 shift_row_in_place(row, delta);
2298 }
2299 rows.reserve(new_content_len + suffix.len());
2300 rows.extend(new_content);
2301 rows.extend(suffix);
2302
2303 // Splice stops by offset. The old dirty block covered `[pk_start, pk_end]`:
2304 // prefix stops fall below it, suffix stops above it (shift by delta), the new
2305 // content supplies the middle. The three ranges stay disjoint and ascending,
2306 // so the result needs no re-sort.
2307 let p1 = prev.stops.partition_point(|&s| s < pk_start);
2308 let p2 = prev.stops.partition_point(|&s| s <= pk_end);
2309 let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
2310 stops.extend_from_slice(&prev.stops[..p1]);
2311 stops.extend(new_stops);
2312 for &s in &prev.stops[p2..] {
2313 stops.push((s as isize + delta) as usize);
2314 }
2315 // The mark ends splice the same way: they are offsets in the same
2316 // coordinates, cut at the same block.
2317 let m1 = prev.mark_ends.partition_point(|&s| s < pk_start);
2318 let m2 = prev.mark_ends.partition_point(|&s| s <= pk_end);
2319 let mut mark_ends = Vec::with_capacity(m1 + new_mark_ends.len() + (prev.mark_ends.len() - m2));
2320 mark_ends.extend_from_slice(&prev.mark_ends[..m1]);
2321 mark_ends.extend(new_mark_ends);
2322 for &s in &prev.mark_ends[m2..] {
2323 mark_ends.push((s as isize + delta) as usize);
2324 }
2325
2326 // Record the patched layout for the next splice: spans move to the new
2327 // coordinates, and the dirty block takes its new content-row count.
2328 let mut new_blocks = prev_layout.blocks;
2329 for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
2330 pl.span = m.span.clone();
2331 }
2332 new_blocks[k].content_rows = new_content_len;
2333 new_blocks[k].has_math = sub_saw_math;
2334 cache.layout = Layout {
2335 blocks: new_blocks,
2336 trailing_rows: prev_layout.trailing_rows,
2337 built_len: source.len(),
2338 has_tables: false,
2339 // Every prefix/suffix block was shift-safe last build (we bailed
2340 // otherwise) and the re-rendered block was just checked, so the patched
2341 // document is still entirely shift-safe.
2342 all_shift_safe: true,
2343 reveal,
2344 };
2345
2346 label_media_boundaries(&mut rows);
2347 let code_blocks = code_block_spans(&rows);
2348 let media = media_spans(&rows);
2349 let directives = directive_spans(&rows);
2350 let math = math_spans(&rows);
2351 Some(VisualMap {
2352 rows,
2353 // The edit was inside one block, so no block moved its start and the
2354 // lines above the first are the ones the floor was taken from.
2355 content_start: prev.content_start,
2356 stops,
2357 mark_ends,
2358 tables: Vec::new(),
2359 code_blocks,
2360 media,
2361 directives,
2362 math,
2363 faces,
2364 spelling: OnceLock::new(),
2365 })
2366}
2367
2368/// A persistent, content-keyed cache of the rows each top-level block renders
2369/// to — the [`VisualMap`] analogue of the GUI's ShapedLine cache, one level
2370/// down. Held by a [`crate::Doc`] and threaded into [`build_cached`], it is what
2371/// makes a rebuild after a keystroke cost "re-render the edited block + shift
2372/// the rest" instead of re-rendering the whole document.
2373///
2374/// A top-level block's rows are a pure function of its source bytes and the wrap
2375/// width, so an unchanged block's rows are cloned and their source offsets
2376/// shifted by the edit's byte delta rather than rebuilt glyph by glyph. Two
2377/// things make that purity hold: at the top level the render prefix is always
2378/// empty (nesting prefixes — a quote gutter, a list indent — exist only *inside*
2379/// a top-level block, within its cached unit), and a block's output never reads
2380/// the incoming `last_off` (it writes `last_off` from its own content before any
2381/// nested separator reads it). So the only thing that differs between two
2382/// positions of an unchanged block is a uniform offset shift. Keyed by a fast
2383/// hash of the block's bytes with the bytes kept for a verify-on-hit — exactly
2384/// the shape cache's weak-hash-then-compare, so a collision costs a re-render,
2385/// never a wrong row.
2386///
2387/// Tables are never cached (a block that emits any table row is always rebuilt):
2388/// their rows are cross-referenced from the map's `tables` side-table by row
2389/// index, which a blind offset-shift wouldn't fix up, and they are rare enough
2390/// that the simplicity beats the reuse.
2391#[derive(Default)]
2392pub struct BlockCache {
2393 /// The wrap width every entry was built at; a change invalidates all of
2394 /// them. `None` before the first build (distinct from `Some(None)`, the
2395 /// unwrapped GUI width).
2396 wrap: Option<Option<usize>>,
2397 /// Bumped once per [`build_cached`]. An entry reused or inserted this build
2398 /// carries the current value; stale entries are dropped at the end of it.
2399 generation: u64,
2400 /// `hash(bytes)` → the block(s) sharing that hash — a bucket because
2401 /// distinct blocks can collide, while two *identical* blocks share one entry
2402 /// (free dedup).
2403 entries: HashMap<u64, Vec<CachedBlock>>,
2404 /// The row/stop decomposition of the last build, which [`build_spliced`]
2405 /// patches in place for a single-block edit. Kept in step with whatever
2406 /// [`VisualMap`] was last produced; empty before the first build.
2407 layout: Layout,
2408}
2409
2410/// How the last build's [`VisualMap`] decomposes into top-level blocks — the
2411/// bookkeeping [`build_spliced`] needs to splice one block's rows and stops
2412/// without rebuilding the whole map. Every field describes the *previous* build,
2413/// in that build's coordinates.
2414#[derive(Default)]
2415struct Layout {
2416 /// One entry per rendered (metadata-filtered) top-level block, in order.
2417 blocks: Vec<BlockLayout>,
2418 /// Trailing blank rows past the last block (from `emit_trailing_blank_lines`).
2419 trailing_rows: usize,
2420 /// The source length this layout was built at — the reference for the edit's
2421 /// byte delta.
2422 built_len: usize,
2423 /// Whether the last build drew any table. A table's cross-referenced row
2424 /// indices don't survive a blind splice, so their presence makes
2425 /// [`build_spliced`] bail to a full rebuild.
2426 has_tables: bool,
2427 /// Whether every block rendered strictly inside its own span (see
2428 /// [`rows_within`]). A block that doesn't — a malformed Markdown inline node
2429 /// that twig leaves with a degenerate `0..0` span renders at a fixed offset
2430 /// outside its block — can't be shifted correctly, so its presence makes
2431 /// [`build_spliced`] bail to a full rebuild.
2432 all_shift_safe: bool,
2433 /// The reveal line this layout was built under (see [`Builder::reveal`]).
2434 /// A splice reuses every row it isn't re-rendering, so a reveal line that
2435 /// has moved would leave the old line still showing its delimiters and the
2436 /// new one still hiding them — [`build_spliced`] bails when this changes.
2437 reveal: Option<Reveal>,
2438}
2439
2440/// One top-level block's contribution to the last build: its span and kind (for
2441/// the structural match that proves only one block changed) and how many
2442/// separator and content rows it emitted (to locate its slice of the row
2443/// vector).
2444struct BlockLayout {
2445 span: Range<usize>,
2446 kind: Kind,
2447 sep_rows: usize,
2448 content_rows: usize,
2449 /// Whether the block holds a formula — see [`BlockCache::math_meets`].
2450 has_math: bool,
2451}
2452
2453/// One cached block: the rows it rendered to, plus what a reuse at a new
2454/// position needs to shift them. Offsets are stored absolute (as built) and
2455/// shifted by `new_start - built_start` on reuse.
2456struct CachedBlock {
2457 /// The block's exact source bytes, compared on a hash hit so a collision
2458 /// can never hand back another block's rows.
2459 bytes: Box<[u8]>,
2460 /// The offset the rows were built at (the block's `span.start`).
2461 built_start: usize,
2462 /// The block's rows, offsets absolute as built.
2463 rows: Vec<VRow>,
2464 /// `last_off` after this block was emitted, absolute as built — restored
2465 /// (shifted) on reuse so the following separator lands correctly.
2466 last_off: usize,
2467 /// `stepped_over` after this block was emitted, absolute as built — the
2468 /// hidden tail a block ends with (a `</div>`), restored with `last_off`
2469 /// so the trailing blank lines are counted from past it on a hit too.
2470 stepped_over: usize,
2471 /// Where the reveal line fell *within this block* when the rows were built,
2472 /// as a block-relative byte range — see [`reveal_key`]. Compared alongside
2473 /// `bytes` on a hit, because identical source renders to different rows
2474 /// depending on whether the caret's line is inside it: the same `*em*`
2475 /// shows its asterisks on the revealed line and hides them everywhere else.
2476 ///
2477 /// Block-relative rather than absolute so an unaffected block still hits
2478 /// after an edit shifts it, and `None` for the overwhelmingly common
2479 /// no-reveal case — which is why an entry stored under `MarkupMode::None`
2480 /// keeps hitting for every block that isn't the caret's.
2481 reveal: Option<Range<usize>>,
2482 /// Whether the block holds a formula, so a hit can say so to the layout
2483 /// without walking the rows it is re-emitting.
2484 has_math: bool,
2485 /// The named families this block's glyphs are set in — see
2486 /// [`VisualMap::faces`]. Stored with the rows because a hit re-emits them
2487 /// without walking a `data-font` again, and the map still has to be able to
2488 /// say what the id on a reused glyph names. Empty for every block that
2489 /// names no family, which is nearly all of them.
2490 faces: FaceTable,
2491 /// The build that last reused or inserted this entry (see `generation`).
2492 generation: u64,
2493}
2494
2495/// Where `reveal` falls inside a block, in block-relative bytes — the extra key
2496/// a cached block is stored and matched under.
2497///
2498/// `None` when the block doesn't meet the reveal line at all, which is every
2499/// block on every build in the two hidden modes, and all but one of them under
2500/// [`crate::MarkupMode::Full`]. So the cache keeps its hit rate as the caret
2501/// moves: only the line the caret leaves and the line it arrives at re-render.
2502fn reveal_key(reveal: &Option<Reveal>, span: &Range<usize>) -> Option<Range<usize>> {
2503 let r = reveal.as_ref()?;
2504 // The same generous intersection test `Builder::revealed` uses, so a block
2505 // is keyed as revealed exactly when its glyphs will be built that way.
2506 // Whether the line reveals markup or only math is not in the key: that is
2507 // a mode, and a mode change empties the cache.
2508 r.meets(span).then(|| {
2509 let start = r.line.start.max(span.start) - span.start;
2510 let end = r.line.end.min(span.end) - span.start;
2511 start..end
2512 })
2513}
2514
2515impl BlockCache {
2516 /// Whether the caret's `line` meets a top-level block that holds a
2517 /// formula, as of the last build — the question [`crate::Doc::reveal_line`]
2518 /// asks in the hidden markup modes before it threads the line through, so
2519 /// that only a line with something to reveal costs a rebuild.
2520 ///
2521 /// Answered from the layout rather than from twig because the layout is
2522 /// free: every build records per block whether its walk met a formula
2523 /// ([`BlockLayout::has_math`]), and a query against the tree is
2524 /// O(document) on every frame. Coarse on purpose — a block, not a line —
2525 /// since a block with a formula in it is rare, small, and the one thing
2526 /// worth re-rendering as the caret moves through it.
2527 ///
2528 /// Exact between edits, when the spans are the document's. Across an edit
2529 /// the spans are the previous revision's, so the caller checks again once
2530 /// the new layout is in and rebuilds if the answer changed.
2531 pub(crate) fn math_meets(&self, line: &Range<usize>) -> bool {
2532 self.layout
2533 .blocks
2534 .iter()
2535 .any(|b| b.has_math && b.span.start <= line.end && line.start <= b.span.end)
2536 }
2537
2538 /// Look up a block by hash, verify its bytes and reveal key, and on a hit
2539 /// stamp it used this build and hand back a borrow to shift-and-clone from.
2540 /// `None` on a miss (unknown hash, a collision whose bytes differ, or the
2541 /// same bytes built under a different reveal).
2542 fn reuse(
2543 &mut self,
2544 hash: u64,
2545 bytes: &[u8],
2546 reveal: &Option<Range<usize>>,
2547 ) -> Option<&CachedBlock> {
2548 let g = self.generation;
2549 let bucket = self.entries.get_mut(&hash)?;
2550 let e = bucket
2551 .iter_mut()
2552 .find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
2553 e.generation = g;
2554 Some(&*e)
2555 }
2556
2557 /// Cache the rows a freshly-rendered block produced (or refresh an existing
2558 /// entry for the same bytes and reveal — an identical block elsewhere, or a
2559 /// re-render).
2560 #[allow(clippy::too_many_arguments)]
2561 fn store(
2562 &mut self,
2563 hash: u64,
2564 bytes: &[u8],
2565 built_start: usize,
2566 rows: Vec<VRow>,
2567 last_off: usize,
2568 stepped_over: usize,
2569 reveal: Option<Range<usize>>,
2570 has_math: bool,
2571 faces: FaceTable,
2572 ) {
2573 let g = self.generation;
2574 let bucket = self.entries.entry(hash).or_default();
2575 if let Some(e) = bucket
2576 .iter_mut()
2577 .find(|e| &*e.bytes == bytes && e.reveal == reveal)
2578 {
2579 e.built_start = built_start;
2580 e.rows = rows;
2581 e.last_off = last_off;
2582 e.stepped_over = stepped_over;
2583 e.has_math = has_math;
2584 e.faces = faces;
2585 e.generation = g;
2586 } else {
2587 bucket.push(CachedBlock {
2588 bytes: bytes.into(),
2589 built_start,
2590 rows,
2591 last_off,
2592 stepped_over,
2593 reveal,
2594 has_math,
2595 faces,
2596 generation: g,
2597 });
2598 }
2599 }
2600}
2601
2602/// The source bytes a top-level block covers — the block cache's key material.
2603///
2604/// Clamped to the source rather than sliced by the span as twig gives it,
2605/// because that span can end *past* the last byte: the final block of a document
2606/// with no trailing newline is closed on the virtual newline the parser supplies
2607/// at EOF, so its `span.end` is `source.len() + 1`. Slicing by such a range
2608/// yields `None`, and the obvious `unwrap_or(&[])` reads that as *this block has
2609/// no bytes* — the wrong answer twice over.
2610///
2611/// Two blocks whose spans both overrun then key alike, and the second is served
2612/// the first one's rows. That is not hypothetical: a footnote definition is a
2613/// root beside `doc` merged back into the top level by [`top_blocks`], while the
2614/// `section` above it spans the definition's bytes too, so both end at EOF —
2615/// and a document ending in `[^note]: …` renders that definition as a second
2616/// copy of the heading. Even alone, a block that keeps hashing empty as the user
2617/// types in it is served the stale rows built before the edit.
2618///
2619/// Clamping hands back the bytes the block really covers, which tells both cases
2620/// apart, and costs nothing for a span that was in range to begin with.
2621fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
2622 let bytes = source.as_bytes();
2623 let start = span.start.min(bytes.len());
2624 &bytes[start..span.end.clamp(start, bytes.len())]
2625}
2626
2627/// A fast, allocation-free content hash (FNV-1a) for a block's bytes. Weak by
2628/// design — the bytes are compared on a hit — so its only job is to spread
2629/// blocks across buckets cheaply. SipHash over every block's bytes on every
2630/// keystroke would cost more than it saves, the same lesson the shape cache
2631/// learned when it stopped hashing through the standard hasher.
2632fn block_hash(bytes: &[u8]) -> u64 {
2633 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2634 for &x in bytes {
2635 h ^= x as u64;
2636 h = h.wrapping_mul(0x0000_0100_0000_01b3);
2637 }
2638 h
2639}
2640
2641/// Clone a cached row with every source offset advanced by `delta` — the whole
2642/// cost of reusing an unchanged block: integer adds where a rebuild would
2643/// re-shape every glyph.
2644fn shift_row(row: &VRow, delta: isize) -> VRow {
2645 let shift = |off: usize| (off as isize + delta) as usize;
2646 VRow {
2647 glyphs: row
2648 .glyphs
2649 .iter()
2650 .map(|g| Glyph {
2651 ch: g.ch,
2652 style: g.style,
2653 src: shift(g.src),
2654 stop: g.stop,
2655 })
2656 .collect(),
2657 end_src: shift(row.end_src),
2658 decoration: row.decoration,
2659 code: row.code,
2660 code_lang: row.code_lang.clone(),
2661 directive: row.directive,
2662 directive_label: row.directive_label.clone(),
2663 media: row.media.clone(),
2664 // A tick, not an offset — reuse carries it as-is, like `code_lang`.
2665 task: row.task,
2666 leaf_directive: row.leaf_directive.clone(),
2667 heading: row.heading,
2668 // Presentation, not offsets: names the author wrote, which a shifted
2669 // block still wears — like `code_lang`.
2670 align: row.align,
2671 line_height: row.line_height,
2672 // Structure, not offsets: a reused block's rows divide the same blocks
2673 // wherever the edit above moved them to.
2674 boundary: row.boundary,
2675 mark_ends: row.mark_ends.iter().map(|&o| shift(o)).collect(),
2676 // Strings and a glyph index: the glyph moved, the index into the row
2677 // did not.
2678 math: row.math.clone(),
2679 }
2680}
2681
2682/// Advance a row's source offsets by `delta` in place — the suffix half of
2683/// [`build_spliced`], where the rows are already owned and only need shifting,
2684/// not copying.
2685fn shift_row_in_place(row: &mut VRow, delta: isize) {
2686 for g in &mut row.glyphs {
2687 g.src = (g.src as isize + delta) as usize;
2688 }
2689 row.end_src = (row.end_src as isize + delta) as usize;
2690 for o in &mut row.mark_ends {
2691 *o = (*o as isize + delta) as usize;
2692 }
2693}
2694
2695/// Whether every source offset a block's rows carry falls inside the block's own
2696/// span — the precondition for reusing the block by a uniform offset shift. It
2697/// holds for well-formed blocks (their glyphs and row ends address bytes within
2698/// the block, synthetic glyphs point at the block start). It fails when a node
2699/// renders *outside* its block, which today means a malformed Markdown inline
2700/// node twig leaves with a degenerate `0..0` span: that content lands at a fixed
2701/// offset that doesn't move with the block. Such a block is re-rendered every
2702/// build instead of shifted, so the incremental map still matches a fresh one —
2703/// see [`build_cached`] and [`build_spliced`].
2704fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
2705 rows.iter().all(|r| {
2706 r.end_src >= span.start
2707 && r.end_src <= span.end
2708 && r.glyphs
2709 .iter()
2710 .all(|g| g.src >= span.start && g.src <= span.end)
2711 })
2712}
2713
2714/// Where the rendered document begins when a leading `metadata` block is all
2715/// there is — the end of that hidden frontmatter, past the newline that closes
2716/// its last line so the floor sits at the start of the (empty) body rather than
2717/// on the closing `---`.
2718///
2719/// With a real block after it the frontmatter's end is never needed: the floor
2720/// is that block's start, and the rows begin there. With nothing after it, both
2721/// the caret floor and the trailing-blank-line count would otherwise fall back
2722/// to offset 0 — inside the hidden frontmatter — which put the caret *before*
2723/// the metadata and made typing land ahead of the opening `---`.
2724fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
2725 let Some(end) = meta_end else { return 0 };
2726 let end = end.min(source.len());
2727 let rest = &source[end..];
2728 if rest.starts_with("\r\n") {
2729 end + 2
2730 } else if rest.starts_with('\n') {
2731 end + 1
2732 } else {
2733 end
2734 }
2735}
2736
2737/// The caret floor: the first block's start, or the first blank line above it
2738/// when those lines draw rows ([`Builder::emit_leading_blank_lines`]).
2739fn floor_of(rows: &[VRow], first_start: usize) -> usize {
2740 rows.first()
2741 .map_or(first_start, |r| r.end_src.min(first_start))
2742}
2743
2744/// The end of the document's hidden frontmatter: the last `metadata` child of
2745/// `doc`, which is what [`top_level`] and [`Builder::blocks`] drop. `None` when
2746/// there is none.
2747fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
2748 let mut end = None;
2749 let mut child = nodes[doc].first_child;
2750 while let Some(cid) = child {
2751 let n = &nodes[cid.0 as usize];
2752 if n.kind == Kind::Metadata {
2753 end = Some(n.span.end);
2754 }
2755 child = n.next_sibling;
2756 }
2757 end
2758}
2759
2760/// The document's rendered top-level blocks, as node indices in source order.
2761///
2762/// Not simply `doc`'s children, for two reasons. Frontmatter (a leading
2763/// `metadata` block) is document metadata rather than prose and is dropped, the
2764/// way [`Builder::blocks`] drops it. And a **footnote definition** (`[^1]: …`)
2765/// is not a child of `doc` at all: twig parses it as a root of its own, a
2766/// *sibling* of the document node with `parent == None`. A walk that starts at
2767/// `doc` therefore never reaches one, which is why a definition — and every
2768/// byte of its body — used to render as nothing at all. Merging the roots back
2769/// in by `span.start` puts each definition on screen exactly where it was
2770/// written, which is what keeps rows, stops, and offsets monotonic.
2771///
2772/// A **link reference definition** (`[foo]: /url`) is a root of the same kind,
2773/// and is merged for the opposite reason: it draws *nothing*, and the walk has
2774/// to know where it stands to step over it. A definition closing a README —
2775/// the `[links]: …` block under the prose — left no block over its lines, so
2776/// the separator logic read them as blank lines and drew an empty paragraph
2777/// per definition. Merged in, it is a hidden block like a comment, and
2778/// [`Builder::block_or_hidden`] moves the walk past it. One with no span
2779/// (`0..0`, what twig before 3.3.3 reported for every one) has nowhere to be
2780/// merged, and is left out as before.
2781///
2782/// Only those roots are merged. twig also leaves stray orphan `str` nodes
2783/// parented to nothing (the `*` of an emphasis run, for one); those are already
2784/// rendered as part of the subtree that owns their bytes, and re-emitting them
2785/// here would double them.
2786fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
2787 let mut out = Vec::new();
2788 let mut child = nodes[doc].first_child;
2789 while let Some(cid) = child {
2790 let n = &nodes[cid.0 as usize];
2791 if n.kind != Kind::Metadata {
2792 out.push(cid.0 as usize);
2793 }
2794 child = n.next_sibling;
2795 }
2796 out.extend(
2797 nodes
2798 .iter()
2799 .enumerate()
2800 .filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
2801 .map(|(i, _)| i),
2802 );
2803 out.sort_by_key(|&i| nodes[i].span.start);
2804 out
2805}
2806
2807/// Is a parentless node of `kind` at `span` a definition the top-level walk
2808/// merges in — a footnote definition, or a link reference definition that
2809/// knows where it stands? Shared by [`top_level`] and [`top_blocks`] so the
2810/// two walks cannot disagree about what the top-level blocks are.
2811fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
2812 match *kind {
2813 Kind::Footnote => true,
2814 Kind::Reference => span.end > span.start,
2815 _ => false,
2816 }
2817}
2818
2819/// The top-level blocks to hand [`build_cached`] / [`build_spliced`] — the
2820/// incremental path's twin of [`top_level`], which the two must agree with block
2821/// for block or the render paths diverge.
2822///
2823/// `child_spans(None)` gives `doc`'s children, which is all of them for an
2824/// ordinary document. A **footnote definition** is not one: twig parses `[^1]: …`
2825/// as a root beside `doc` with no parent, and indexes it at no offset either —
2826/// `node_at` inside its bytes answers `doc`, and a `query("footnote")` selector
2827/// finds nothing. Leaf used to discover them by marshalling the whole arena with
2828/// `nodes()` — the very cost the incremental path exists to avoid — behind a
2829/// byte-scan gate that gave documents with no `[^…]:` line a substring search
2830/// instead. twig 3.0's `definitions()` asks the library the question directly,
2831/// so both the marshal and the gate are gone.
2832///
2833/// The link reference definitions `definitions()` also reports are merged on
2834/// the same terms as [`top_level`] merges them — see [`is_placed_definition`].
2835///
2836/// This is the one part of the render that needs an [`Editor`] rather than a
2837/// marshalled node array. The builders themselves stay editor-free; this only
2838/// prepares their input.
2839pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
2840 let mut top = editor.child_spans(None).unwrap_or_default();
2841 let defs: Vec<QueryMatch> = definitions(editor)
2842 .into_iter()
2843 .filter(|m| is_placed_definition(&m.kind, &m.span))
2844 .collect();
2845 if defs.is_empty() {
2846 return top;
2847 }
2848 top.extend(defs);
2849 // Source order — what every offset-keyed thing downstream (rows, stops, the
2850 // splice path's block-for-block match) is built to assume.
2851 top.sort_by_key(|m| m.span.start);
2852 top
2853}
2854
2855/// Every `[^label]: …` definition in the document, in whatever order twig
2856/// reports them.
2857///
2858/// Filtered to [`Kind::Footnote`]: `definitions()` also reports the *link*
2859/// reference definitions (`[foo]: /url`), which are [`top_blocks`]'s business
2860/// and not [`crate::Doc::footnote_at_caret`]'s.
2861///
2862/// Empty when the document can't be walked, which leaves [`top_blocks`] with
2863/// the ordinary top-level children and [`crate::Doc::footnote_at_caret`] with an
2864/// undefined reference — in both cases the same answer as a document that has
2865/// no definitions, which is the right way to degrade.
2866pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2867 definitions(editor)
2868 .into_iter()
2869 .filter(|m| m.kind == Kind::Footnote)
2870 .collect()
2871}
2872
2873/// Every definition twig resolves by label rather than by position — footnote
2874/// and link reference definitions both — or nothing when the document can't be
2875/// walked.
2876fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2877 let Ok(mut doc) = editor.document() else {
2878 return Vec::new();
2879 };
2880 doc.definitions().unwrap_or_default()
2881}
2882
2883/// The last line of a block whose span ends at `span_end` — a thematic break,
2884/// a page break — as the two offsets the rest of the crate means by it: where
2885/// the line's text ends, and the home past it — past the newline that ends the
2886/// line, the start of the line under it (or the text's end, with no newline to
2887/// pass). A rule's row ends at that home.
2888///
2889/// Read off the span less the newline djot's span takes in, since Markdown's
2890/// stops before it. Measured from djot's span end, the home after a rule landed
2891/// past the blank line under it, on the next block's first character.
2892pub(crate) fn block_line(source: &str, span_end: usize) -> (usize, usize) {
2893 let span_end = span_end.min(source.len());
2894 let text_end = source[..span_end]
2895 .strip_suffix('\n')
2896 .map_or(span_end, |s| s.strip_suffix('\r').unwrap_or(s).len());
2897 let rest = &source[text_end..];
2898 let newline = if rest.starts_with("\r\n") {
2899 2
2900 } else {
2901 usize::from(rest.starts_with('\n'))
2902 };
2903 (text_end, text_end + newline)
2904}
2905
2906/// The label of the footnote definition starting at `start` — the `1` in
2907/// `[^1]: …`. twig gives the `footnote` node no label of its own (no `text`, no
2908/// `name`), and the bytes that spell it belong to no child node either — the
2909/// body `para` starts its *content* past them — so the source is the only place
2910/// to read it from. `None` when what's there isn't a definition after all.
2911pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
2912 let rest = source.get(start..)?.strip_prefix("[^")?;
2913 let end = rest.find("]:")?;
2914 Some(&rest[..end])
2915}
2916
2917/// Where the body of the footnote definition spanning `span` sits in `source` —
2918/// everything past the `[^1]:` marker, which is the part a reader actually wants
2919/// when they follow a reference.
2920///
2921/// Source bytes, verbatim but for the whitespace trimmed off each end: a note
2922/// that says `see *later*` answers with the asterisks in. Rendering that body is
2923/// a frontend's business the same way painting a [`Role`] is, and a caller that
2924/// wants it laid out already has the definition on screen where it was written.
2925///
2926/// The trim is what makes the common case read right — `[^1]: text` has a space
2927/// after the colon that belongs to the marker, not the note, and a definition's
2928/// span runs to the newline ending it.
2929///
2930/// The span is taken at its word, which it has only been safe to do since twig
2931/// 3.1: a djot definition's span used to run *past* its own last line, through
2932/// the blank line separating it from the next block and into that block's first
2933/// byte, so `[^2a]: a note.` came back as `"a note.\n\n["` and the offsets named
2934/// the following note's rows as well as this one's — a reader asking about one
2935/// footnote was shown two. leaf measured the body itself to get around that, and
2936/// paid for it: the scan stopped at the first blank line, so a note with a second
2937/// indented paragraph lost it. Both halves go away with the fix, since a blank
2938/// line *inside* a definition was always interior to the span and still is.
2939///
2940/// A range rather than a slice because "go to note" needs the *position* as much
2941/// as the text, and it needs the position of the body specifically: a
2942/// definition's `[^1]:` marker is decoration the caret can't occupy (the rich
2943/// view draws it as `[1] ` and gives it no stop), so aiming a caret at the
2944/// definition's first byte lands it on the nearest real stop instead — which is
2945/// up in the paragraph *above* the note. The body's first byte is a stop, and is
2946/// where a reader following a reference wants to arrive anyway.
2947pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
2948 let rest = source.get(span.clone())?.strip_prefix("[^")?;
2949 let marker = rest.find("]:")?;
2950 // `span.start` + `[^` + the label + `]:`.
2951 let after_marker = span.start + 2 + marker + 2;
2952 let raw = source.get(after_marker..span.end)?;
2953 // Written as a start plus a length so an all-whitespace body lands on an
2954 // empty range at the end rather than an inverted one.
2955 let start = after_marker + (raw.len() - raw.trim_start().len());
2956 Some(start..start + raw.trim().len())
2957}
2958
2959/// The label of the footnote *reference* spanning `span` — the `1` in `[^1]`.
2960///
2961/// The peer of [`footnote_label`] for the other half of the pair, and needed for
2962/// the same reason: a reference whose node carries neither a `content_span` nor
2963/// a `text` still spells its label plainly in the source. `None` when the bytes
2964/// aren't a reference after all.
2965pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
2966 let rest = source.get(span)?.strip_prefix("[^")?;
2967 let end = rest.find(']')?;
2968 Some(&rest[..end])
2969}
2970
2971/// Where a heading's *content* starts — past the `#`s and the space the rich
2972/// view hides, for an ATX heading; the block's own start for a setext one (which
2973/// has no leading marker) and for a format that spells headings some other way.
2974///
2975/// Only an empty heading needs asking: with any content at all, the row ends on
2976/// its last glyph. Bounded to the heading's own first line so a marker-less
2977/// heading can't scan into the text under it.
2978fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
2979 let end = span.end.min(source.len());
2980 let Some(line) = source.get(span.start..end) else {
2981 return span.start;
2982 };
2983 let line = line.split('\n').next().unwrap_or("");
2984 let hashes = line.len() - line.trim_start_matches('#').len();
2985 if hashes == 0 {
2986 return span.start;
2987 }
2988 let after = &line[hashes..];
2989 span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
2990}
2991
2992struct Builder<'a> {
2993 nodes: &'a [FlatNode],
2994 /// The document source, consulted to place blank-line rows at the source
2995 /// offsets the caret should occupy on them (the AST drops blank lines).
2996 source: &'a str,
2997 /// The word-wrap column budget, or `None` to emit each block as a single
2998 /// unwrapped row (the frontend wraps).
2999 wrap: Option<usize>,
3000 rows: Vec<VRow>,
3001 /// Built alongside `rows`, never instead of them — see [`TableInfo`].
3002 tables: Vec<TableInfo>,
3003 /// The end offset of the last content emitted — the anchor for blank
3004 /// separator rows so the caret never snaps onto one.
3005 last_off: usize,
3006 /// The end of the last block the walk stepped over without drawing — a
3007 /// comment, which the rich view hides. `last_off` moves past it too, for the
3008 /// separators; this is kept apart so the trailing blank lines can be counted
3009 /// from it without also being counted from a code block's closing fence,
3010 /// which `last_off` likewise ends after. `0` until a hidden block is met.
3011 stepped_over: usize,
3012 /// How many rows each block image reserves, keyed by its destination — the
3013 /// frontend's per-image height, threaded in from [`crate::Doc::set_media_rows`]
3014 /// so [`Builder::block_media`] can size the placeholder without core doing any
3015 /// I/O. A destination absent from the map (or a `0`/`1` entry) reserves the
3016 /// bare one-row placeholder, which is the whole-document default and what
3017 /// every existing test — passing an empty map — still gets.
3018 surface: &'a Surface,
3019 /// The glyph a hard break renders as while the current inline run is built:
3020 /// a space in prose (a break folds into the flow the frontend wraps), but a
3021 /// newline (`\n`) inside a table cell, where a row is one source line and the
3022 /// only break it can carry is an explicit one that must show as a line of its
3023 /// own. Set around [`Builder::row_cells`] and otherwise left at `' '`.
3024 break_glyph: Cell<char>,
3025 /// Render a soft break (a bare newline inside a paragraph) as a line break
3026 /// where it was written, rather than folding it into the reflowed paragraph
3027 /// — the `LineFlow::Preserve` behaviour. A soft break emits a `'\n'` glyph
3028 /// (like a hard break in a cell), which [`Builder::emit_wrapped`] turns into
3029 /// a fresh visual row. `false` is the flowing-prose default. Inside a table
3030 /// cell (where `break_glyph` is already `'\n'`) it has no effect: a cell is
3031 /// one line and folds its own soft breaks regardless.
3032 preserve_soft: bool,
3033 /// The source byte range of the one line that should render its markup
3034 /// *raw* — the caret's line under `MarkupMode::Full` (see
3035 /// [`crate::Doc::reveal_line`]). `None` in every other mode and view, which
3036 /// is the delimiters-always-hidden behaviour every build had before the
3037 /// preference existed.
3038 ///
3039 /// Read only by [`Builder::revealed`], which every delimiter-bearing arm of
3040 /// [`Builder::inline`] consults. A range rather than a bare caret offset
3041 /// because the decision is per-*node*, not per-caret: a node is revealed
3042 /// when its span meets this line, so `*em*` shows both its asterisks even
3043 /// with the caret at one end of it.
3044 reveal: Option<Reveal>,
3045 /// The content ends of the hidden marks rendered since the last row was
3046 /// pushed — recorded as the inline walk meets each mark, and drained onto
3047 /// the rows as they are emitted (see [`Builder::take_mark_ends`]). A cell
3048 /// rather than a `&mut`, for the reason `break_glyph` is: the inline walk
3049 /// borrows the builder shared.
3050 pending_mark_ends: RefCell<Vec<usize>>,
3051 /// The inline atoms rendered since the last row was pushed, keyed by the
3052 /// formula's start offset — the offset the atom glyph carries, which is
3053 /// how [`Builder::take_math`] pairs each with its glyph once the wrap has
3054 /// decided which row it landed on. Drained the way `pending_mark_ends` is.
3055 pending_math: RefCell<Vec<(usize, MathMark)>>,
3056 /// Whether this walk met a formula at all, atom, block, or revealed — the
3057 /// fact [`BlockCache`] keeps per block so [`crate::Doc::reveal_line`] can
3058 /// tell whether the caret's line has anything to reveal without walking.
3059 saw_math: Cell<bool>,
3060 /// The presentation vocabulary in force at the block being walked — the
3061 /// keys the `div`s around it carry, folded together with the nearest
3062 /// winning, and [`Presentation::default`] at the top level.
3063 ///
3064 /// Saved and restored around each `div` in [`Builder::block`], so a block
3065 /// reads its own attributes over whatever its containers said and nothing
3066 /// leaks sideways to the block after it. It is per-*build* state rather
3067 /// than a parameter because every one of the dozen call sites of `block`
3068 /// would otherwise thread a value none of them care about.
3069 presentation: Presentation,
3070 /// The named families this walk has met, by the id its glyphs carry — see
3071 /// [`VisualMap::faces`]. A `RefCell` for [`pending_mark_ends`]'s reason:
3072 /// the inline walk borrows the builder shared, and a span's `data-font` is
3073 /// read from inside it.
3074 ///
3075 /// [`pending_mark_ends`]: Builder::pending_mark_ends
3076 faces: RefCell<FaceTable>,
3077}
3078
3079/// The six presentation keys as the walker carries them down a block tree —
3080/// the two that are the block's ([`Align`], [`LineHeight`]) and the three that
3081/// are a run's but may be written on the block ([`FontSize`], [`FaceRef`],
3082/// [`TextColor`]).
3083///
3084/// `Copy` and five `Option`s, because folding is the whole of what it does:
3085/// [`under`](Presentation::under) reads a container's attributes over an
3086/// existing set and a key the container does not name keeps the value it had.
3087/// That is the "nearest wins" rule stated once, rather than at each of the
3088/// three levels a key can be written at. A name and a value fold alike: the
3089/// nearer node wins whichever of the two forms either of them wrote.
3090#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
3091struct Presentation {
3092 align: Option<Align>,
3093 line_height: Option<LineHeight>,
3094 size: Option<FontSize>,
3095 font: Option<FaceRef>,
3096 color: Option<TextColor>,
3097}
3098
3099impl Presentation {
3100 /// This set with whatever `attrs` names written over it — the nearer node's
3101 /// answer where it has one, the outer node's where it hasn't.
3102 ///
3103 /// `faces` is the build's intern table, which a named family is recorded in
3104 /// on the way past: the glyph carries the id and the table carries the
3105 /// string. Shared rather than `&mut` because the inline walk this feeds
3106 /// borrows the builder shared, the way `pending_mark_ends` does.
3107 fn under(self, attrs: &[(String, Option<String>)], faces: &RefCell<FaceTable>) -> Self {
3108 Self {
3109 align: Align::from_attrs(attrs).or(self.align),
3110 line_height: LineHeight::from_attrs(attrs).or(self.line_height),
3111 size: FontSize::from_attrs(attrs).or(self.size),
3112 font: faces.borrow_mut().face_from_attrs(attrs).or(self.font),
3113 color: TextColor::from_attrs(attrs).or(self.color),
3114 }
3115 }
3116
3117 /// `base` carrying the three run-level keys — the style a block's glyphs
3118 /// start from, which an attributed span inside it then writes over.
3119 fn over(self, base: Style) -> Style {
3120 base.size(self.size).font(self.font).color(self.color)
3121 }
3122}
3123
3124impl Builder<'_> {
3125 /// Note that the mark `id` closes with a hidden delimiter, so its content
3126 /// end is a caret home — unless the mark is empty, where the end is the
3127 /// start and there is nothing to extend.
3128 fn note_mark_end(&self, id: usize) {
3129 let node = &self.nodes[id];
3130 if let Some(content) = &node.content_span
3131 && content.end < node.span.end
3132 && !content.is_empty()
3133 {
3134 self.pending_mark_ends.borrow_mut().push(content.end);
3135 }
3136 }
3137
3138 /// The pending mark ends at or before `end_src`, for the row ending there
3139 /// — every mark rendered so far that closes on it. A mark's end never
3140 /// exceeds the end of the row its last glyph is on, so the leftovers are
3141 /// those of rows still to come.
3142 fn take_mark_ends(&self, end_src: usize) -> Vec<usize> {
3143 let mut pending = self.pending_mark_ends.borrow_mut();
3144 let (taken, kept): (Vec<usize>, Vec<usize>) =
3145 pending.drain(..).partition(|&o| o <= end_src);
3146 *pending = kept;
3147 taken
3148 }
3149
3150 /// The pending inline atoms whose glyph is on the row `glyphs` is about
3151 /// to become — each paired with the index of the [`Role::Math`] glyph
3152 /// carrying its offset, in glyph order. An atom whose glyph landed on an
3153 /// earlier row was taken then; one on a later row is left for it. The
3154 /// peer of [`take_mark_ends`](Self::take_mark_ends), and why an atom is
3155 /// keyed by offset: the wrap decides the row, and the offset is what the
3156 /// glyph still carries once it has.
3157 fn take_math(&self, glyphs: &[Glyph]) -> Vec<MathMark> {
3158 let mut pending = self.pending_math.borrow_mut();
3159 if pending.is_empty() {
3160 return Vec::new();
3161 }
3162 let mut out = Vec::new();
3163 for (i, g) in glyphs.iter().enumerate() {
3164 if g.style.role != Role::Math || !g.stop {
3165 continue;
3166 }
3167 if let Some(at) = pending.iter().position(|(src, _)| *src == g.src) {
3168 let (_, mut mark) = pending.remove(at);
3169 mark.glyph = Some(i);
3170 out.push(mark);
3171 }
3172 }
3173 out
3174 }
3175 /// Whether `span` belongs to the line that is showing its raw markup. True
3176 /// only when a reveal line is set *for markup* (`MarkupMode::Full`) and the
3177 /// two ranges actually meet — see [`Reveal::meets`] for what meeting is.
3178 fn revealed(&self, span: &Range<usize>) -> bool {
3179 self.reveal
3180 .as_ref()
3181 .is_some_and(|r| r.markup && r.meets(span))
3182 }
3183
3184 /// Whether a formula at `span` shows its TeX rather than its picture: it
3185 /// meets the reveal line, in *any* mode. A formula's content is its source
3186 /// and not its picture, so for it the choice is not between a clean
3187 /// surface and a raw one but between editable and not — which is why this
3188 /// does not read [`Reveal::markup`] the way [`revealed`](Self::revealed)
3189 /// does.
3190 fn math_revealed(&self, span: &Range<usize>) -> bool {
3191 self.reveal.as_ref().is_some_and(|r| r.meets(span))
3192 }
3193
3194 /// The `(opening, closing)` source byte ranges of a node's delimiters — the
3195 /// bytes its `span` holds that its `content_span` doesn't.
3196 ///
3197 /// This is how *every* inline delimiter is recovered, rather than a table of
3198 /// spellings per kind: twig gives `*em*` a span of `13..17` and a content
3199 /// span of `14..16`, so the gaps at each end are the delimiters, whatever
3200 /// they happen to be. That matters because one kind has many spellings —
3201 /// `*em*` and `_em_` are both emphasis, `` `x` `` and ``` ``x`` ``` both
3202 /// verbatim — and re-deriving the text from the source is the only way to
3203 /// show back what the author actually typed. It also gets a link's
3204 /// asymmetric `[` / `](dest)` right for free.
3205 ///
3206 /// `None` when the node has no content span, or when content and span
3207 /// coincide (nothing was elided, so there is nothing to reveal).
3208 fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
3209 let node = &self.nodes[id];
3210 let content = node.content_span.clone()?;
3211 let span = node.span.clone();
3212 // A content span that escapes its own node's span means the two are
3213 // describing different things; reveal nothing rather than slice wildly.
3214 if content.start < span.start || content.end > span.end {
3215 return None;
3216 }
3217 let (open, close) = (span.start..content.start, content.end..span.end);
3218 // A delimiter that spans a newline isn't this line's to reveal — a setext
3219 // heading's `\n=====` underline is the case that arises in practice. It
3220 // would also inject a `'\n'` glyph, which `emit_wrapped` reads as a hard
3221 // row break, so the row would split where the author wrote no break.
3222 let multiline =
3223 |r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
3224 if multiline(&open) || multiline(&close) {
3225 return None;
3226 }
3227 (!open.is_empty() || !close.is_empty()).then_some((open, close))
3228 }
3229
3230 /// Emit the source bytes of `range` as revealed markup — real glyphs, each
3231 /// mapped to its own source byte and each a caret stop, so a delimiter shown
3232 /// is a delimiter that can be selected, edited and deleted like any other
3233 /// text. Styled [`Role::Delimiter`] on top of the run's own style, which is
3234 /// how a frontend tells scaffolding from prose and dims it.
3235 ///
3236 /// Deliberately *not* [`push_escaped_text`]: this is raw source, not parsed
3237 /// text, so there is no escape-driven drift between the two to correct.
3238 fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
3239 let Some(text) = self.source.get(range.clone()) else {
3240 return;
3241 };
3242 push_text(out, text, range.start, base.role(Role::Delimiter));
3243 }
3244
3245 /// Render an inline node's children wrapped in its raw delimiters when the
3246 /// node is on the revealed line, and bare (delimiters resolved away) when it
3247 /// isn't — the shared body of every delimiter-bearing arm of
3248 /// [`inline`](Self::inline).
3249 ///
3250 /// `style` is the resolved styling the content still gets in *both* modes:
3251 /// revealing `*em*` shows the asterisks *and* keeps the text italic, the
3252 /// live-preview behaviour. Showing the markup is not the same as turning the
3253 /// rendering off — that is what [`crate::View::Source`] is for.
3254 fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
3255 let show = self
3256 .revealed(&self.nodes[id].span)
3257 .then(|| self.delims(id))
3258 .flatten();
3259 if let Some((open, _)) = &show {
3260 self.push_delim(out, open, style);
3261 }
3262 self.recurse(id, style, out);
3263 match &show {
3264 Some((_, close)) => self.push_delim(out, close, style),
3265 // Hidden, so the content's end has no glyph after it: give the
3266 // caret its home there.
3267 None => self.note_mark_end(id),
3268 }
3269 }
3270
3271 /// A verbatim span — or a formula drawn as its TeX — in the code style:
3272 /// its text at its content's offset, bracketed by its raw delimiters when
3273 /// `show` says the line is revealed and by nothing (plus the caret's home
3274 /// at the content's end) when it isn't.
3275 ///
3276 /// Not [`inline_delimited`](Self::inline_delimited): verbatim has no child
3277 /// nodes to recurse into — its content is its own `text` — so the fences
3278 /// bracket a [`push_text`] instead. The fences themselves keep
3279 /// `Role::Code`'s sibling treatment via [`push_delim`]'s role override.
3280 ///
3281 /// [`push_delim`]: Self::push_delim
3282 fn inline_verbatim(&self, id: usize, base: Style, out: &mut Vec<Glyph>, show: bool) {
3283 let node = &self.nodes[id];
3284 // The interior begins at `content_span.start` — past however many
3285 // backticks the fence used, which `span.start + 1` only guessed right
3286 // for a single one. Fall back to that guess if it's absent.
3287 let at = node
3288 .content_span
3289 .as_ref()
3290 .map_or(node.span.start + 1, |c| c.start);
3291 let style = base.role(Role::Code);
3292 let show = show.then(|| self.delims(id)).flatten();
3293 if let Some((open, _)) = &show {
3294 self.push_delim(out, open, style);
3295 }
3296 push_text(out, node.text.as_deref().unwrap_or(""), at, style);
3297 match &show {
3298 Some((_, close)) => self.push_delim(out, close, style),
3299 None => self.note_mark_end(id),
3300 }
3301 }
3302
3303 fn children(&self, id: usize) -> Vec<usize> {
3304 let mut out = Vec::new();
3305 let mut c = self.nodes[id].first_child;
3306 while let Some(cid) = c {
3307 out.push(cid.0 as usize);
3308 c = self.nodes[cid.0 as usize].next_sibling;
3309 }
3310 out
3311 }
3312
3313 /// Render a node's block children, a blank separator between each. `tight`
3314 /// suppresses the *fabricated* separator between adjacent children that share
3315 /// a source line boundary — a tight list item and the sub-list nested in it —
3316 /// while a real blank source line between them still opens a gap.
3317 fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
3318 // Frontmatter (a leading `metadata` block) is document metadata, not
3319 // prose: hide it entirely in the rich-text view. Skipping it here means
3320 // no phantom blank rows for its lines and no separator before the first
3321 // real block — the document opens straight into its content.
3322 let kids: Vec<usize> = self
3323 .children(id)
3324 .into_iter()
3325 .filter(|&c| self.nodes[c].kind != Kind::Metadata)
3326 .collect();
3327 let mut above: Option<BlockClass> = None;
3328 for child in kids {
3329 let below = BlockClass::from_node_kind(&self.nodes[child].kind);
3330 let before_sep = self.rows.len();
3331 if let Some(above) = above {
3332 self.emit_separators_before(
3333 self.nodes[child].span.start,
3334 pc,
3335 !tight,
3336 Boundary { above, below },
3337 );
3338 }
3339 // The first *drawn* child wears the first-row prefix (a bullet, a
3340 // footnote label), not the first child: a comment opening a list
3341 // item draws nothing, and the bullet belongs to what follows it.
3342 let first = if above.is_none() { pf } else { pc };
3343 if self.block_or_hidden(child, before_sep, first, pc) {
3344 above = Some(below);
3345 }
3346 }
3347 }
3348
3349 /// Render `child` after the separator [`Builder::emit_separators_before`]
3350 /// spelled for it from row `before_sep` on, and say whether it drew
3351 /// anything.
3352 ///
3353 /// A block that draws no rows — an HTML comment, which the rich view hides
3354 /// the way it hides frontmatter — is still *there* in the source, and the
3355 /// walk has to step over it: `last_off` moves past it so the next separator
3356 /// counts the blank lines from its end, not from wherever the last drawn
3357 /// block stopped. Left where it was, the separator counted every line of the
3358 /// comment as a blank row; and the cached path, whose per-block builder
3359 /// starts at offset 0, handed back a `last_off` of 0 and counted every line
3360 /// of the *document* — one phantom blank row per source line, once per
3361 /// comment. The separator drawn for it is taken back too, so a hidden block
3362 /// leaves no gap of its own: what stands either side of it meets across one
3363 /// boundary, as if the comment were not there.
3364 fn block_or_hidden(
3365 &mut self,
3366 child: usize,
3367 before_sep: usize,
3368 pf: &[Glyph],
3369 pc: &[Glyph],
3370 ) -> bool {
3371 let after_sep = self.rows.len();
3372 self.block(child, pf, pc);
3373 if self.rows.len() > after_sep {
3374 return true;
3375 }
3376 self.rows.truncate(before_sep);
3377 let end = self.nodes[child].span.end;
3378 self.last_off = self.last_off.max(end);
3379 self.stepped_over = self.stepped_over.max(end);
3380 false
3381 }
3382
3383 /// Render an explicit, ordered list of top-level blocks — [`Builder::blocks`]
3384 /// for a walk that isn't "the children of one node". The document's top level
3385 /// no longer is: a footnote definition is a root beside `doc`, not under it,
3386 /// and [`top_level`] merges it into this list by source position.
3387 ///
3388 /// The separator between blocks is spelled by the same
3389 /// [`Builder::emit_separators_before`] the incremental top-level walk in
3390 /// [`build_cached`] uses, so the two paths can't drift on how a boundary
3391 /// looks.
3392 ///
3393 /// Returns the class of the last block that drew anything — what the
3394 /// trailing blank lines close — or `None` when nothing did.
3395 fn top_blocks(&mut self, ids: &[usize], hidden_end: usize) -> Option<BlockClass> {
3396 let mut above: Option<BlockClass> = None;
3397 for &child in ids {
3398 let below = BlockClass::from_node_kind(&self.nodes[child].kind);
3399 let before_sep = self.rows.len();
3400 if let Some(above) = above {
3401 self.emit_separators_before(
3402 self.nodes[child].span.start,
3403 &[],
3404 true,
3405 Boundary { above, below },
3406 );
3407 } else {
3408 let from = self.leading_from(hidden_end);
3409 self.emit_leading_blank_lines(from, self.nodes[child].span.start, below);
3410 }
3411 if self.block_or_hidden(child, before_sep, &[], &[]) {
3412 above = Some(below);
3413 }
3414 }
3415 above
3416 }
3417
3418 /// Emit the blank separator row(s) that sit between a block ending at the
3419 /// current `last_off` and the next block starting at `next_start`, wearing
3420 /// the continuation prefix `pc`. Shared by [`Builder::blocks`] and the
3421 /// incremental top-level walk so the two can't drift on how a boundary is
3422 /// spelled.
3423 ///
3424 /// The blank line(s) between two blocks are real caret stops, each needing
3425 /// its *own* source offset — one strictly past the previous block's content,
3426 /// else it collides with that block's last row and `pos_of_offset`
3427 /// (first-match-wins) would resolve the caret onto the wrong row, pinning
3428 /// downward motion there.
3429 ///
3430 /// One row *per* blank source line, not a single collapsed separator: an
3431 /// empty paragraph opened between two blocks (Enter in the gap,
3432 /// `…\n\n\n\n…`) must be a navigable empty row, not vanish — else the caret
3433 /// in it snaps onto the *next* block's start and Enter looks like it did
3434 /// nothing.
3435 fn emit_separators_before(
3436 &mut self,
3437 next_start: usize,
3438 pc: &[Glyph],
3439 synthetic: bool,
3440 boundary: Boundary,
3441 ) {
3442 let mut offs = self.blank_rows_between(self.last_off, next_start);
3443 // Under a `</div>` the first blank line is the one Markdown needs to
3444 // end the div, not a line the author opened. Preserve flow drew it as
3445 // one, and Backspace there took it and glued the block below onto the
3446 // closing tag, as raw HTML.
3447 if self.preserve_soft && offs.first().is_some_and(|&o| self.closes_div_above(o)) {
3448 offs.remove(0);
3449 if offs.is_empty() {
3450 return;
3451 }
3452 }
3453 if offs.is_empty() {
3454 if !synthetic {
3455 // A tight list item's own text sits directly above the sub-list
3456 // nested in it — no fabricated gap. The "breathe" row belongs
3457 // between free-standing blocks, not between an item and its
3458 // child list, which the source writes on the very next line. A
3459 // real blank source line (a loose list) still lands a gap below,
3460 // because `blank_rows_between` found it and we never reach here.
3461 return;
3462 }
3463 // A tight gap with no blank line (e.g. a heading directly above its
3464 // text): keep the one conventional separator row so blocks still
3465 // breathe, as they always have.
3466 offs.push(self.blank_line_offset(self.last_off, next_start));
3467 }
3468 let last = offs.len() - 1;
3469 for (k, end_src) in offs.into_iter().enumerate() {
3470 // Only the drawn-only rows carry the boundary: the navigable blank
3471 // lines between them (and every blank line under preserve-soft flow)
3472 // are somewhere text can go, not a gap between blocks, and a frontend
3473 // that shrank one would be shrinking a line the author is typing on.
3474 let drawn = !self.preserve_soft && (k == 0 || k == last);
3475 // The blank line a boundary is *drawn* with isn't a place text can
3476 // go. The first one closes the block above and the last one opens the
3477 // block below — with a single blank line, the usual case, doing both
3478 // at once. Typing on either just continues the paragraph it abuts,
3479 // since the blank line it would need to be a paragraph of its own is
3480 // the very line being typed on. So they're a gap, like a table's
3481 // border: drawn, clickable, never a caret's home.
3482 //
3483 // The lines *between* them are the real ones. That's what Enter
3484 // opens: it inserts a paragraph break (`\n\n`), which leaves a blank
3485 // line spare on each side and the caret on the navigable line
3486 // between them.
3487 //
3488 // Preserve flow is the exception: there a bare `\n` is a visible line
3489 // break the author edits directly, so a lone blank line *is* a caret
3490 // home — typing on it makes the soft break the mode exists to show,
3491 // and Enter at a line's end lands the caret on exactly this row. So no
3492 // separator is drawn-only; every blank line is navigable.
3493 self.rows.push(VRow {
3494 glyphs: pc.to_vec(),
3495 end_src,
3496 decoration: drawn,
3497 code: false,
3498 code_lang: None,
3499 directive: false,
3500 directive_label: None,
3501 media: None,
3502 task: None,
3503 leaf_directive: None,
3504 heading: None,
3505 // A line the author can type on is a line of the block
3506 // around it, and keeps its spacing and alignment.
3507 align: (!drawn).then_some(self.presentation.align).flatten(),
3508 line_height: (!drawn).then_some(self.presentation.line_height).flatten(),
3509 boundary: drawn.then_some(boundary),
3510 mark_ends: Vec::new(),
3511 math: Vec::new(),
3512 });
3513 }
3514 }
3515
3516 /// The blank lines above the first block that draws anything, from `from`
3517 /// — the first line past any hidden frontmatter or comment — to the line
3518 /// holding `next_start`. [`Builder::emit_trailing_blank_lines`] turned
3519 /// upside down: nothing above needs a gap, so every line is an empty
3520 /// paragraph but the last, which is the gap that opens the block below.
3521 ///
3522 /// One blank line is only that gap, and draws nothing, which keeps the
3523 /// usual line under frontmatter out of sight. Two are the empty paragraph
3524 /// Enter opens at the first block's start (`\n\nHello`), which drew
3525 /// nothing either: the caret stayed with the text and the text stayed put.
3526 /// Preserve flow draws every line, as it does between blocks, but the one
3527 /// under frontmatter, which is the frontmatter's and not the author's.
3528 fn emit_leading_blank_lines(&mut self, from: usize, next_start: usize, below: BlockClass) {
3529 let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
3530 let mut offs = Vec::new();
3531 let mut start = from;
3532 while start < next_line_start {
3533 offs.push(start);
3534 match self.source[start..next_line_start].find('\n') {
3535 Some(k) => start += k + 1,
3536 None => break,
3537 }
3538 }
3539 if self.preserve_soft {
3540 if from > 0 && !offs.is_empty() {
3541 offs.remove(0);
3542 }
3543 } else if offs.len() < 2 {
3544 return;
3545 }
3546 let last = offs.len().saturating_sub(1);
3547 for (k, end_src) in offs.into_iter().enumerate() {
3548 let drawn = !self.preserve_soft && k == last;
3549 self.rows.push(VRow {
3550 glyphs: Vec::new(),
3551 end_src,
3552 decoration: drawn,
3553 code: false,
3554 code_lang: None,
3555 directive: false,
3556 directive_label: None,
3557 media: None,
3558 task: None,
3559 leaf_directive: None,
3560 heading: None,
3561 align: None,
3562 line_height: None,
3563 boundary: drawn.then_some(Boundary {
3564 above: BlockClass::Paragraph,
3565 below,
3566 }),
3567 mark_ends: Vec::new(),
3568 math: Vec::new(),
3569 });
3570 }
3571 }
3572
3573 /// Whether the line above the one starting at `line` is a `</div>`.
3574 fn closes_div_above(&self, line: usize) -> bool {
3575 let Some(above) = self.source[..line].strip_suffix('\n') else {
3576 return false;
3577 };
3578 let above = above.strip_suffix('\r').unwrap_or(above);
3579 let start = above.rfind('\n').map_or(0, |p| p + 1);
3580 above[start..].trim() == "</div>"
3581 }
3582
3583 /// The blank lines between a `<div>`'s last block and its closing tag,
3584 /// drawn inside the div, as the lines between two of its blocks are.
3585 ///
3586 /// One blank line is the one Markdown needs before `</div>`, and two are
3587 /// that and the gap, so neither draws (in Preserve flow, which has no
3588 /// gap, the second does). A third is the empty paragraph
3589 /// Enter opens at the end of the div's last block — the end of a line
3590 /// with a line height or an alignment on it — which drew nothing: the
3591 /// key looked dead, and each press left another line nobody could see.
3592 fn emit_div_trailing_lines(&mut self, id: usize, pc: &[Glyph]) {
3593 let Some(&last) = self.children(id).last() else {
3594 return;
3595 };
3596 if self.rows.is_empty() {
3597 return;
3598 }
3599 let end = self.nodes[id].span.end.min(self.source.len());
3600 let from = block_line(self.source, self.last_off).1;
3601 if from >= end {
3602 return;
3603 }
3604 let Some(close) = self.source[from..end].rfind("</div>") else {
3605 return;
3606 };
3607 let close_line = self.source[..from + close].rfind('\n').map_or(0, |p| p + 1);
3608 let mut offs = Vec::new();
3609 let mut start = from;
3610 while start < close_line {
3611 if !self.source[start..close_line]
3612 .lines()
3613 .next()
3614 .unwrap_or("")
3615 .trim()
3616 .is_empty()
3617 {
3618 return;
3619 }
3620 offs.push(start);
3621 match self.source[start..close_line].find('\n') {
3622 Some(k) => start += k + 1,
3623 None => break,
3624 }
3625 }
3626 // Preserve flow draws every blank line but the one `</div>` needs.
3627 if offs.len() < if self.preserve_soft { 2 } else { 3 } {
3628 return;
3629 }
3630 offs.pop();
3631 let above = BlockClass::from_node_kind(&self.nodes[last].kind);
3632 for (k, end_src) in offs.into_iter().enumerate() {
3633 let drawn = !self.preserve_soft && k == 0;
3634 self.rows.push(VRow {
3635 glyphs: pc.to_vec(),
3636 end_src,
3637 decoration: drawn,
3638 code: false,
3639 code_lang: None,
3640 directive: false,
3641 directive_label: None,
3642 media: None,
3643 task: None,
3644 leaf_directive: None,
3645 heading: None,
3646 align: (!drawn).then_some(self.presentation.align).flatten(),
3647 line_height: (!drawn).then_some(self.presentation.line_height).flatten(),
3648 boundary: drawn.then_some(Boundary {
3649 above,
3650 below: BlockClass::Paragraph,
3651 }),
3652 mark_ends: Vec::new(),
3653 math: Vec::new(),
3654 });
3655 }
3656 }
3657
3658 /// Where the lines above the first drawn block begin: past the hidden
3659 /// frontmatter, or past the line of the last hidden block the walk
3660 /// stepped over, whichever is later.
3661 fn leading_from(&self, hidden_end: usize) -> usize {
3662 if self.last_off == 0 {
3663 return hidden_end;
3664 }
3665 block_line(self.source, self.last_off).1.max(hidden_end)
3666 }
3667
3668 /// One block, drawn under whatever presentation the containers around it
3669 /// impose.
3670 ///
3671 /// A container named `div` with `Element` origin is transparent already —
3672 /// its children draw as themselves — and it now also *contributes* its
3673 /// vocabulary keys to every block it holds. That is the reading side of
3674 /// twig's own rule for where a Markdown block's attributes live: there is
3675 /// no attribute syntax to put on the paragraph, so `set_block_attrs` writes
3676 /// a `<div …>` around it, and reading one back has to look through the div.
3677 /// `<div class="center">` around three paragraphs centres all three, which
3678 /// is what the author of that HTML meant, and around one is the sole-child
3679 /// shape twig writes.
3680 ///
3681 /// Saved and restored rather than pushed onto a stack, so a nested div
3682 /// reads its own keys over its parent's and the block *after* the div is
3683 /// unaffected.
3684 fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
3685 if element_tag(&self.nodes[id]) == Some("div") {
3686 let saved = self.presentation;
3687 self.presentation = saved.under(&self.nodes[id].attrs, &self.faces);
3688 self.block_kind(id, pf, pc);
3689 self.emit_div_trailing_lines(id, pc);
3690 self.presentation = saved;
3691 // Step the walk past the closing `</div>`, as the fenced-div arm
3692 // below anchors past its `:::`. The tag sits on a line of its own
3693 // after the last child and the blank line under it, and the rich
3694 // view draws nothing for it — so left where the last child ended,
3695 // the separator logic read the tag's line as a blank line between
3696 // the div and the block below, and drew a navigable empty row there
3697 // that the author never opened and Backspace could not close; and
3698 // at the end of the document the trailing count read it as an empty
3699 // paragraph the author had left. It is hidden markup the walk steps
3700 // over, which is what `stepped_over` records, so both counts start
3701 // past it.
3702 let end = self.nodes[id].span.end;
3703 self.last_off = self.last_off.max(end);
3704 self.stepped_over = self.stepped_over.max(end);
3705 return;
3706 }
3707 self.block_kind(id, pf, pc);
3708 }
3709
3710 fn block_kind(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
3711 let node = &self.nodes[id];
3712 match node.kind.as_str() {
3713 "doc" | "section" => self.blocks(id, pf, pc, false),
3714 "heading" => {
3715 // A heading whose only visible content is a single image — a
3716 // banner set in an `<h1>` (`<h1><picture><img></picture></h1>`),
3717 // or `# ` — is a block picture, not text. Render
3718 // it as one; anything with real heading text falls through.
3719 if let Some((m, kind)) = self.media_only(id) {
3720 self.block_media(m, kind, id, pf);
3721 return;
3722 }
3723 let level = node.level.unwrap_or(1);
3724 // A `data-size` on a heading scales the *heading's* ramp, not
3725 // the body's — the role and the step compose rather than
3726 // compete, which is the same thing a colour does to a link.
3727 let pres = self.presentation.under(&node.attrs, &self.faces);
3728 let style = pres.over(heading_style(level));
3729 let mut glyphs = Vec::new();
3730 // On the revealed line the `# ` comes back as real, editable
3731 // text in front of the heading. Only the opening marker: a
3732 // closing `#`-run (`## title ##`) is covered by the same
3733 // `delims` pair, and a setext underline is excluded there for
3734 // being on another line entirely.
3735 if let Some((open, close)) =
3736 self.revealed(&node.span).then(|| self.delims(id)).flatten()
3737 {
3738 self.push_delim(&mut glyphs, &open, style);
3739 glyphs.extend(self.inline_children_with_trailing(id, style));
3740 self.push_delim(&mut glyphs, &close, style);
3741 } else {
3742 glyphs = self.inline_children_with_trailing(id, style);
3743 }
3744 // An *empty* heading — `# ` with nothing typed after it, which is
3745 // what the toolbar's H1 leaves on a blank line — has no glyph for
3746 // its row to end on, so the fallback below is the row's whole
3747 // extent: its only caret stop, and the offset every row after it
3748 // is measured from. The block's start is the wrong answer for
3749 // both, because it sits *in front of* the `# ` the rich view
3750 // hides: the caret drew (and typed) before the hashes, and the
3751 // rows below inherited an offset short by the marker's length,
3752 // which put the caret on one of them the moment the heading grew
3753 // text. Its content's start is where the caret belongs.
3754 let home = heading_content_start(self.source, &node.span);
3755 let first = self.rows.len();
3756 self.emit_wrapped(glyphs, home, pf, pc);
3757 // Stamp the level on every row the heading just emitted — a
3758 // wrapped heading's continuation rows as much as its first, and
3759 // an empty one's single glyphless row, which is the whole point
3760 // (see [`VRow::heading`]).
3761 for row in &mut self.rows[first..] {
3762 row.heading = Some(level.min(255) as u8);
3763 row.align = pres.align;
3764 row.line_height = pres.line_height;
3765 }
3766 }
3767 "block_quote" => {
3768 let (start, end) = (node.span.start, node.span.end);
3769 let gutter = synth("│ ", Role::QuoteGutter, start);
3770 let f = concat(pf, &gutter);
3771 let c = concat(pc, &gutter);
3772 // A childless quote — a bare `> ` on an otherwise blank line,
3773 // which is what the toolbar's Quote button leaves there — has no
3774 // inner block to carry the gutter or a caret home, so `blocks`
3775 // emitted *nothing at all*: the quote didn't merely draw
3776 // unstyled, it disappeared, and a document that was only `> `
3777 // rendered zero rows with the caret nowhere to stand. Emit the
3778 // gutter row itself, ending just past the marker, exactly as an
3779 // empty `list_item` emits its bare bullet.
3780 if self.children(id).is_empty() {
3781 self.push_row_at(f, end.min(self.source.len()));
3782 } else {
3783 self.blocks(id, &f, &c, false);
3784 self.emit_quote_trailing_lines(&c, end);
3785 }
3786 }
3787 // A generic `:::name{.class}` fenced-div container (twig's
3788 // `directive`, container form). Core is agnostic of `name` — it's
3789 // the host app's vocabulary (diaryx's `vis` for audience
3790 // visibility, say) and isn't available here regardless: twig only
3791 // threads an `element`'s tag name through `FlatNode::name`, not a
3792 // directive's own identifier. Every row gets marked `directive` (a
3793 // frontend draws a tinted panel around each maximal run, the
3794 // `code`/`code_block` recipe) and the first row carries a label —
3795 // the way a code fence's language rides only its first row.
3796 //
3797 // The label reads BOTH attribute conventions diaryx content
3798 // actually uses: twig's own dot-prefixed classes (`{.public
3799 // .family}`, one combined `class` attr) and bare pandoc-style
3800 // words with no leading dot (`{public family}` — the syntax
3801 // `diaryx_core::visibility`'s hand-rolled publish-time filter and
3802 // apps/web's directive serializer both write; twig parses each
3803 // bare word as its own attribute with an empty value, per
3804 // `languages/markdown/attributes.zig`). Reading only `.class`
3805 // would leave every *existing* diaryx `:::vis{...}` block
3806 // unlabeled.
3807 // Only the *container* form is the panel below. A `text` directive
3808 // is inline and never reaches the block walker (see `is_inline`); a
3809 // `leaf` one is a standalone block with no body, drawn as a
3810 // placeholder the way an image is.
3811 "container"
3812 if container_is_directive(node)
3813 && node.directive_form == Some(DirectiveForm::Leaf) =>
3814 {
3815 self.block_directive(id, pf);
3816 }
3817 // HTML and AsciiDoc spell `insert_directive`'s page break in ways
3818 // of their own — `<page-break></page-break>`, an *element* with
3819 // no form at all, and `<<<`, a directive with none — so neither
3820 // meets the arm above. Both are named `page-break` and empty, and
3821 // both draw the same placeholder, for the same reason djot's
3822 // fence below does: a frontend that paginates on the mark must
3823 // not be able to tell which format the file is in. Narrow to the
3824 // one name: an arbitrary empty custom element is not a leaf
3825 // directive.
3826 "container"
3827 if node.name.as_deref() == Some(crate::doc::PAGE_BREAK)
3828 && node.directive_form.is_none()
3829 && node.origin.is_some()
3830 && self.children(id).is_empty() =>
3831 {
3832 self.block_directive(id, pf);
3833 }
3834 // djot has no *leaf* directive form. `insert_directive` spells the
3835 // same document as an empty `::: page-break` fence — a container
3836 // with nothing in it — and the name comes back as the fence's one
3837 // class rather than as the node's name, because djot's div is
3838 // anonymous. Draw it as the placeholder Markdown's `::page-break`
3839 // gets, so a frontend that paginates on a `page-break`
3840 // [`DirectiveMark`] cannot tell which format the file is in.
3841 //
3842 // Narrow on purpose: only an *anonymous* empty fence. A Markdown
3843 // `:::note` with nothing in it keeps the reading it has, because
3844 // its name is its own and nothing about it says "a block with no
3845 // body" the way djot's spelling of a leaf directive does.
3846 "container"
3847 if container_is_directive(node)
3848 && node.directive_form == Some(DirectiveForm::Container)
3849 && node.name.as_deref().unwrap_or_default().is_empty()
3850 && self.children(id).is_empty()
3851 && !leaf_directive_identity(node, self.source).0.is_empty() =>
3852 {
3853 self.block_directive(id, pf);
3854 }
3855 "container" if container_is_directive(node) => {
3856 let label = directive_attr_label(&node.attrs);
3857 let start_row = self.rows.len();
3858 self.blocks(id, pf, pc, false);
3859 for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
3860 row.directive = true;
3861 if i == 0 {
3862 row.directive_label = label.clone();
3863 }
3864 }
3865 // Anchor the block's end past its closing `:::` fence, exactly as
3866 // the code-block arm anchors past its ```` ``` ````. A container's
3867 // last content row ends at its last *child*, before the fence and
3868 // the blank line under it, so the separator logic counted the
3869 // fence line as a blank row of its own and drew a second boundary
3870 // — one gap's worth of margin twice, under every fenced div.
3871 self.last_off = node.span.end;
3872 }
3873 "bullet_list" | "ordered_list" | "task_list" => {
3874 let ordered = node.kind == Kind::OrderedList;
3875 let mut item_no = 0usize;
3876 let kids = self.children(id);
3877 for (i, child) in kids.iter().copied().enumerate() {
3878 let kind = &self.nodes[child].kind;
3879 if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
3880 let start = self.nodes[child].span.start;
3881 item_no += 1;
3882 // A task item's box replaces the bullet rather than
3883 // joining it. The `[ ] ` that spells it is markup twig
3884 // has already consumed — the item's paragraph *content*
3885 // starts past it — so without a drawn box a task item
3886 // was indistinguishable from a plain bullet, ticked or
3887 // not. `☐`/`☑` is the marker for the same reason `•` is:
3888 // it stands where the source's own marker stands. Which
3889 // way it faces is `checked`, straight off the node.
3890 let checked = self.nodes[child].checked;
3891 let marker = match (checked, ordered) {
3892 (Some(true), _) => "☑ ".to_string(),
3893 (Some(false), _) => "☐ ".to_string(),
3894 (None, true) => format!("{item_no}. "),
3895 (None, false) => "• ".to_string(),
3896 };
3897 let bullet = synth(&marker, Role::ListMarker, start);
3898 // The item's later rows wear the marker's own
3899 // characters, drawn blank: the width a proportional
3900 // face gives the marker, whatever the marker is.
3901 let indent = synth(&marker, Role::ListIndent, start);
3902 let first_row = self.rows.len();
3903 self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
3904 // On the item's first row, the way `code_lang` rides the
3905 // first row of its block.
3906 if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
3907 row.task = Some(c);
3908 }
3909 } else {
3910 // twig can nest a *following* top-level block as a direct
3911 // child of the list rather than a sibling of it — e.g.
3912 // `- item\n\n> quote` parses the block quote under the
3913 // `bullet_list`. It isn't a list item, so render it de-nested:
3914 // no bullet, at the list's own prefix, with the usual block
3915 // separator — never `• │ quote`.
3916 if i > 0 {
3917 self.emit_separators_before(
3918 self.nodes[child].span.start,
3919 pc,
3920 true,
3921 Boundary {
3922 above: BlockClass::from_node_kind(
3923 &self.nodes[kids[i - 1]].kind,
3924 ),
3925 below: BlockClass::from_node_kind(&self.nodes[child].kind),
3926 },
3927 );
3928 }
3929 self.block(child, pc, pc);
3930 }
3931 }
3932 }
3933 "list_item" | "task_list_item" => {
3934 // A childless item — the empty bullet you get the instant you
3935 // press Enter to open a new one — has no inner block to carry the
3936 // marker prefix or a caret home, so `blocks` would emit nothing
3937 // and the new bullet simply wouldn't appear until something was
3938 // typed into it. Emit the prefixed row itself, ending at a caret
3939 // stop just past the marker (the item's `span.end`), the way an
3940 // empty paragraph emits its one prefixed row via `emit_wrapped`.
3941 if self.children(id).is_empty() {
3942 let home = self.nodes[id].span.end.min(self.source.len());
3943 self.push_row_at(pf.to_vec(), home);
3944 } else {
3945 // Tight: an item's text and the list nested under it butt
3946 // together (`• a` / ` • b`), no fabricated blank row between —
3947 // a loose item's real blank line still parts them.
3948 self.blocks(id, pf, pc, true);
3949 }
3950 }
3951 // A footnote *definition* (`[^1]: the note`). It reaches this walker
3952 // only because [`top_level`] merges it back in — twig hangs it off no
3953 // parent at all, so a walk from `doc` never sees one and every byte
3954 // of its body used to render as nothing.
3955 //
3956 // Drawn as a hanging-indent item, the way a list item is: the marker
3957 // reads `[1] `, matching the `[1]` its references render as, so the
3958 // two can be paired by eye, and the body wraps under it. The marker
3959 // is synthetic decoration (one shared offset, never a caret stop) —
3960 // the `[^1]: ` that spells it in the source is markup, hidden like a
3961 // heading's `# `.
3962 "footnote" => {
3963 let (start, end) = (node.span.start, node.span.end);
3964 let source = self.source;
3965 let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
3966 let f = concat(pf, &synth(&marker, Role::ListMarker, start));
3967 let c = concat(pc, &synth(&marker, Role::ListIndent, start));
3968 if self.children(id).is_empty() {
3969 // A definition with no body yet — the instant `[^1]: ` has
3970 // been typed and nothing after it. `blocks` would emit
3971 // nothing and the definition simply wouldn't appear, so emit
3972 // the marker row itself with a caret home just past it,
3973 // exactly as an empty list item does.
3974 self.push_row_at(f, end.min(source.len()));
3975 } else {
3976 self.blocks(id, &f, &c, false);
3977 }
3978 }
3979 // A link reference definition (`[foo]: /url`): resolved by label
3980 // into the links that use it, and drawn nowhere — the rich view has
3981 // no more use for its line than for a comment's. It is walked at all
3982 // (see [`top_level`]) so [`Builder::block_or_hidden`] can step the
3983 // walk past its bytes rather than count them as blank lines.
3984 "reference" => {}
3985 "table" => self.table(id, pf, pc),
3986 "code_block" => {
3987 let style = Style::default().role(Role::Code);
3988 let text = node.text.clone().unwrap_or_default();
3989 // Cut the block's *terminator*, not every trailing newline. A
3990 // block whose last line is empty spells that as a second `\n`,
3991 // and `trim_end_matches` ate it along with the terminator: the
3992 // Return that made the line got no row, so the caret placed on
3993 // it fell through to the paragraph below and typing landed
3994 // outside the block. twig's `content_span` is `text` less
3995 // exactly this one newline, so cutting one and no more is also
3996 // what keeps `code_line_offsets` lined up.
3997 let lines: Vec<&str> = text
3998 .strip_suffix('\n')
3999 .unwrap_or(text.as_str())
4000 .split('\n')
4001 .collect();
4002 // Each line at its own source offset, so the caret can walk the
4003 // code a character at a time like any other text. Where the
4004 // lines can't be lined up with the source there's no honest
4005 // offset to give, so the block maps coarsely to its start (and
4006 // stays a source-view job, as all of it once was).
4007 let offs = node
4008 .content_span
4009 .as_ref()
4010 .and_then(|c| self.code_line_offsets(c, &lines));
4011 // The fence's info string, carried on the block's first row as
4012 // its language label (`None` for an indented block or a bare
4013 // fence). Kept on the row so it rides the block cache.
4014 let lang = code_language(self.source, node.span.start);
4015 // The block's syntax highlighting, a token per byte range of
4016 // each line — `None` unless the fence names a language the
4017 // grammars know (and unless the `syntax` feature is on). Done
4018 // here, once per build of the block, because the rows it
4019 // colours ride the block cache: an edit elsewhere in the
4020 // document reuses them, tokens and all.
4021 let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
4022 for (i, raw) in lines.iter().enumerate() {
4023 let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
4024 // No gutter glyph: the block is set apart by the border and
4025 // tint a frontend draws around the whole run of `code` rows,
4026 // not by a per-line mark. Just the block prefix and the
4027 // code text: the first-row prefix on the first line (a list
4028 // item's marker) and the continuation on the rest (its
4029 // indent), as a wrapped paragraph takes them.
4030 let mut glyphs: Vec<Glyph> = if i == 0 { pf } else { pc }.to_vec();
4031 match tokens.as_ref().and_then(|t| t.get(i)) {
4032 Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
4033 None => push_text(&mut glyphs, raw, at, style),
4034 }
4035 // Explicitly past the line's *text*: a blank code line has no
4036 // glyph, and any prefix's offset would put the row's end
4037 // inside the next line.
4038 self.push_row_at(glyphs, at + raw.len());
4039 if let Some(row) = self.rows.last_mut() {
4040 row.code = true;
4041 if i == 0 {
4042 row.code_lang = lang.clone();
4043 }
4044 }
4045 }
4046 // Anchor the block's end past its closing fence. Its last content
4047 // row ends at the last code line, before the ``` and the blank
4048 // line under it; without this the separator logic would count the
4049 // closing-fence line as its own blank row and open a phantom
4050 // second gap below the block.
4051 self.last_off = node.span.end;
4052 }
4053 "thematic_break" => {
4054 let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
4055 let w = full.saturating_sub(prefix_width(pf)).max(4);
4056 let mut glyphs = pf.to_vec();
4057 for _ in 0..w {
4058 glyphs.push(Glyph {
4059 ch: '─',
4060 style: Style::default().role(Role::Rule),
4061 src: node.span.start,
4062 // A rule is a block the caret can sit on, as it always
4063 // has; it maps coarsely to the block's start.
4064 stop: true,
4065 });
4066 }
4067 // The dashes share one caret home in front of the atomic block,
4068 // while the row's end is the second home, past the newline that
4069 // ends the rule's line. Without that trailing stop a final rule
4070 // made the document end unreachable: Right could not cross it
4071 // and a click in the empty space below it snapped back before
4072 // the rule.
4073 let (text_end, after_line) = block_line(self.source, node.span.end);
4074 self.push_row_at(glyphs, after_line);
4075 // The walk stands at the rule itself, though, as it stands at
4076 // the end of any other block's last line: the lines under a
4077 // rule are counted from the newline that ends it. Counted from
4078 // the home past that newline, every gap under a rule came up a
4079 // line short, and the empty paragraph Enter opens beneath one
4080 // drew as nothing at all.
4081 self.last_off = text_end;
4082 }
4083 // A block-level image node with no wrapping paragraph — a promoted
4084 // top-level HTML `<img>` lands as a direct `doc` child like this
4085 // (a Markdown `` comes wrapped in a `para`, handled below).
4086 "image" => self.block_media(id, MediaKind::Image, id, pf),
4087 // The same case for a promoted top-level `<video>`/`<audio>`, which
4088 // arrives as a generic `container` rather than a node kind of its
4089 // own. It can't be found by the `media_only` scan below the way a
4090 // wrapped one is: that scan looks at a wrapper's *children*, and here
4091 // the media element is itself the block.
4092 "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
4093 let kind = match element_tag(node) {
4094 Some("audio") => MediaKind::Audio,
4095 _ => MediaKind::Video,
4096 };
4097 self.block_media(id, kind, id, pf);
4098 }
4099 _ => {
4100 // A container of blocks, or an inline-bearing paragraph.
4101 let kids = self.children(id);
4102 // A block-level image: a paragraph (or other wrapper — a
4103 // `<picture>`, an `<h1>` banner) whose only visible content is a
4104 // single `image` node. Render it as a placeholder row + record an
4105 // [`MediaInfo`] a capable frontend replaces. An image mixed with
4106 // real text or other images on the line isn't block-level and
4107 // falls through to the inline path below, still as its alt text.
4108 if let Some((m, kind)) = self.media_only(id) {
4109 self.block_media(m, kind, id, pf);
4110 return;
4111 }
4112 // A display formula on lines of its own — a paragraph whose
4113 // only visible content is one `display_math` — promotes the
4114 // same way, to a placeholder row and a [`MathMark`].
4115 if let Some(m) = self.math_only(id) {
4116 self.block_math(m, pf, pc);
4117 return;
4118 }
4119 let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
4120 if inline || kids.is_empty() {
4121 // The block's own attributes over its containers' — the
4122 // three run-level keys become the style its glyphs start
4123 // from, and the two line-level ones ride every row it
4124 // emits, a wrapped paragraph's continuations included.
4125 let pres = self.presentation.under(&node.attrs, &self.faces);
4126 let glyphs =
4127 self.inline_children_with_trailing(id, pres.over(Style::default()));
4128 if !glyphs.is_empty() {
4129 let first = self.rows.len();
4130 self.emit_wrapped(glyphs, node.span.start, pf, pc);
4131 for row in &mut self.rows[first..] {
4132 row.align = pres.align;
4133 row.line_height = pres.line_height;
4134 }
4135 }
4136 } else {
4137 self.blocks(id, pf, pc, false);
4138 }
4139 }
4140 }
4141 }
4142
4143 /// Render a table as a box-drawn grid: every column as wide as its widest
4144 /// cell, the header bold and ruled off, each cell padded to its column's
4145 /// alignment. This is the *default* monospace rendering (see
4146 /// [`VisualMap::rows`]); the same cells are also published structurally as
4147 /// [`TableInfo`], so a frontend that lays the grid out in its own units draws
4148 /// from there and skips the picture built here.
4149 ///
4150 /// The alignment comes from twig's `cell.alignment` — the delimiter row
4151 /// (`|:--|--:|`) that spells it out is consumed by the parser and leaves no
4152 /// node, so the snapshot is the only source for it.
4153 ///
4154 /// Borders and padding are *decoration*: they carry the source offset of the
4155 /// text they surround, so a click lands in that cell, but they're never
4156 /// caret stops — the caret steps cell-to-cell instead of into the box art.
4157 fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
4158 let node_end = self.nodes[id].span.end;
4159 // twig's shape is `[caption, row, row, …]`: the caption is always
4160 // present (usually empty in Markdown) and is not part of the grid.
4161 let row_ids: Vec<usize> = self
4162 .children(id)
4163 .into_iter()
4164 .filter(|&c| self.nodes[c].kind == Kind::Row)
4165 .collect();
4166 if row_ids.is_empty() {
4167 return;
4168 }
4169 // Lay every cell out first — the column widths depend on all of them.
4170 let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
4171 let heads: Vec<bool> = row_ids
4172 .iter()
4173 .map(|&r| self.nodes[r].head.unwrap_or(false))
4174 .collect();
4175 let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
4176 if cols == 0 {
4177 return;
4178 }
4179 let mut widths = vec![0usize; cols];
4180 for row in &grid {
4181 for (c, cell) in row.iter().enumerate() {
4182 widths[c] = widths[c].max(cell_width(&cell.glyphs));
4183 }
4184 }
4185 // Every column at its widest cell is only the *wish*; a grid wider than
4186 // the surface has its far side hanging off the edge where no amount of
4187 // caret motion can reach it. Cut it down to what's actually there, and
4188 // let the cells wrap into the space they're given.
4189 if let Some(w) = self.wrap {
4190 fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
4191 }
4192
4193 // Where the picture starts, so a frontend drawing its own grid knows
4194 // which rows to skip. Recorded before the first border goes down.
4195 let rows_start = self.rows.len();
4196
4197 let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
4198 self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
4199 for (ri, row) in grid.iter().enumerate() {
4200 self.push_table_row(row, &widths, pc);
4201 // The rule under the header: only where the head actually ends.
4202 let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
4203 if ends_head {
4204 let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
4205 self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
4206 }
4207 }
4208 // The bottom border is the one rule the caret can rest on: its end is
4209 // the table's trailing stop, the caret home just past the block — the
4210 // peer of a block picture's second stop, and of a rule's row end. Without
4211 // it a document ending in a table ended *inside* it: nothing after the
4212 // last cell was a stop, so Right could not leave the table, and a click
4213 // in the blank space under it snapped back into the last cell — or, on a
4214 // surface that resolved the click onto the border row, to the table's
4215 // first cell, since a decoration row's only stop is the nearest one.
4216 // Typing at the stop opens a paragraph first, as at a picture's — see
4217 // `Doc::open_paragraph_at_block_edge`. The glyphs stay non-stops at
4218 // `node_end`, so a click anywhere on the border lands past the table.
4219 self.push_rule_with_home(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
4220
4221 // The same cells the picture above was drawn from, published unwrapped
4222 // and unpadded for a frontend that lays them out in pixels.
4223 self.tables.push(TableInfo {
4224 rows_span: rows_start..self.rows.len(),
4225 end_src: node_end,
4226 // The *continuation* prefix: `pf` opens the block and only its first
4227 // row wears it, but every row of a grid is a continuation of the
4228 // block the table sits in.
4229 prefix: pc.to_vec(),
4230 grid: grid
4231 .into_iter()
4232 .zip(heads)
4233 .map(|(cells, head)| TableRow { head, cells })
4234 .collect(),
4235 });
4236 // The table's own end anchors whatever separator follows it; the border
4237 // rows deliberately don't move `last_off` (they hold no content).
4238 self.last_off = node_end;
4239 }
4240
4241 /// One row of laid-out cells, in column order.
4242 fn row_cells(&self, row: usize) -> Vec<TableCell> {
4243 // A cell is one source line, so a break within it is an explicit line
4244 // break (an inline `<br>`) that must render as a line of its own — not the
4245 // flow-folding space a break is in prose.
4246 self.break_glyph.set('\n');
4247 let cells = self
4248 .children(row)
4249 .into_iter()
4250 .filter(|&c| self.nodes[c].kind == Kind::Cell)
4251 .enumerate()
4252 .map(|(col, c)| {
4253 let n = &self.nodes[c];
4254 let style = if n.head.unwrap_or(false) {
4255 Style::default().bold()
4256 } else {
4257 Style::default()
4258 };
4259 // Only `content_span` bounds a cell's text, and an EMPTY cell
4260 // has none at all — twig records no interior for it — so both
4261 // offsets would fall back to the cell's `span.start`: on the
4262 // pipe that opens it, or (under a twig that gave every cell
4263 // the whole row's span) the row's start, where every empty
4264 // cell collapses onto one spot before the first `│` and a
4265 // caret there types *before* the table. Derive the interior
4266 // from the span's own pipes and this cell's column instead,
4267 // so each empty cell has a distinct, editable caret home.
4268 let span = n.content_span.clone().unwrap_or_else(|| {
4269 let off = empty_cell_offset(
4270 &self.source[n.span.start.min(self.source.len())
4271 ..n.span.end.min(self.source.len())],
4272 n.span.start,
4273 col,
4274 );
4275 off..off
4276 });
4277 TableCell {
4278 glyphs: self.inline_children(c, style),
4279 start: span.start,
4280 end: span.end,
4281 align: n.alignment.unwrap_or(Alignment::Default),
4282 }
4283 })
4284 .collect();
4285 self.break_glyph.set(' ');
4286 cells
4287 }
4288
4289 /// A horizontal rule between/around rows — entirely decoration.
4290 fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
4291 self.push_rule_row(text, src, prefix, true);
4292 }
4293
4294 /// A table's bottom border: drawn like the other rules, but a row the caret
4295 /// can rest on, its end (`src`) being the table's trailing stop.
4296 fn push_rule_with_home(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
4297 self.push_rule_row(text, src, prefix, false);
4298 }
4299
4300 fn push_rule_row(&mut self, text: &str, src: usize, prefix: &[Glyph], decoration: bool) {
4301 let glyphs = concat(prefix, &synth(text, Role::Rule, src));
4302 self.rows.push(VRow {
4303 glyphs,
4304 end_src: src,
4305 decoration,
4306 code: false,
4307 code_lang: None,
4308 directive: false,
4309 directive_label: None,
4310 media: None,
4311 task: None,
4312 leaf_directive: None,
4313 heading: None,
4314 align: None,
4315 line_height: None,
4316 boundary: None,
4317 mark_ends: Vec::new(),
4318 math: Vec::new(),
4319 });
4320 }
4321
4322 /// One `│ a │ b │` row of the grid: real cell text between decoration.
4323 ///
4324 /// A row of cells is not a row of the screen — a cell wrapped to its column
4325 /// spans several, each one `│`-divided across the full width so the grid
4326 /// stays square. Cells in the same row are laid out independently and run
4327 /// out at their own heights; a column that has run dry pads out as
4328 /// decoration while its neighbours keep going.
4329 fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
4330 let fallback = cells.last().map(|c| c.end).unwrap_or(0);
4331 let laid: Vec<Vec<Vec<Glyph>>> = cells
4332 .iter()
4333 .enumerate()
4334 .map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
4335 .collect();
4336 let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
4337
4338 for j in 0..height {
4339 let mut glyphs = prefix.to_vec();
4340 for (ci, &w) in widths.iter().enumerate() {
4341 let cell = cells.get(ci);
4342 let line = laid.get(ci).and_then(|l| l.get(j));
4343 // The divider before this column belongs to the cell it
4344 // introduces, so clicking it lands in that cell — on this line
4345 // of it, which is what's next to the divider being clicked.
4346 let at = line
4347 .and_then(|l| l.first().map(|g| g.src))
4348 .or_else(|| cell.map(|c| c.start))
4349 .unwrap_or(fallback);
4350 glyphs.extend(synth("│", Role::Rule, at));
4351 match (cell, line) {
4352 (Some(cell), Some(line)) => {
4353 let pad = w.saturating_sub(glyphs_width(line));
4354 let (lead, trail) = match cell.align {
4355 Alignment::Right => (pad, 0),
4356 Alignment::Center => (pad / 2, pad - pad / 2),
4357 Alignment::Left | Alignment::Default => (0, pad),
4358 };
4359 // Every line renders at least one space after its text
4360 // (the gutter before `│`), so there is always somewhere
4361 // to put the "after the last character" caret a line
4362 // needs. It's the one padding glyph that is a stop: on
4363 // the cell's last line that's the cell's end, and on any
4364 // other it's the space the wrap consumed.
4365 let last = laid[ci].len() == j + 1;
4366 let end = match last {
4367 true => cell.end,
4368 false => line
4369 .last()
4370 .map(|g| g.src + g.ch.len_utf8())
4371 .unwrap_or(cell.end),
4372 };
4373 glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
4374 glyphs.extend(line.iter().cloned());
4375 glyphs.push(Glyph {
4376 ch: ' ',
4377 style: Style::default(),
4378 src: end,
4379 stop: true,
4380 });
4381 glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
4382 }
4383 // A ragged row, or a column whose cell ended higher up: pad
4384 // it out so the grid stays square.
4385 _ => {
4386 let at = cell.map(|c| c.end).unwrap_or(fallback);
4387 glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
4388 }
4389 }
4390 }
4391 glyphs.extend(synth("│", Role::Rule, fallback));
4392 // The row ends where its last stop does. A table row has no gap
4393 // between its final cell and the border, so inventing an end past
4394 // that would be a stop with nothing under it.
4395 let end_src = glyphs
4396 .iter()
4397 .rev()
4398 .find(|g| g.stop)
4399 .map_or(fallback, |g| g.src);
4400 let mark_ends = self.take_mark_ends(end_src);
4401 let math = self.take_math(&glyphs);
4402 self.rows.push(VRow {
4403 glyphs,
4404 end_src,
4405 decoration: false,
4406 code: false,
4407 code_lang: None,
4408 directive: false,
4409 directive_label: None,
4410 media: None,
4411 task: None,
4412 leaf_directive: None,
4413 heading: None,
4414 align: None,
4415 line_height: None,
4416 boundary: None,
4417 mark_ends,
4418 math,
4419 });
4420 }
4421 }
4422
4423 /// Render a block-level image, video, or audio as one placeholder row: the
4424 /// `🖼 alt` / `🎬 alt` / `🔊 alt` label styled [`Role::Image`], every glyph
4425 /// mapped to the media's start offset and a caret stop there (they share the
4426 /// offset, so the stop table dedups them to a single home in front of it, as
4427 /// a rule's dashes do), and the row's end stop set past it so the caret can
4428 /// also rest after it. The row carries a [`MediaMark`] so [`media_spans`]
4429 /// publishes it as a [`MediaInfo`] a capable frontend replaces with the real
4430 /// picture or player; a plain surface paints the label as-is. `pf` is the
4431 /// block prefix (a list indent, a quote gutter) the row opens with, exactly
4432 /// as every other block honours it.
4433 fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
4434 let node = &self.nodes[img];
4435 let start = node.span.start;
4436 let end = node.span.end;
4437 // An `image`'s URL is twig's `destination`; a `<video>`/`<audio>` is a
4438 // generic element, so its URL is the `src` attribute — and may be absent
4439 // entirely, the element naming its candidates in child `<source>`s.
4440 let destination = match kind {
4441 MediaKind::Image => node.destination.clone().unwrap_or_default(),
4442 MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
4443 };
4444 let poster = match kind {
4445 MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
4446 MediaKind::Image | MediaKind::Audio => String::new(),
4447 };
4448 // The `<source>`s under the media element itself, not under `wrapper`: a
4449 // `<video>` is its own container, unlike an `<img>`, whose `<picture>`
4450 // alternatives are its *siblings* and so only reachable from the wrapper.
4451 let sources = match kind {
4452 MediaKind::Image => self.media_sources(wrapper),
4453 MediaKind::Video | MediaKind::Audio => self.media_sources(img),
4454 };
4455 let alt = self.image_alt(img);
4456 let sigil = kind.sigil();
4457 let label = if alt.is_empty() {
4458 // With no alt, name the file — but a `<video>` with neither `src` nor
4459 // alt has only its `<source>`s to be named by, so fall back to the
4460 // first candidate rather than labelling the row a bare sigil.
4461 let named = if destination.is_empty() {
4462 sources
4463 .first()
4464 .map(|s| s.srcset.as_str())
4465 .unwrap_or_default()
4466 } else {
4467 &destination
4468 };
4469 format!("{sigil} {}", media_label(named))
4470 } else {
4471 format!("{sigil} {alt}")
4472 };
4473 let style = Style::default().role(Role::Image);
4474 let mut glyphs = pf.to_vec();
4475 for ch in label.chars() {
4476 glyphs.push(Glyph {
4477 ch,
4478 style,
4479 src: start,
4480 stop: true,
4481 });
4482 }
4483 // How many rows the frontend wants for this picture: the label row plus
4484 // the blank fillers below it. Absent (a GUI that lays images out in
4485 // pixels, an image that didn't resolve, or a plain surface) means the
4486 // bare one-row placeholder.
4487 let rows = self
4488 .surface
4489 .media_rows
4490 .get(&destination)
4491 .copied()
4492 .unwrap_or(1)
4493 .max(1);
4494 // End past the image so the caret has a stop after it: the last glyph's
4495 // offset is the image *start*, not its extent, so `push_row`'s
4496 // last-glyph rule would strand the end stop inside the markup.
4497 self.push_row_at(glyphs, end);
4498 if let Some(row) = self.rows.last_mut() {
4499 row.media = Some(MediaMark {
4500 kind,
4501 destination,
4502 sources,
4503 alt,
4504 poster,
4505 rows,
4506 });
4507 }
4508 // Reserve the picture's remaining height as blank `decoration` rows: drawn
4509 // (so the frontend has the vertical room to paint the raster over them),
4510 // but holding no caret and contributing no stops — vertical motion steps
4511 // over them and the caret's only homes stay the stop in front of the image
4512 // and the one just past it, both on the label row above. They anchor at the
4513 // image's end offset so a click on the picture's lower half lands after it,
4514 // the nearest caret home. Mirrors how a table's box-rule rows reserve space
4515 // without ever holding the caret.
4516 for _ in 1..rows {
4517 self.rows.push(VRow {
4518 glyphs: Vec::new(),
4519 end_src: end,
4520 decoration: true,
4521 code: false,
4522 code_lang: None,
4523 directive: false,
4524 directive_label: None,
4525 media: None,
4526 task: None,
4527 leaf_directive: None,
4528 heading: None,
4529 align: None,
4530 line_height: None,
4531 boundary: None,
4532 mark_ends: Vec::new(),
4533 math: Vec::new(),
4534 });
4535 }
4536 self.last_off = end;
4537 }
4538
4539 /// The `<picture>` alternatives inside block-image `wrapper`, in document
4540 /// order — every `<source>` element in its subtree. Empty when there's no
4541 /// `<picture>`. Each is a `<source>`'s `media` + `srcset`; core keeps them
4542 /// verbatim and picks none (see [`MediaSource`]). A `<source>` with no
4543 /// `srcset` is dropped (nothing to load); its `media` may be empty (an
4544 /// unconditional override), which a frontend treats as always-matching.
4545 ///
4546 /// It scans the wrapper's whole subtree (via the forward `first_child` /
4547 /// `next_sibling` links, the reliable ones) rather than the `<img>`'s parent,
4548 /// for two reasons. A `<picture>` reaches core in two shapes: twig promotes a
4549 /// block `<picture>` to an `element(picture)` wrapping `[source, img]`, but
4550 /// leaves an inline one's tags as raw siblings — `[raw "<picture>", source,
4551 /// img, raw "</picture>"]` — so the `<source>`s sit at different depths in
4552 /// the two. And the editor's flat arena leaves a promoted inline node's
4553 /// `parent` back-pointer dangling on a phantom root, so only the wrapper
4554 /// (known at the call site) is a trustworthy anchor. A block image is the
4555 /// sole visible content of its wrapper, so every `<source>` under it is its
4556 /// picture's.
4557 fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
4558 let mut out = Vec::new();
4559 self.collect_sources(wrapper, &mut out);
4560 out
4561 }
4562
4563 fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
4564 for c in self.children(id) {
4565 let node = &self.nodes[c];
4566 if node.name.as_deref() == Some("source") {
4567 // `<picture>` spells its candidate `srcset`, `<video>`/`<audio>`
4568 // spell it `src`. Both mean "the URL to load", so they normalise
4569 // onto one field; `srcset` wins where (illegally) both appear.
4570 let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
4571 if let Some(srcset) = url {
4572 out.push(MediaSource {
4573 media: attr_of(node, "media").unwrap_or_default(),
4574 srcset,
4575 mime: attr_of(node, "type").unwrap_or_default(),
4576 });
4577 }
4578 }
4579 self.collect_sources(c, out);
4580 }
4581 }
4582
4583 /// The single block-level media `id`'s subtree resolves to, or `None`.
4584 ///
4585 /// A wrapper is a block picture when the only *visible* thing under it is one
4586 /// image: whitespace-only text and structure-only elements (a `<picture>`'s
4587 /// `<source>`, which declares an alternate but paints nothing) don't count,
4588 /// and the search descends through wrapping elements (`<picture>`, a linking
4589 /// `<a>`). This is what makes `<p><img></p>`, a bare `<img>`, and
4590 /// `<h1><picture>…<img></picture></h1>` all render as one framed picture.
4591 /// Any real text, or a second image, means it isn't image-only — it falls
4592 /// back to inline rendering, where the image still shows as its alt text.
4593 ///
4594 /// [`FlatNode`]'s snapshot doesn't carry an element's tag name, so a
4595 /// `<source>` can't be skipped by name — but it needs no special case:
4596 /// contributing no image and no text, it's simply invisible to the scan.
4597 fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
4598 let mut found = None;
4599 let mut count = 0usize;
4600 let mut has_text = false;
4601 self.scan_visual(id, &mut found, &mut count, &mut has_text);
4602 (count == 1 && !has_text).then(|| found.unwrap())
4603 }
4604
4605 /// Walk `id`'s subtree tallying visible leaves for [`media_only`]: each
4606 /// image, `<video>`, or `<audio>` (remembering the last, counting the total)
4607 /// and whether any non-whitespace text appears. Media isn't descended into —
4608 /// an image's inline children are alt text, and a `<video>`'s are its
4609 /// no-support fallback and its `<source>` declarations, none of which is
4610 /// document content.
4611 ///
4612 /// [`media_only`]: Self::media_only
4613 fn scan_visual(
4614 &self,
4615 id: usize,
4616 found: &mut Option<(usize, MediaKind)>,
4617 count: &mut usize,
4618 has_text: &mut bool,
4619 ) {
4620 for c in self.children(id) {
4621 let node = &self.nodes[c];
4622 match node.kind.as_str() {
4623 "image" => {
4624 *found = Some((c, MediaKind::Image));
4625 *count += 1;
4626 }
4627 // A `<video>`/`<audio>` reaches core as a generic `container`
4628 // (twig gives neither a semantic node, so `html_elements`
4629 // promotion leaves the tag name on `name`). Counted as media and
4630 // *not* descended into, so its `<source>` children and its
4631 // "your browser does not support…" fallback text neither add a
4632 // second count nor make the block look like text.
4633 "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
4634 let kind = match element_tag(node) {
4635 Some("audio") => MediaKind::Audio,
4636 _ => MediaKind::Video,
4637 };
4638 *found = Some((c, kind));
4639 *count += 1;
4640 }
4641 // Text leaves: only non-whitespace counts as visible content.
4642 // (Twig keeps the whitespace `str`s between HTML tags — the
4643 // newlines and indentation inside a `<picture>` — as real nodes.)
4644 "str" | "smart_punctuation" | "verbatim" | "inline_math" | "display_math" => {
4645 if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
4646 *has_text = true;
4647 }
4648 }
4649 // Structural breaks carry no visible glyph of their own.
4650 "soft_break" | "hard_break" | "non_breaking_space" => {}
4651 // Any other wrapper (emphasis, a link, a `<picture>`) is
4652 // transparent to the scan — descend into it.
4653 _ => self.scan_visual(c, found, count, has_text),
4654 }
4655 }
4656 }
4657
4658 /// The single `display_math` that is all of `id`'s visible content, or
4659 /// `None` — [`media_only`](Self::media_only) for a formula. Whitespace-only
4660 /// text around it does not count (twig keeps the newlines either side of a
4661 /// `$$` on its own lines as `str`s); any other text, or a second formula,
4662 /// means the paragraph is prose with math in it, and falls through to the
4663 /// inline path.
4664 fn math_only(&self, id: usize) -> Option<usize> {
4665 let mut found = None;
4666 let mut count = 0usize;
4667 for c in self.children(id) {
4668 let node = &self.nodes[c];
4669 match node.kind.as_str() {
4670 "display_math" => {
4671 found = Some(c);
4672 count += 1;
4673 }
4674 "str" | "smart_punctuation" => {
4675 if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
4676 return None;
4677 }
4678 }
4679 "soft_break" | "hard_break" | "non_breaking_space" => {}
4680 _ => return None,
4681 }
4682 }
4683 (count == 1).then(|| found.unwrap())
4684 }
4685
4686 /// A display formula on lines of its own, drawn on
4687 /// [`block_media`](Self::block_media)'s recipe: one placeholder row —
4688 /// `∑` and the TeX on one line, every glyph [`Role::Math`] at the
4689 /// formula's start and a caret stop there, the row ending past the
4690 /// formula so the caret can rest after it too — carrying a [`MathMark`]
4691 /// for [`math_spans`] to publish, and under it as many blank decoration
4692 /// rows as the frontend said the picture is tall
4693 /// ([`Surface::math_rows`]).
4694 ///
4695 /// On the caret's line the block is its source instead: the `$$`
4696 /// delimiters in [`Role::Delimiter`] and the TeX between them in
4697 /// [`Role::Code`], line for line, the way a fence draws — so a formula is
4698 /// edited where it stands and folds back to its picture when the caret
4699 /// leaves. That is the same rule an inline formula follows, and it is
4700 /// what makes a block-level formula need no equivalent of
4701 /// [`VisualMap::block_media_stop`]: both of the placeholder's caret homes
4702 /// are on the formula's own lines, and standing on either reveals it.
4703 fn block_math(&mut self, math: usize, pf: &[Glyph], pc: &[Glyph]) {
4704 self.saw_math.set(true);
4705 let node = &self.nodes[math];
4706 let (start, end) = (node.span.start, node.span.end);
4707 let tex = node.text.clone().unwrap_or_default();
4708 if self.math_revealed(&node.span) {
4709 let mut glyphs = Vec::new();
4710 self.inline_verbatim(math, Style::default(), &mut glyphs, true);
4711 self.emit_wrapped(glyphs, start, pf, pc);
4712 self.last_off = end;
4713 return;
4714 }
4715 let style = Style::default().role(Role::Math);
4716 let mut glyphs = pf.to_vec();
4717 // One line of label: the TeX with its newlines folded, so the row
4718 // reads as one thing however the source laid it out.
4719 let label: String = tex.split_whitespace().collect::<Vec<_>>().join(" ");
4720 for ch in format!("{MATH_ATOM} {label}").chars() {
4721 glyphs.push(Glyph {
4722 ch,
4723 style,
4724 src: start,
4725 stop: true,
4726 });
4727 }
4728 let rows = self
4729 .surface
4730 .math_rows
4731 .get(&tex)
4732 .copied()
4733 .unwrap_or(1)
4734 .max(1);
4735 self.push_row_at(glyphs, end);
4736 if let Some(row) = self.rows.last_mut() {
4737 row.math = vec![MathMark {
4738 tex,
4739 display: true,
4740 glyph: None,
4741 rows,
4742 }];
4743 }
4744 for _ in 1..rows {
4745 self.rows.push(VRow {
4746 glyphs: Vec::new(),
4747 end_src: end,
4748 decoration: true,
4749 code: false,
4750 code_lang: None,
4751 directive: false,
4752 directive_label: None,
4753 media: None,
4754 task: None,
4755 leaf_directive: None,
4756 heading: None,
4757 align: None,
4758 line_height: None,
4759 boundary: None,
4760 mark_ends: Vec::new(),
4761 math: Vec::new(),
4762 });
4763 }
4764 self.last_off = end;
4765 }
4766
4767 /// A leaf directive (`::name{…}`) as one placeholder row — the
4768 /// [`block_media`](Self::block_media) recipe, for the same reason: it is a
4769 /// block that renders as *a thing*, not as text, and the frontend paints
4770 /// whatever the host app's vocabulary makes of it.
4771 ///
4772 /// The row's glyphs are a `⧉ label` (or `⧉ name`) stand-in a plain surface
4773 /// paints as-is, every glyph anchored at the directive's start with a caret
4774 /// stop there, and the row ending past it so the caret can also rest after
4775 /// it. It carries a [`DirectiveMark`] for [`directive_spans`], and is marked
4776 /// [`directive`](VRow::directive) so a frontend already drawing the
4777 /// container form's panel frames this one identically for free.
4778 ///
4779 /// Before this, a leaf directive emitted no rows at all: it was invisible,
4780 /// held no caret, and vertical motion crossed a void where it stood.
4781 fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
4782 let node = &self.nodes[id];
4783 let (start, end) = (node.span.start, node.span.end);
4784 let (name, attrs) = leaf_directive_identity(node, self.source);
4785 let label = self.image_alt(id); // its `[label]` children, flattened
4786 let shown = if label.is_empty() { &name } else { &label };
4787 // How many rows the frontend drew the directive in: the label row plus
4788 // the blank fillers below it. Absent means the bare placeholder.
4789 let rows = self
4790 .surface
4791 .directive_rows
4792 .get(&DirectiveKey::new(&name, &label, &attrs))
4793 .copied()
4794 .unwrap_or(1)
4795 .max(1);
4796 let style = Style::default().role(Role::Image);
4797 let mut glyphs = pf.to_vec();
4798 for ch in format!("⧉ {shown}").chars() {
4799 glyphs.push(Glyph {
4800 ch,
4801 style,
4802 src: start,
4803 stop: true,
4804 });
4805 }
4806 // End past the directive so the caret has a stop after it — the same
4807 // reason `block_media` anchors its row at the image's end.
4808 self.push_row_at(glyphs, end);
4809 if let Some(row) = self.rows.last_mut() {
4810 row.directive = true;
4811 row.leaf_directive = Some(DirectiveMark {
4812 name,
4813 attrs,
4814 label,
4815 rows,
4816 });
4817 }
4818 // The rest of the host's drawing as blank `decoration` rows, holding no
4819 // caret — `block_media`'s fillers, anchored at the directive's end for
4820 // the same reason: a click on the drawing's lower part lands after it.
4821 for _ in 1..rows {
4822 self.rows.push(VRow {
4823 glyphs: Vec::new(),
4824 end_src: end,
4825 decoration: true,
4826 code: false,
4827 code_lang: None,
4828 directive: false,
4829 directive_label: None,
4830 media: None,
4831 task: None,
4832 leaf_directive: None,
4833 heading: None,
4834 align: None,
4835 line_height: None,
4836 boundary: None,
4837 mark_ends: Vec::new(),
4838 math: Vec::new(),
4839 });
4840 }
4841 self.last_off = end;
4842 }
4843
4844 /// An image's alt text: the flattened text of its inline descendants (an
4845 /// image's children *are* its alt content), empty when it has none. Also a
4846 /// leaf directive's `[label]`, which is the same shape — inline children
4847 /// standing for the block.
4848 fn image_alt(&self, id: usize) -> String {
4849 let mut out = String::new();
4850 self.collect_text(id, &mut out);
4851 out
4852 }
4853
4854 /// Append every descendant's `text` to `out`, in document order. Inline text
4855 /// (`str`) nodes are leaves, so a node never contributes both its own text and
4856 /// a child's — no double counting.
4857 fn collect_text(&self, id: usize, out: &mut String) {
4858 for c in self.children(id) {
4859 if let Some(t) = &self.nodes[c].text {
4860 out.push_str(t);
4861 }
4862 self.collect_text(c, out);
4863 }
4864 }
4865
4866 fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
4867 let mut out = Vec::new();
4868 for c in self.children(id) {
4869 self.inline(c, base, &mut out);
4870 }
4871 out
4872 }
4873
4874 /// [`inline_children`](Self::inline_children) plus any trailing whitespace the
4875 /// block carries past its inline content (see [`trailing_ws_glyphs`]). Used
4876 /// for the leaf inline blocks — paragraphs and headings — whose own `span`
4877 /// bounds exactly one line of text, so the trailing gap is theirs. *Not* for
4878 /// a table cell, whose `span` is the whole row and would swallow the
4879 /// delimiters and neighbours between it and the row's end.
4880 ///
4881 /// [`trailing_ws_glyphs`]: Self::trailing_ws_glyphs
4882 fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
4883 let mut out = self.inline_children(id, base);
4884 out.extend(self.trailing_ws_glyphs(id, base));
4885 out
4886 }
4887
4888 /// Glyphs for whatever trailing whitespace a block's source carries past its
4889 /// last inline node — the space(s) at the end of `hello ` that Markdown and
4890 /// Djot drop from the `str` node as insignificant. twig still records them:
4891 /// a block's `content_span` ends at its last meaningful character while its
4892 /// `span` runs to the end of the line's text (before the terminating
4893 /// newline), so the gap between the two *is* that trailing whitespace.
4894 ///
4895 /// Emitting it as real caret-stop glyphs is what lets the caret be drawn
4896 /// past the last visible character. Without it, typing a space at the end of
4897 /// a paragraph moved the caret in the source but not on screen — the caret
4898 /// stuck on the last glyph until the next visible character reparsed the
4899 /// space into an interior `str` node that finally carried it.
4900 ///
4901 /// Restricted to spaces: only they are safe to synthesize one-cell-per-byte,
4902 /// and only they are what the parser silently strips. Anything else in the
4903 /// gap means the span accounting isn't what this assumes, so it's left alone.
4904 fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
4905 let node = &self.nodes[id];
4906 let Some(content) = &node.content_span else {
4907 return Vec::new();
4908 };
4909 let (from, to) = (content.end, node.span.end);
4910 let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
4911 return Vec::new();
4912 };
4913 if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
4914 return Vec::new();
4915 }
4916 slice
4917 .bytes()
4918 .enumerate()
4919 .map(|(i, _)| Glyph {
4920 ch: ' ',
4921 style,
4922 src: from + i,
4923 stop: true,
4924 })
4925 .collect()
4926 }
4927
4928 fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
4929 let node = &self.nodes[id];
4930 match node.kind.as_str() {
4931 "str" | "smart_punctuation" => push_escaped_text(
4932 out,
4933 node.text.as_deref().unwrap_or(""),
4934 node.span.clone(),
4935 self.source,
4936 base,
4937 ),
4938 "soft_break" | "hard_break" | "non_breaking_space" => {
4939 // A break renders as a real, caret-navigable glyph — but twig
4940 // gives it no span of its own (`0..0`), so the offset comes from
4941 // the text in front of it: one *past* the last glyph, which is
4942 // the newline the break stands for. Past, not on: sharing the
4943 // previous glyph's offset would put two stops on one byte, and a
4944 // caret that can't change offset can't move.
4945 let src = if node.span.start != 0 {
4946 node.span.start
4947 } else {
4948 out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
4949 };
4950 // A *hard* break renders as this run's break glyph — a newline
4951 // inside a table cell (its own line), the same space in prose the
4952 // frontend re-wraps. A soft break normally folds into a space;
4953 // under `LineFlow::Preserve` it renders as a `'\n'` too, so the
4954 // author's line break shows where it was written. Never inside a
4955 // cell (`break_glyph` is `'\n'` there): a cell is one line and
4956 // folds its own soft breaks regardless.
4957 let ch = if node.kind == Kind::HardBreak {
4958 self.break_glyph.get()
4959 } else if node.kind == Kind::SoftBreak
4960 && self.preserve_soft
4961 && self.break_glyph.get() == ' '
4962 {
4963 '\n'
4964 } else {
4965 ' '
4966 };
4967 out.push(Glyph {
4968 ch,
4969 style: base,
4970 src,
4971 stop: true,
4972 });
4973 }
4974 // A cell's only spelling for an in-line break is a raw `<br>`; read it
4975 // back as one (outside a cell it stays the literal text it falls to
4976 // below). The tag's bytes carry no stop of their own — the line it
4977 // ends stops just before it, the next just after.
4978 "raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
4979 out.push(Glyph {
4980 ch: '\n',
4981 style: base,
4982 src: node.span.start,
4983 stop: true,
4984 });
4985 }
4986 "emph" => self.inline_delimited(id, base.italic(), out),
4987 "strong" => self.inline_delimited(id, base.bold(), out),
4988 // A coloured highlight's emoji is spelling, not content: twig strips
4989 // it and records the colour on the node, so the glyphs are the
4990 // author's words and the colour rides the role. Revealed markup
4991 // still shows the emoji, because `delims` reads the source bytes
4992 // between the span and the content span — which is exactly the
4993 // `==🔴 ` the author typed.
4994 "mark" => {
4995 let color = MarkColor::from_attrs(&node.attrs);
4996 self.inline_delimited(id, base.role(Role::Mark(color)), out)
4997 }
4998 "insert" => self.inline_delimited(id, base.underline(), out),
4999 "delete" => self.inline_delimited(id, base.strikethrough(), out),
5000 // The one pair whose whole meaning is *where the glyphs sit*. Drawn
5001 // in the surrounding style otherwise, so `^**2**^` stays bold and a
5002 // superscript inside a heading keeps the heading's role — which is
5003 // exactly why this is a `Baseline` and not a `Role`.
5004 "superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
5005 "subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
5006 "verbatim" => self.inline_verbatim(id, base, out, self.revealed(&node.span)),
5007 // A formula in a line. Three renderings, in order of preference:
5008 //
5009 // - On the caret's line it is its TeX in the code style with its
5010 // delimiters shown — in *every* markup mode, because the
5011 // content is not the picture and hiding the `$` alone would
5012 // leave nothing to edit. `math_revealed` is `revealed` without
5013 // the mode gate.
5014 // - On a surface that paints pictures in a line it is one atom
5015 // glyph, `stop: true` at the formula's start, standing for the
5016 // whole thing; the [`MathMark`] drained onto the row by
5017 // [`take_math`](Self::take_math) says what the frontend draws
5018 // there. The caret has the stop on the atom and the next glyph's
5019 // past it, and nothing inside the markup.
5020 // - Elsewhere — a terminal — it is the code-styled TeX with the
5021 // delimiters hidden, exactly the verbatim treatment, and what
5022 // `inline_math` rendered as before there was anything else.
5023 // `display_math` had no arm at all and fell to the default one,
5024 // which pushed its text at the *node's* start, three bytes short
5025 // of where the text sits.
5026 "inline_math" | "display_math" => {
5027 self.saw_math.set(true);
5028 if self.math_revealed(&node.span) {
5029 self.inline_verbatim(id, base, out, true);
5030 } else if self.surface.inline_pictures {
5031 let tex = node.text.clone().unwrap_or_default();
5032 self.pending_math.borrow_mut().push((
5033 node.span.start,
5034 MathMark {
5035 tex,
5036 display: node.kind == Kind::DisplayMath,
5037 glyph: None,
5038 rows: 1,
5039 },
5040 ));
5041 out.push(Glyph {
5042 ch: MATH_ATOM,
5043 style: base.role(Role::Math),
5044 src: node.span.start,
5045 stop: true,
5046 });
5047 } else {
5048 self.inline_verbatim(id, base, out, false);
5049 }
5050 }
5051 // An attributed span — the run-level half of the presentation
5052 // vocabulary. djot's `[text]{…}`, AsciiDoc's `[.a]#text#`, HTML's
5053 // and Markdown's `<span …>`: one node with a name twig hands back
5054 // for two of the four (see [`is_run_span`]), all four carrying the
5055 // author's `data-size`, `data-font` and `data-color` on the run
5056 // they cover.
5057 //
5058 // The keys are written over the surrounding style rather than
5059 // replacing it, so a span inside a block that names its own size
5060 // wins on size and keeps the block's face — the nearest-wins rule
5061 // the block walker applies through a `div`. A key the span does not
5062 // name is one the block still says.
5063 //
5064 // A `data-color` here is the text's *foreground*, where the same key
5065 // on a `mark` is a highlight's background: same vocabulary, same
5066 // enum, and no collision, because a `mark` is a `mark` and a span is
5067 // a span.
5068 //
5069 // Otherwise this is the plain `recurse` an anonymous container has
5070 // always had — no delimiters, because the `{…}` is markup and the
5071 // span's text is the author's words.
5072 "container" if is_run_span(node) && !self.children(id).is_empty() => {
5073 self.recurse(id, run_style(node, base, &self.faces), out)
5074 }
5075 // A text directive (`:name[label]{…}`) — the inline form of a generic
5076 // directive. Its `[label]` children are the visible text; the name and
5077 // the `{…}` attributes are the host app's vocabulary (diaryx's
5078 // `:vis[…]`) and stay hidden markup, exactly as a link's `](dest)` is.
5079 // Drawn in the surrounding style: a role of its own would need one
5080 // every frontend maps, and the bug this fixes is that the text was
5081 // invisible, not that it was unstyled.
5082 "container" if container_is_directive(node) && !self.children(id).is_empty() => {
5083 self.recurse(id, base, out)
5084 }
5085 // No `[label]`, so there are no children to render and recursing
5086 // emitted *nothing*: the directive's bytes vanished from the document
5087 // and left no caret stop behind. What to draw instead turns on
5088 // whether the syntax looks deliberate.
5089 //
5090 // Bare `:word` almost never is. twig matches a colon followed by any
5091 // letter-led word (`scanTextDirective`, deliberately matching remark),
5092 // so ordinary prose is full of them — `:see below`, a `:smile:`
5093 // shortcode, a stray colon before a word. Those are prose, and prose
5094 // renders as itself: every byte visible, every byte a caret stop, so a
5095 // colon typed by accident can be seen and deleted. Hiding them behind
5096 // a placeholder would be the invisible-and-unreachable failure this
5097 // arm exists to fix, just wearing a nicer glyph.
5098 "container" if container_is_directive(node) && node.attrs.is_empty() => {
5099 let span = node.span.clone();
5100 push_text(
5101 out,
5102 self.source.get(span.clone()).unwrap_or(""),
5103 span.start,
5104 base,
5105 );
5106 }
5107 // `{…}` attributes, though, are unmistakably deliberate — nobody
5108 // types `:vis{.family}` by accident, and diaryx writes exactly that
5109 // inline. So an attribute-bearing directive with no label draws as a
5110 // chip on `block_directive`'s recipe (`⧉ name attrs`, `Role::Image`),
5111 // the inline peer of the leaf form's placeholder row.
5112 //
5113 // Only the first glyph is a caret stop, and the whole chip shares the
5114 // directive's start offset: the caret treats it as one atomic thing
5115 // rather than walking hidden markup a byte at a time, and a paragraph
5116 // holding nothing but a chip still has a stop to be navigated to.
5117 "container" if container_is_directive(node) => {
5118 let start = node.span.start;
5119 let name = node.name.clone().unwrap_or_default();
5120 let shown = match directive_attr_label(&node.attrs) {
5121 Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
5122 Some(attrs) => format!("⧉ {attrs}"),
5123 None => format!("⧉ {name}"),
5124 };
5125 let style = base.role(Role::Image);
5126 for (i, ch) in shown.chars().enumerate() {
5127 out.push(Glyph {
5128 ch,
5129 style,
5130 src: start,
5131 stop: i == 0,
5132 });
5133 }
5134 }
5135 // A footnote reference (`[^1]`). The label bracketed is what a reader
5136 // needs — bare, `note1` reads as a typo rather than a reference — so
5137 // the `^` is hidden as the spelling artefact it is (a link's
5138 // `](dest)` goes the same way) and the brackets are kept as
5139 // decoration: one shared offset, never a caret stop, like a table's
5140 // borders, so the caret walks the label alone.
5141 //
5142 // Styled `Role::Link`: a reference *is* a link to its definition, and
5143 // every frontend already paints that role. A role of its own would
5144 // need one in each of them, and what a frontend needs to tell the two
5145 // apart is not a paint colour but an answer to "what does clicking
5146 // here do" — which is [`Doc::footnote_at_caret`]'s job, not a glyph's.
5147 //
5148 // Raised, though, because that a reference is *set* differently from
5149 // the prose it interrupts is exactly what makes it read as a
5150 // reference. `[1]` at body size reads as bracketed text.
5151 "footnote_reference" => {
5152 let style = base.role(Role::Link);
5153 // Revealed, the reference is just its source bytes: the `^` that
5154 // is normally elided comes back and every byte becomes a real
5155 // stop, so the brackets stop being decoration and start being
5156 // text. That's the whole point of the mode, and it replaces the
5157 // hand-built chip below rather than decorating it — including the
5158 // raised baseline, since what's on screen there is source, and
5159 // source is set as prose.
5160 if self.revealed(&node.span) {
5161 self.push_delim(out, &node.span, style);
5162 return;
5163 }
5164 let style = style.baseline(Baseline::Super);
5165 // The label's own span, so its glyphs map to their true bytes.
5166 // Absent one, it starts past the `[^` that opens the reference.
5167 let (label, at) = match &node.content_span {
5168 Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
5169 None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
5170 };
5171 out.push(Glyph {
5172 ch: '[',
5173 style,
5174 src: node.span.start,
5175 stop: false,
5176 });
5177 push_text(out, label, at, style);
5178 out.push(Glyph {
5179 ch: ']',
5180 style,
5181 src: node.span.end.saturating_sub(1),
5182 stop: false,
5183 });
5184 }
5185 "link" | "url" | "email" => {
5186 let style = base.role(Role::Link);
5187 if self.children(id).is_empty() {
5188 // A bare autolink (`<a@b.c>`, a naked URL): the destination
5189 // *is* the visible text, so there is nothing elided to
5190 // reveal and both modes draw the same thing.
5191 push_text(
5192 out,
5193 node.destination
5194 .as_deref()
5195 .or(node.text.as_deref())
5196 .unwrap_or("link"),
5197 node.span.start,
5198 style,
5199 );
5200 } else {
5201 // An inline link reveals asymmetrically — `[` before the
5202 // label, `](dest)` after it — which the generic
5203 // span-minus-content derivation already produces.
5204 self.inline_delimited(id, style, out);
5205 }
5206 }
5207 _ => {
5208 if self.children(id).is_empty() {
5209 if let Some(t) = &node.text {
5210 push_text(out, t, node.span.start, base);
5211 }
5212 } else {
5213 self.recurse(id, base, out);
5214 }
5215 }
5216 }
5217 }
5218
5219 fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
5220 for c in self.children(id) {
5221 self.inline(c, style, out);
5222 }
5223 }
5224
5225 /// Lay a block's inline `glyphs` into visual rows, prefixing the first with
5226 /// `pf` and the rest with `pc`. A preserved soft break arrives as a `'\n'`
5227 /// glyph (see the `soft_break` arm): a hard row boundary that splits the
5228 /// glyphs so each run lays out on its own and the author's line structure
5229 /// shows on screen. The `'\n'` is dropped from the row it closes and its
5230 /// source offset becomes that row's end stop — exactly how a table cell's
5231 /// in-line `<br>` is handled — so the caret can rest at the line's end
5232 /// without a zero-width control char leaking into what the frontends render.
5233 /// With no `'\n'` present (the folding default, and every build that isn't
5234 /// `LineFlow::Preserve`) there is one run and this is byte-identical to
5235 /// laying the glyphs out directly.
5236 fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
5237 if !glyphs.iter().any(|g| g.ch == '\n') {
5238 self.emit_line(glyphs, block_start, pf, pc, None);
5239 return;
5240 }
5241 // Each run up to a '\n' is a line of its own: the first wears the block's
5242 // opening prefix, every later one the continuation prefix, and the break's
5243 // own offset ends the run's last row. The break glyph is dropped. A
5244 // trailing '\n' flushes its run and leaves nothing behind, so no spurious
5245 // blank row follows it.
5246 let mut run: Vec<Glyph> = Vec::new();
5247 let mut first = true;
5248 for g in glyphs {
5249 if g.ch == '\n' {
5250 let lead = if first { pf } else { pc };
5251 self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
5252 first = false;
5253 } else {
5254 run.push(g);
5255 }
5256 }
5257 if !run.is_empty() {
5258 let lead = if first { pf } else { pc };
5259 self.emit_line(run, block_start, lead, pc, None);
5260 }
5261 }
5262
5263 /// Word-wrap a single line of `glyphs` (no interior line breaks) to the
5264 /// available width and push the visual rows, prefixing the first with `pf`
5265 /// and the rest with `pc`. `end`, when set, is the source offset that ends
5266 /// the line's final row — the offset of the break that terminated it, which
5267 /// the caller has already stripped from `glyphs`; when `None` the row ends
5268 /// just past its last glyph, as an unbroken block's does.
5269 fn emit_line(
5270 &mut self,
5271 glyphs: Vec<Glyph>,
5272 block_start: usize,
5273 pf: &[Glyph],
5274 pc: &[Glyph],
5275 end: Option<usize>,
5276 ) {
5277 // The line's final row ends at `end` when a break gave one, else just
5278 // past its last glyph (`push_row`'s default).
5279 let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
5280 Some(e) => b.push_row_at(row, e),
5281 None => b.push_row(row, block_start),
5282 };
5283
5284 // No column budget: emit the whole line as one row and let the frontend
5285 // wrap it at its own (pixel) width.
5286 let Some(width) = self.wrap else {
5287 let row = if glyphs.is_empty() {
5288 pf.to_vec()
5289 } else {
5290 concat(pf, &glyphs)
5291 };
5292 push_last(self, row);
5293 return;
5294 };
5295
5296 // Split into words (maximal non-space runs), each carrying the space
5297 // glyph that followed it (so its source offset is preserved).
5298 let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
5299 let mut word: Vec<Glyph> = Vec::new();
5300 for g in glyphs {
5301 if g.ch == ' ' {
5302 words.push((std::mem::take(&mut word), Some(g)));
5303 } else {
5304 word.push(g);
5305 }
5306 }
5307 if !word.is_empty() {
5308 words.push((word, None));
5309 }
5310 if words.is_empty() {
5311 // An empty block (or an empty preserved line) still occupies one
5312 // (prefixed) row.
5313 push_last(self, pf.to_vec());
5314 return;
5315 }
5316
5317 let mut line: Vec<Glyph> = Vec::new();
5318 let mut used = 0usize;
5319 let mut first = true;
5320 for (w, space) in words {
5321 let avail = width
5322 .saturating_sub(prefix_width(if first { pf } else { pc }))
5323 .max(1);
5324 let cells = glyphs_width(&w);
5325 if used > 0 && used + cells > avail {
5326 let row = concat(if first { pf } else { pc }, &line);
5327 self.push_row(row, block_start);
5328 line = Vec::new();
5329 used = 0;
5330 first = false;
5331 }
5332 used += cells;
5333 line.extend(w);
5334 if let Some(sp) = space {
5335 used += 1;
5336 line.push(sp);
5337 }
5338 }
5339 let row = concat(if first { pf } else { pc }, &line);
5340 push_last(self, row);
5341 }
5342
5343 /// The source offset of each line of a code block's `text`.
5344 ///
5345 /// `content` is the block's `content_span` — where twig says the body lives
5346 /// in the source, fences already excluded. Its lines run 1:1 with the
5347 /// rendered `text` lines, so no search is needed; each is anchored at the
5348 /// *end* of its source line, which places it past whatever indent `text` had
5349 /// stripped (a fenced block's fences, an indented one's leading spaces)
5350 /// without having to know how much there was.
5351 ///
5352 /// `None` when the body and the rendered lines don't line up — a coarse
5353 /// fallback the caller turns into the block's start offset.
5354 fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
5355 let mut src_lines: Vec<(usize, &str)> = Vec::new();
5356 let mut at = content.start;
5357 for l in self.source.get(content.start..content.end)?.split('\n') {
5358 src_lines.push((at, l));
5359 at += l.len() + 1;
5360 }
5361 if src_lines.len() != lines.len() {
5362 return None;
5363 }
5364 Some(
5365 lines
5366 .iter()
5367 .zip(&src_lines)
5368 .map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
5369 .collect(),
5370 )
5371 }
5372
5373 fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
5374 // Step past the character the *source* holds at the last glyph's offset,
5375 // not past the glyph's own `ch`. The two agree for ordinary text, but a
5376 // glyph is not always the character it stands on: `synth` decoration and
5377 // a substituted run (an image's `⧉ label`) share one offset by design.
5378 // Trusting `ch` there yields an offset inside a multi-byte character,
5379 // which every later slice of `source` panics on.
5380 let end_src = glyphs
5381 .last()
5382 .map(|g| {
5383 let at = g.src.min(self.source.len());
5384 at + self.source[at..].chars().next().map_or(0, char::len_utf8)
5385 })
5386 .unwrap_or(fallback);
5387 self.push_row_at(glyphs, end_src);
5388 }
5389
5390 /// Push a row with an explicit end stop, for content that knows its own
5391 /// extent better than its last glyph does.
5392 fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
5393 self.last_off = end_src;
5394 let mark_ends = self.take_mark_ends(end_src);
5395 let math = self.take_math(&glyphs);
5396 self.rows.push(VRow {
5397 glyphs,
5398 end_src,
5399 decoration: false,
5400 code: false,
5401 code_lang: None,
5402 directive: false,
5403 directive_label: None,
5404 media: None,
5405 task: None,
5406 leaf_directive: None,
5407 heading: None,
5408 align: None,
5409 line_height: None,
5410 boundary: None,
5411 mark_ends,
5412 math,
5413 });
5414 }
5415
5416 /// Where the last row's line ends — what the lines under it are counted
5417 /// from: the row's own end, or the walk's where that stands short of it.
5418 /// Only a thematic break's does, whose row ends at the caret's home past
5419 /// the newline under the rule while the walk stands at the rule itself
5420 /// (see its arm). Every other block leaves the walk at its last row's end
5421 /// or past it — past a closing fence, say, which the counts below reach
5422 /// by other means.
5423 fn last_line_end(&self) -> Option<usize> {
5424 self.rows.last().map(|r| r.end_src.min(self.last_off))
5425 }
5426
5427 /// The quote's own trailing marker lines: the `>` / `> ` lines that lie past
5428 /// its last child but inside its span, one gutter row each.
5429 ///
5430 /// Pressing Enter at the end of `> a` writes `> a\n>\n> \n` — twig's
5431 /// spelling, and the right one. Those last two lines hold no block (a
5432 /// `block_quote`'s `content_span` still stops at its last child) so the
5433 /// children walk never reaches them, and they used to fall all the way to
5434 /// the document-level [`Builder::emit_trailing_blank_lines`], which knows no
5435 /// prefix: the gutter simply stopped, and a writer adding a line to a quote
5436 /// watched it draw as plain prose.
5437 ///
5438 /// This is only answerable since twig 3.2.0, where a Markdown `block_quote`'s
5439 /// span covers its own trailing marker lines (it reported `0..3` for that
5440 /// source and now reports `0..8`). Before that the lines belonged to no node
5441 /// at any level, and the only way to draw them was to sniff `>` off the raw
5442 /// source and re-derive the nesting depth by counting markers — format
5443 /// inference this crate exists to keep out of the render path.
5444 ///
5445 /// Each row is a real caret home rather than a decoration gap: the writer
5446 /// spelled every one of these lines with a marker of its own, so each is a
5447 /// line of the quote to stand on, not the spacing between two blocks (which
5448 /// is [`Builder::emit_separators_before`]'s, and falls *between* children
5449 /// where this never looks).
5450 fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
5451 let end = end.min(self.source.len());
5452 // From where the walk stands, not where the last row ends: a fenced
5453 // code block's last row is its last line of code, and its closing
5454 // fence (`> ```) is markup the block already stepped `last_off` past.
5455 // Counted from the row, the fence's line drew as an empty quoted line.
5456 let mut at = self.last_line_end().unwrap_or(0).max(self.last_off);
5457 // Walk line by line from the last child's end to the quote's, taking each
5458 // line's *end* as the row's offset — the caret home at the end of a line
5459 // is where one on an empty quoted line belongs, and it keeps every row's
5460 // offset distinct from its neighbours'.
5461 while at < end {
5462 let Some(k) = self.source[at..end].find('\n') else {
5463 break;
5464 };
5465 let line_start = at + k + 1;
5466 let line_end = self.source[line_start..end]
5467 .find('\n')
5468 .map_or(end, |i| line_start + i);
5469 self.push_row_at(pc.to_vec(), line_end);
5470 at = line_end;
5471 }
5472 }
5473
5474 /// The source offset the caret rests at on the blank line separating a block
5475 /// that ends at `prev_end` from the next block starting at `next_start`:
5476 /// just past the newline that terminates the previous block, but kept
5477 /// strictly before the next block so the offset is unique to this row.
5478 fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
5479 let after_nl = self.source[prev_end..]
5480 .find('\n')
5481 .map_or(prev_end, |p| prev_end + p + 1);
5482 after_nl.min(next_start.saturating_sub(1)).max(prev_end)
5483 }
5484
5485 /// The source offset of each blank row between a block ending at `prev_end`
5486 /// and content starting at `next_start` — one per blank source line. The
5487 /// first newline terminates the previous block's line; every line it opens up
5488 /// to (but not including) the line that holds `next_start` is a blank row the
5489 /// caret can occupy. Offsets are unique and ascending so `pos_of_offset`
5490 /// resolves each to its own row. Empty when the two blocks are tight (no
5491 /// blank line between them).
5492 fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
5493 // Spans aren't always in tidy source order (e.g. a block after
5494 // frontmatter can start *before* the previous block's rendered content
5495 // ends). There's no blank line to place then — fall back to the clamped
5496 // single separator (an empty return) rather than slicing an inverted
5497 // range.
5498 if next_start <= prev_end {
5499 return Vec::new();
5500 }
5501 let gap = &self.source[prev_end..next_start];
5502 let Some(nl) = gap.find('\n') else {
5503 return Vec::new();
5504 };
5505 // The line holding `next_start` belongs to the next block; blank rows
5506 // stop before it.
5507 let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
5508 let mut offs = Vec::new();
5509 let mut start = prev_end + nl + 1;
5510 while start < next_line_start {
5511 offs.push(start);
5512 match self.source[start..next_start].find('\n') {
5513 Some(k) => start += k + 1,
5514 None => break,
5515 }
5516 }
5517 offs
5518 }
5519
5520 /// Blank lines the user typed past the end of the last block (e.g. two
5521 /// `Enter`s to open a fresh paragraph) leave no AST node, so nothing renders
5522 /// and the caret appears stuck on the old line. Reconstruct one empty row
5523 /// per extra trailing newline from the source, each at its own offset, so
5524 /// the caret rides down onto the new line the moment it's created.
5525 ///
5526 /// `above` is the class of the last block in the document — the one this gap
5527 /// closes. A document with no blocks at all has nothing above these rows, and
5528 /// [`BlockClass::Paragraph`] is the honest answer there too: what they are is
5529 /// empty paragraphs, on both sides of the gap.
5530 fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
5531 // With no rows at all the count starts past any hidden frontmatter, not
5532 // at 0: its newlines are not trailing blank lines, and counting them
5533 // opened phantom rows *inside* the metadata for a frontmatter-only file.
5534 //
5535 // Or past the last hidden block, if that is later: a closing comment
5536 // draws no row, and its lines are not blank lines the author opened.
5537 //
5538 // Or past the last byte of content, if *that* is later: a fenced code
5539 // block's last row ends at its last line of code, and the closing
5540 // fence under it is markup with no row of its own — so counted from
5541 // the row, the fence's own line and terminator read as two blank lines
5542 // and opened a phantom empty paragraph whose offset was *inside* the
5543 // fence. Typing on it broke the fence. (A setext heading's underline
5544 // was the same shape.) Trailing whitespace is not content, so a line
5545 // of spaces still counts as the blank line it looks like.
5546 let last_end = self
5547 .last_line_end()
5548 .unwrap_or(hidden_end)
5549 .max(self.stepped_over)
5550 .max(self.source.trim_end().len());
5551 if last_end >= self.source.len() {
5552 return;
5553 }
5554 // The first newline after the last content just terminates that line, so
5555 // a lone trailing `\n` (an ordinary file ending) opens no blank row. A
5556 // *second* newline opens an empty paragraph: render it the way a block
5557 // boundary is rendered — a blank spacer row, then the empty paragraph row
5558 // the caret rests on — so the just-pressed-Enter view already shows the
5559 // gap it will keep once text is typed, and typing doesn't shift the line
5560 // down. One row per trailing newline (each its own caret offset), the
5561 // last landing at the document end where the caret sits.
5562 let extra = self.source[last_end..].matches('\n').count();
5563 if extra < 2 {
5564 return;
5565 }
5566 for k in 1..=extra {
5567 self.rows.push(VRow {
5568 glyphs: Vec::new(),
5569 end_src: last_end + k,
5570 // As between two blocks: the first blank row is the gap that
5571 // closes the block above, not somewhere to type. Nothing follows
5572 // to need a gap of its own, though, so every row after it is a
5573 // real empty paragraph — the end of the document bounds the last
5574 // one the way a following block would. Preserve flow makes even
5575 // that first row navigable, as it does every blank line.
5576 decoration: !self.preserve_soft && k == 1,
5577 code: false,
5578 code_lang: None,
5579 directive: false,
5580 directive_label: None,
5581 media: None,
5582 task: None,
5583 leaf_directive: None,
5584 heading: None,
5585 align: None,
5586 line_height: None,
5587 // The one drawn row here is a block boundary like any other —
5588 // "rendered the way a block boundary is rendered" is the whole
5589 // point of it — so it says so, and a frontend spacing boundaries
5590 // spaces this one the same. The rows below it are navigable empty
5591 // paragraphs, not gaps.
5592 boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
5593 above,
5594 below: BlockClass::Paragraph,
5595 }),
5596 mark_ends: Vec::new(),
5597 math: Vec::new(),
5598 });
5599 }
5600 }
5601}
5602
5603// ── display width ────────────────────────────────────────────────────────────
5604//
5605// Two things a row can be counted in, and they are not the same number:
5606//
5607// *glyphs*, one per codepoint — how the text is stored here, and what an
5608// index into `VRow::glyphs` means; and
5609// *columns*, one per terminal cell — where the text is drawn, and what every
5610// `col` in this crate means.
5611//
5612// `你` is one glyph in two columns. Counting columns with `glyphs.len()` (or,
5613// in the source view, `chars().count()`) is the same number only for the ASCII
5614// that most fixtures are written in, and drifts one cell per wide character
5615// everywhere else — the caret drawn a column short of the text it types into.
5616// Everything below converts between the two; nothing else should have to.
5617
5618/// The display width of `s` in terminal cells.
5619///
5620/// Measured per grapheme cluster, because that is the unit a surface advances
5621/// by: `👨👩👧` is five codepoints measuring 2 + 0 + 2 + 0 + 2 cells one at a
5622/// time, but the character they spell is drawn in 2. Both frontends already
5623/// measure it that way — ratatui asks `unicode-width` per cluster, and the GUI
5624/// asks its own text system — so the caret only lands where the text is if this
5625/// agrees with them.
5626pub fn text_width(s: &str) -> usize {
5627 UnicodeWidthStr::width(s)
5628}
5629
5630/// One grapheme cluster of a laid-out row: the glyphs that spell it, and the
5631/// cells it is drawn in.
5632///
5633/// The cluster, not the glyph, is what has a width. A row's glyphs are one per
5634/// codepoint, so an accented letter or an emoji is several of them drawn in one
5635/// character's worth of cells — the glyph that opens the cluster claims those
5636/// cells, and the ones continuing it are drawn *inside* them rather than beside
5637/// them. It's the same cluster the stop table is built on: the opening glyph is
5638/// the one a caret can rest on, and so the only one whose column it can be
5639/// drawn at.
5640struct Cluster {
5641 /// Index of the glyph that opens it.
5642 glyph: usize,
5643 /// The display column it starts at.
5644 col: usize,
5645 /// How many cells it is drawn in. Zero for a cluster with no width of its
5646 /// own (a lone joiner), which therefore sits at no column at all.
5647 cells: usize,
5648}
5649
5650/// Walk a row's glyphs as the clusters they spell, in column order.
5651fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
5652 let text: String = glyphs.iter().map(|g| g.ch).collect();
5653 let mut out = Vec::new();
5654 let (mut glyph, mut col) = (0, 0);
5655 for cluster in text.graphemes(true) {
5656 let cells = text_width(cluster);
5657 out.push(Cluster { glyph, col, cells });
5658 // One glyph per codepoint, so a cluster spans exactly its own.
5659 glyph += cluster.chars().count();
5660 col += cells;
5661 }
5662 out
5663}
5664
5665/// The display width of a run of glyphs.
5666fn glyphs_width(glyphs: &[Glyph]) -> usize {
5667 clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
5668}
5669
5670/// A cell's display width — the widest of its lines, since an in-cell `\n` break
5671/// splits it into several. Sizes the column that must hold every line.
5672fn cell_width(glyphs: &[Glyph]) -> usize {
5673 glyphs
5674 .split(|g| g.ch == '\n')
5675 .map(glyphs_width)
5676 .max()
5677 .unwrap_or(0)
5678}
5679
5680/// Whether a raw inline HTML tag is a line break (`<br>`, `<br/>`, `<br />`,
5681/// case-insensitively) — the one tag a table cell reads as an in-cell break.
5682fn is_br(text: Option<&str>) -> bool {
5683 let Some(t) = text else { return false };
5684 matches!(
5685 t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
5686 "<br>" | "<br/>"
5687 )
5688}
5689
5690impl VRow {
5691 /// The row's width in display columns — and so the column of the caret
5692 /// placed past its last glyph, which is the rightmost column it can occupy.
5693 fn width(&self) -> usize {
5694 glyphs_width(&self.glyphs)
5695 }
5696
5697 /// The display column glyph `i` is drawn at. Glyphs continuing a cluster
5698 /// report the column of the glyph that opened it, since that is where they
5699 /// are drawn; none of them is ever a stop, so no caret is placed by it.
5700 fn col_of_glyph(&self, i: usize) -> usize {
5701 clusters(&self.glyphs)
5702 .iter()
5703 .rev()
5704 .find(|c| c.glyph <= i)
5705 .map_or(0, |c| c.col)
5706 }
5707
5708 /// The glyph drawn at display column `col`, or `None` past the row's last
5709 /// cell.
5710 ///
5711 /// A column landing on the *second* cell of a wide glyph resolves to that
5712 /// glyph: half a character is not a place to be, so clicking either cell of
5713 /// `你` means `你`, and the caret comes to rest at its start — the column it
5714 /// would be drawn at anyway. That rule is what makes the mapping invertible:
5715 /// every offset has one column, and every column has one offset.
5716 fn glyph_at_col(&self, col: usize) -> Option<usize> {
5717 clusters(&self.glyphs)
5718 .into_iter()
5719 .find(|c| col < c.col + c.cells)
5720 .map(|c| c.glyph)
5721 }
5722}
5723
5724// ── helpers ──────────────────────────────────────────────────────────────────
5725
5726/// The caret home inside an *empty* table cell (`col`, 0-based) whose node
5727/// `span` is `src` starting at byte `start`. twig gives an empty cell no
5728/// `content_span`, so its interior is read from the pipes: the home is one
5729/// space past the pipe that opens the cell — mimicking the `| ` padding a
5730/// filled cell has — and never at or past the pipe that closes it. So
5731/// `| | |` gives the two cells distinct, editable homes instead of both
5732/// collapsing onto the row's start.
5733///
5734/// Two shapes of span are read. A twig from 3.3.3 gave every cell of a row
5735/// the *row's* span, so the cell's own pipes are the `col`-th and
5736/// `col+1`-th unescaped `|` in it; a later twig spans a cell from the pipe
5737/// that opens it to the one that closes it, exclusive, so the span holds at
5738/// most that one pipe, at its start, and the closing one is the byte past
5739/// its end. The two are told apart by the pipes the span holds — a row's
5740/// span has several, or one that is not at its start.
5741fn empty_cell_offset(src: &str, start: usize, col: usize) -> usize {
5742 let bytes = src.as_bytes();
5743 let mut pipes = Vec::new();
5744 for (i, &b) in bytes.iter().enumerate() {
5745 if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
5746 pipes.push(i);
5747 }
5748 }
5749 let whole_row = pipes.len() > 1 || pipes.first().is_some_and(|&i| i != 0);
5750 let (open, close) = if whole_row {
5751 (pipes.get(col).copied(), pipes.get(col + 1).copied())
5752 } else {
5753 (pipes.first().copied(), Some(src.len()))
5754 };
5755 match (open, close) {
5756 (Some(open), Some(close)) => {
5757 let lo = open + 1; // just inside the opening pipe
5758 let hi = close.saturating_sub(1); // just inside the closing pipe
5759 let inside = if hi < lo {
5760 lo
5761 } else {
5762 (open + 2).clamp(lo, hi)
5763 };
5764 start + inside
5765 }
5766 (Some(open), None) => start + open + 1,
5767 _ => start,
5768 }
5769}
5770
5771/// One laid-out table cell: its rendered text, the source range that text
5772/// occupies (`start`/`end` are the caret anchors decoration points at), and the
5773/// column alignment its padding honours.
5774///
5775/// `glyphs` is the cell's inline content *unwrapped* — the box-drawn rows wrap
5776/// it to a column width, but a frontend laying the grid out itself needs the
5777/// text before that decision was made.
5778#[derive(Clone)]
5779pub struct TableCell {
5780 pub glyphs: Vec<Glyph>,
5781 pub start: usize,
5782 pub end: usize,
5783 pub align: Alignment,
5784}
5785
5786/// One row of a table's grid, as the document spells it — not as it's drawn.
5787#[derive(Clone)]
5788pub struct TableRow {
5789 /// A header row: drawn bold, and ruled off from the body below it.
5790 pub head: bool,
5791 pub cells: Vec<TableCell>,
5792}
5793
5794/// A table's structure, published alongside the box-drawn rows that spell it.
5795///
5796/// The rows in [`VisualMap::rows`] are the *default monospace* picture of a
5797/// table: every border a `│`, every column a whole number of character cells.
5798/// That picture is exactly right on any monospace surface, and unfixable off one
5799/// — in a proportional font the `│`s of two rows land at different x and the grid
5800/// shears. So a frontend that draws its own geometry reads this instead: the
5801/// cells, their alignment, and which rows are the head, with no opinion about
5802/// how wide a column is or what a border looks like.
5803///
5804/// Both are always built. The TUI paints `rows` and ignores this; the GUI skips
5805/// `rows` for the span in `rows_span` and draws from here. They describe the
5806/// same cells, so the caret lands on the same offsets either way.
5807#[derive(Clone)]
5808pub struct TableInfo {
5809 /// The `VisualMap::rows` this table's picture occupies, borders included —
5810 /// what a frontend drawing its own table skips over.
5811 pub rows_span: Range<usize>,
5812 /// The end of the table node's source span, and the offset its trailing
5813 /// caret stop sits at — the one caret home past the last cell, held by the
5814 /// bottom border row's end. Typing there opens a paragraph under the table
5815 /// rather than joining the block; see `Doc::open_paragraph_at_block_edge`.
5816 pub end_src: usize,
5817 /// The block prefix every row of this table carries — a blockquote's `│ `
5818 /// gutter, a list item's indent. Empty for a table at the top level.
5819 ///
5820 /// A frontend drawing its own grid has to render this and start the table
5821 /// past it, exactly as the picture does; a table nested in a quote that
5822 /// draws flush at the left margin has left the quote.
5823 pub prefix: Vec<Glyph>,
5824 pub grid: Vec<TableRow>,
5825}
5826
5827/// A fenced or indented code block, named by the [`VisualMap::rows`] it occupies.
5828///
5829/// Unlike a table, the rows *are* the block's content — a frontend still paints
5830/// them, it just draws a border and a tinted background around the whole span
5831/// and lets the code inside scroll horizontally instead of wrapping. So this
5832/// carries only the row range; there's no structural alternative to the picture
5833/// the way [`TableInfo`] is one. Derived from [`VRow::code`] — see
5834/// [`code_block_spans`].
5835#[derive(Clone, Debug, PartialEq, Eq)]
5836pub struct CodeBlockInfo {
5837 /// The contiguous run of [`VisualMap::rows`] this code block spans, blank
5838 /// code lines included.
5839 pub rows_span: Range<usize>,
5840 /// The block's language, from a fenced block's info string — what a frontend
5841 /// paints as a small label on the box (`` ```rust `` → `Some("rust")`).
5842 /// `None` for a fence written without one, or an indented block. Editing it
5843 /// goes through [`crate::Doc::set_code_language`], which re-finds the fence
5844 /// in the AST, so this stays a display string.
5845 pub lang: Option<String>,
5846}
5847
5848/// A block-level image (`` on its own line), named by the single
5849/// [`VisualMap::rows`] row it occupies.
5850///
5851/// Like [`CodeBlockInfo`], the row *is* the block's default rendering — a plain
5852/// surface paints the `🖼 alt` placeholder glyphs as-is. An image-capable
5853/// frontend instead **skips the row in `rows_span`** and paints the resolved
5854/// picture there, exactly as it skips a [`TableInfo`]'s box-drawn rows. Derived
5855/// from [`VRow::media`] by [`media_spans`], so it survives the row reuse of
5856/// [`BlockCache`] and [`build_spliced`].
5857#[derive(Clone, Debug, PartialEq, Eq)]
5858pub struct MediaInfo {
5859 /// The [`VisualMap::rows`] rows this media's placeholder occupies — what a
5860 /// capable frontend replaces with the picture or player.
5861 pub rows_span: Range<usize>,
5862 /// Whether this is a picture, a movie, or a sound — which widget the
5863 /// frontend builds over [`rows_span`](MediaInfo::rows_span). A frontend that
5864 /// handles only some kinds leaves the rest as core's placeholder rows, which
5865 /// already read sensibly on their own.
5866 pub kind: MediaKind,
5867 /// The media's link destination — a path, URL, or `data:` URI, verbatim from
5868 /// the AST. A frontend resolves a relative path against the document's own
5869 /// directory; core does no I/O. For a `<picture>` this is the `<img>`
5870 /// fallback — the source used when no [`sources`](MediaInfo::sources) media
5871 /// query matches (or the frontend has no theme). Empty when a `<video>`/
5872 /// `<audio>` carries no `src` and names its candidates in `<source>`s
5873 /// instead; [`resolve`](MediaInfo::resolve) already accounts for that.
5874 pub destination: String,
5875 /// The `<source>` alternatives in document order, or empty for a plain
5876 /// image. See [`MediaSource`]; a theme- or codec-aware frontend picks one and
5877 /// otherwise loads [`destination`](MediaInfo::destination).
5878 pub sources: Vec<MediaSource>,
5879 /// The media's alt text, flattened from its inline children (empty when it
5880 /// has none).
5881 pub alt: String,
5882 /// A `<video poster="…">`'s still frame, or empty when there is none — an
5883 /// image destination, resolved exactly as [`destination`] is.
5884 ///
5885 /// [`destination`]: MediaInfo::destination
5886 pub poster: String,
5887}
5888
5889/// One leaf directive (`::name{…}`) as a frontend sees it: which rows its
5890/// placeholder occupies, its type, and its attributes. A plain surface paints
5891/// the `⧉ name` placeholder glyphs as-is; a frontend that knows the host app's
5892/// vocabulary **skips the rows in `rows_span`** and paints the real thing there,
5893/// exactly as an image-capable one does with [`MediaInfo`]. Derived from
5894/// [`VRow::leaf_directive`] by [`directive_spans`].
5895///
5896/// Core resolves nothing here — it has no idea what an `embed` or a `toc` is,
5897/// and deliberately so: the directive vocabulary belongs to the app on top.
5898#[derive(Clone, Debug, PartialEq, Eq)]
5899pub struct DirectiveInfo {
5900 /// The [`VisualMap::rows`] rows this directive's placeholder occupies — the
5901 /// label row plus any blank fillers under it.
5902 pub rows_span: Range<usize>,
5903 /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
5904 pub name: String,
5905 /// Its `{…}` attributes in source order; a bare one has a `None` value.
5906 pub attrs: Vec<(String, Option<String>)>,
5907 /// Its `[label]` text, flattened from its inline children (empty when it has
5908 /// none) — what the placeholder row shows.
5909 pub label: String,
5910}
5911
5912/// One formula as a frontend sees it: where its picture goes, and the TeX to
5913/// typeset for it. Two shapes, told apart by [`glyph`](MathInfo::glyph):
5914///
5915/// - An **inline atom** — `$E = mc^2$` in a line of prose — is one
5916/// [`Role::Math`] glyph on `row` at index `glyph`, standing for the whole
5917/// formula. A frontend that paints pictures in a line typesets the TeX at
5918/// the run's font size and draws the picture in the glyph's place, as wide
5919/// as the picture is and with the text baseline through it at its height —
5920/// a run delegate on Apple, an inline element on the web. The glyph is a
5921/// caret stop at the formula's start; the stop after it is the next glyph's.
5922/// - A **display block** — a paragraph holding nothing but `$$…$$` — is the
5923/// placeholder row (`∑ tex`, every glyph at the formula's start) plus the
5924/// blank fillers `rows_span` reserves under it, exactly a [`MediaInfo`]'s
5925/// shape. A frontend skips those rows and paints the picture there, centred
5926/// on the measure.
5927///
5928/// Either way the frontend supplies the *width*: core lays out in glyphs and
5929/// cannot know how wide a typeset formula is, which is why an inline atom is
5930/// one glyph and not a run of them. Only in a **column-wrapped** build does
5931/// that matter to the wrap: a terminal never asks for atoms (see
5932/// [`Surface::inline_pictures`]), and the web re-fits a row the picture
5933/// overflows.
5934///
5935/// A plain surface paints the glyphs as they are — `∑` for an atom, the
5936/// labelled row for a block — and needs none of this. Derived from
5937/// [`VRow::math`] by [`math_spans`], so it survives the row reuse of
5938/// [`BlockCache`] and [`build_spliced`].
5939#[derive(Clone, Debug, PartialEq, Eq)]
5940pub struct MathInfo {
5941 /// The [`VisualMap::rows`] rows this formula occupies: `row..row + 1` for
5942 /// an atom, the placeholder row and its fillers for a block.
5943 pub rows_span: Range<usize>,
5944 /// The row the atom glyph, or the block's placeholder label, is on.
5945 pub row: usize,
5946 /// The atom's index into that row's glyphs, or `None` for a block.
5947 pub glyph: Option<usize>,
5948 /// The TeX between the delimiters, verbatim.
5949 pub tex: String,
5950 /// Display style rather than text style — see [`MathMark::display`].
5951 pub display: bool,
5952 /// The formula's source start: where a click on its picture lands the
5953 /// caret, and the same offset every placeholder glyph carries.
5954 pub src: usize,
5955}
5956
5957impl DirectiveInfo {
5958 /// The value of attribute `key`, if it has one with a value. The convenience
5959 /// a frontend reaches for first (`info.attr("src")`), since almost every
5960 /// directive that draws as something real is pointed at by one attribute.
5961 pub fn attr(&self, key: &str) -> Option<&str> {
5962 self.attrs
5963 .iter()
5964 .find(|(k, _)| k == key)
5965 .and_then(|(_, v)| v.as_deref())
5966 }
5967
5968 /// What [`crate::Doc::set_directive_rows`] knows this directive by — the
5969 /// key a terminal reports the height of its drawing under.
5970 pub fn key(&self) -> DirectiveKey {
5971 DirectiveKey::new(&self.name, &self.label, &self.attrs)
5972 }
5973}
5974
5975impl MediaInfo {
5976 /// The image URL to load under `scheme`: the first [`sources`] `<source>`
5977 /// whose media query matches, else the [`destination`] `<img>` fallback. The
5978 /// pick is a `<source>`'s first `srcset` URL or the destination — a frontend
5979 /// resolves whichever it gets against the document directory exactly as it
5980 /// resolves `destination`, and reserves/keys the picture under `destination`
5981 /// regardless, so a theme switch just re-picks without disturbing the layout.
5982 ///
5983 /// Only `prefers-color-scheme` is understood (that's what a light/dark banner
5984 /// uses); a `<source>` with any other media query is skipped, and one with no
5985 /// media at all always matches (an unconditional override). With no matching
5986 /// source — including every frontend that can't/doesn't theme and passes
5987 /// [`ColorScheme::Light`] to a dark-only picture — it's the plain `<img>`.
5988 ///
5989 /// [`sources`]: MediaInfo::sources
5990 /// [`destination`]: MediaInfo::destination
5991 pub fn resolve(&self, scheme: ColorScheme) -> &str {
5992 if let Some(url) = self
5993 .sources
5994 .iter()
5995 .find(|s| media_matches(&s.media, scheme))
5996 .and_then(|s| first_srcset_url(&s.srcset))
5997 {
5998 return url;
5999 }
6000 // A `<video>`/`<audio>` may carry no `src` of its own, naming its
6001 // candidates only in child `<source>`s — none of which matched above,
6002 // because a codec-typed `<source>` has no media query and core judges no
6003 // MIME types. Falling through to an empty destination would hand the
6004 // frontend nothing to load, so take the first candidate URL instead and
6005 // let the frontend reject it if it can't decode it. An `<img>` never
6006 // reaches this: its `src` is the picture.
6007 if self.destination.is_empty()
6008 && let Some(url) = self
6009 .sources
6010 .iter()
6011 .find_map(|s| first_srcset_url(&s.srcset))
6012 {
6013 return url;
6014 }
6015 &self.destination
6016 }
6017
6018 /// The **still picture** that stands for this media under `scheme`, for a
6019 /// frontend that can rasterize an image but not play a movie — a terminal, or
6020 /// a GUI still growing its player. `None` when there is no picture to draw,
6021 /// which is the honest answer for audio and for a poster-less video: the
6022 /// caller leaves core's labelled placeholder row, which already reads as
6023 /// *a thing that isn't text*.
6024 ///
6025 /// This exists so those frontends never hand a `.mp4` to an image decoder.
6026 /// That fails harmlessly today (a failed decode falls back to the same
6027 /// placeholder), but it spends a file read and a decode attempt per frame to
6028 /// arrive where this gets in one match.
6029 pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
6030 match self.kind {
6031 MediaKind::Image => Some(self.resolve(scheme)),
6032 // A `poster` is an image destination, so it resolves the same way —
6033 // but it is named directly and has no `<source>` alternatives of its
6034 // own, so it needs no theme matching.
6035 MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
6036 MediaKind::Video | MediaKind::Audio => None,
6037 }
6038 }
6039}
6040
6041/// A frontend's active color scheme — what a `<picture>`'s `prefers-color-scheme`
6042/// `<source>`s are matched against by [`MediaInfo::resolve`]. A frontend with no
6043/// notion of theme passes [`Light`](ColorScheme::Light), the web's own default.
6044#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6045pub enum ColorScheme {
6046 Light,
6047 Dark,
6048}
6049
6050/// Whether a `<source media="…">` query applies under `scheme`. Empty media is
6051/// an unconditional `<source>` (always matches); otherwise only a
6052/// `prefers-color-scheme: dark|light` feature is understood — anything else
6053/// (a width query, `print`, …) doesn't match, so resolution falls through to the
6054/// next source or the `<img>`. Deliberately lax about the surrounding syntax
6055/// (`(prefers-color-scheme: dark)`, `screen and (prefers-color-scheme:dark)`):
6056/// it keys off the feature and its value, which is all the theme case needs.
6057fn media_matches(media: &str, scheme: ColorScheme) -> bool {
6058 let media = media.trim();
6059 if media.is_empty() {
6060 return true;
6061 }
6062 let lower = media.to_ascii_lowercase();
6063 let Some(after) = lower
6064 .split_once("prefers-color-scheme")
6065 .map(|(_, rest)| rest)
6066 else {
6067 return false;
6068 };
6069 // Skip the `:` and any spaces to reach the value word.
6070 let value = after.trim_start_matches([':', ' ', '\t']);
6071 let wanted = match scheme {
6072 ColorScheme::Light => "light",
6073 ColorScheme::Dark => "dark",
6074 };
6075 value.starts_with(wanted)
6076}
6077
6078/// The first URL in a `srcset`: its first comma-separated candidate, before any
6079/// `1x`/`2x`/width descriptor. The theme case only ever puts one URL per
6080/// `<source>`, so the first candidate is the picture.
6081fn first_srcset_url(srcset: &str) -> Option<&str> {
6082 let first = srcset.split(',').next()?.trim();
6083 first.split_whitespace().next().filter(|u| !u.is_empty())
6084}
6085
6086/// The narrowest a column may be squeezed. Below a few characters a column
6087/// stops carrying text and just shreds it one letter per line, which is worse
6088/// than letting the grid run wide.
6089const MIN_COL_WIDTH: usize = 3;
6090
6091/// Shrink `widths` until the grid fits `avail` screen columns, taking from the
6092/// widest column each time so the loss is shared out rather than falling on
6093/// whichever column happens to be last. No column goes below
6094/// [`MIN_COL_WIDTH`]; a table with more columns than the surface has room for
6095/// still overflows, which is the honest outcome — there's nothing left to give.
6096fn fit_widths(widths: &mut [usize], avail: usize) {
6097 // Chrome: each column is its content plus a gutter either side, and every
6098 // column is closed by a `│` — with one more opening the row.
6099 let budget = avail.saturating_sub(3 * widths.len() + 1);
6100 while widths.iter().sum::<usize>() > budget {
6101 let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
6102 return;
6103 };
6104 *w -= 1;
6105 }
6106}
6107
6108/// Word-wrap `glyphs` into lines of at most `width` columns, hard-breaking any
6109/// single word too long to fit.
6110///
6111/// Unlike a paragraph — where an overlong word just trails off the end of the
6112/// line — a table column is a hard boundary: a glyph past it lands on top of
6113/// the border, or on the next cell. So the width here is a promise, and a word
6114/// that won't keep it is broken.
6115///
6116/// The space at a break is dropped rather than hung past the edge. Its offset
6117/// isn't lost: the caller gives every line an end stop just past its last
6118/// glyph, which is exactly where that space was.
6119///
6120/// `width` is in display columns, and a break only ever falls between grapheme
6121/// clusters. Both matter to more than the picture: the caller anchors each
6122/// line's end stop just past its last glyph, so a line cut mid-cluster would
6123/// put a caret stop inside a character — reachable by Down or a click, and the
6124/// next Backspace would take the cluster apart from the middle.
6125///
6126/// An explicit in-cell break (a `\n` glyph, from a `<br>`) is a hard boundary:
6127/// each run between the breaks wraps on its own and the results stack. The break
6128/// glyphs are dropped — the caller's per-line end stop already sits exactly where
6129/// each break was, so no offset is lost.
6130fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
6131 if glyphs.iter().any(|g| g.ch == '\n') {
6132 return glyphs
6133 .split(|g| g.ch == '\n')
6134 .flat_map(|seg| wrap_segment(seg, width))
6135 .collect();
6136 }
6137 wrap_segment(glyphs, width)
6138}
6139
6140/// [`wrap_glyphs`] for a run with no explicit breaks — the word-wrap proper.
6141fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
6142 let width = width.max(1);
6143 // Words are maximal non-space runs, each carrying the space that followed it
6144 // — which survives only if the next word joins it on this line.
6145 let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
6146 let mut word: Vec<Glyph> = Vec::new();
6147 for g in glyphs {
6148 if g.ch == ' ' {
6149 words.push((std::mem::take(&mut word), Some(g.clone())));
6150 } else {
6151 word.push(g.clone());
6152 }
6153 }
6154 if !word.is_empty() {
6155 words.push((word, None));
6156 }
6157
6158 let mut lines: Vec<Vec<Glyph>> = Vec::new();
6159 let mut line: Vec<Glyph> = Vec::new();
6160 let mut used = 0usize;
6161 let mut gap: Option<Glyph> = None;
6162 for (word, space) in words {
6163 for chunk in hard_break(&word, width) {
6164 let sep = gap.is_some() as usize;
6165 let cells = glyphs_width(chunk);
6166 if !line.is_empty() && used + sep + cells > width {
6167 lines.push(std::mem::take(&mut line));
6168 used = 0;
6169 gap = None; // the break swallows the space
6170 }
6171 if let Some(sp) = gap.take() {
6172 line.push(sp);
6173 used += 1;
6174 }
6175 line.extend_from_slice(chunk);
6176 used += cells;
6177 }
6178 gap = space;
6179 }
6180 // An empty cell is still one (empty) line — it has an end the caret can
6181 // sit at, which is how you type into it.
6182 if !line.is_empty() || lines.is_empty() {
6183 lines.push(line);
6184 }
6185 lines
6186}
6187
6188/// Break a single word into pieces of at most `width` columns, cutting only
6189/// between grapheme clusters — the replacement for slicing it into fixed runs
6190/// of glyphs, which measures a wide character as one column and can cut an
6191/// emoji in half.
6192///
6193/// A cluster wider than the whole column still gets a piece to itself: there is
6194/// nowhere legal to cut it, and overflowing by a cell is better than splitting a
6195/// character. An empty word yields no pieces at all, which is what keeps a
6196/// double space from opening a line of its own.
6197fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
6198 let mut out = Vec::new();
6199 if word.is_empty() {
6200 return out;
6201 }
6202 let (mut start, mut used) = (0usize, 0usize);
6203 for c in clusters(word) {
6204 if used > 0 && used + c.cells > width {
6205 out.push(&word[start..c.glyph]);
6206 start = c.glyph;
6207 used = 0;
6208 }
6209 used += c.cells;
6210 }
6211 out.push(&word[start..]);
6212 out
6213}
6214
6215/// A table rule spanning `widths`, e.g. `┌──────┬─────┐`. Each column is its
6216/// content width plus the one-space gutter on either side.
6217fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
6218 let mut s = String::new();
6219 s.push(left);
6220 for (i, w) in widths.iter().enumerate() {
6221 if i > 0 {
6222 s.push(mid);
6223 }
6224 for _ in 0..w + 2 {
6225 s.push('─');
6226 }
6227 }
6228 s.push(right);
6229 s
6230}
6231
6232/// Push real document text: each glyph maps to its own source byte, and the one
6233/// that opens a grapheme cluster is the caret stop for the whole cluster.
6234///
6235/// Per cluster rather than per codepoint because a cluster is the character the
6236/// user sees, and it's the unit backspace and delete already step by. A stop
6237/// inside 👨👩👧 — five codepoints strung together with joiners — is a caret
6238/// parked in the middle of a character: one press of Right lands there, and the
6239/// next Backspace severs a joiner from what it joined, leaving a dangling ZWJ in
6240/// the source. The rest of the cluster still gets its glyph (it has to be
6241/// drawn); it just isn't somewhere to stand.
6242fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
6243 for (gi, cluster) in text.grapheme_indices(true) {
6244 for (ci, ch) in cluster.char_indices() {
6245 out.push(Glyph {
6246 ch,
6247 style,
6248 src: base_src + gi + ci,
6249 stop: ci == 0,
6250 });
6251 }
6252 }
6253}
6254
6255/// [`push_text`] for one line of a highlighted code block: the same glyphs at
6256/// the same offsets, each additionally carrying the [`Token`] of the span it
6257/// falls in — `spans` being the line's entry from [`code_tokens`], ascending
6258/// byte ranges *into `text`*. A byte between spans keeps `style` as it is.
6259///
6260/// Offsets are what matters here: a token changes how a glyph is painted and
6261/// nothing about where it is or which source byte it stands on, so a caret
6262/// walks a highlighted block exactly as it walks an unhighlighted one.
6263fn push_code_text(
6264 out: &mut Vec<Glyph>,
6265 text: &str,
6266 base_src: usize,
6267 style: Style,
6268 spans: &[(Range<usize>, Token)],
6269) {
6270 let mut spans = spans.iter().peekable();
6271 for (gi, cluster) in text.grapheme_indices(true) {
6272 // Spans are ascending, so the one covering this cluster's first byte
6273 // is at or after the one that covered the last; step past those ended.
6274 while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
6275 spans.next();
6276 }
6277 let token = spans
6278 .peek()
6279 .filter(|(r, _)| r.contains(&gi))
6280 .map(|(_, t)| *t);
6281 // A cluster is classed whole, by its first byte: a grammar that split
6282 // an emoji's scalars between two tokens would otherwise split the
6283 // glyph, and no grammar means to.
6284 let style = style.token(token);
6285 for (ci, ch) in cluster.char_indices() {
6286 out.push(Glyph {
6287 ch,
6288 style,
6289 src: base_src + gi + ci,
6290 stop: ci == 0,
6291 });
6292 }
6293 }
6294}
6295
6296/// One line's highlighting — `crate::syntax::LineTokens`, spelled here so the
6297/// shape exists whether or not the feature that fills it does.
6298type LineTokens = Vec<(Range<usize>, Token)>;
6299
6300/// The syntax highlighting for a code block's lines, or `None` when the fence's
6301/// language is not one the grammars know. Without the `syntax` feature nothing
6302/// is known, and every code glyph draws in the plain code colour.
6303#[cfg(feature = "syntax")]
6304fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
6305 crate::syntax::highlight(lang, lines)
6306}
6307
6308#[cfg(not(feature = "syntax"))]
6309fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
6310 None
6311}
6312
6313/// Emit an inline `str`/`smart_punctuation` run, mapping every visible char back
6314/// to its *true* source byte even when the source carries backslash escapes the
6315/// parsed `text` dropped (`\*` → `*`). The naive `span.start + text_offset`
6316/// mapping [`push_text`] uses drifts by one byte after each escape, so a caret or
6317/// click past an escaped `*` would land on the wrong character; walking the text
6318/// against its source keeps them aligned, and the hidden escape backslash gets no
6319/// glyph of its own (it is a spelling artefact, not something the caret lands on).
6320fn push_escaped_text(
6321 out: &mut Vec<Glyph>,
6322 text: &str,
6323 span: Range<usize>,
6324 source: &str,
6325 style: Style,
6326) {
6327 let end = span.end.min(source.len());
6328 let src = source.get(span.start..end).unwrap_or("");
6329 // Fast path — no dropped bytes, so text and source align 1:1 (the common
6330 // case: prose with no escapes). Byte lengths equal ⇒ no backslash was eaten.
6331 if src.len() == text.len() {
6332 push_text(out, text, span.start, style);
6333 return;
6334 }
6335 // Slow path: some `\` was consumed. Walk char-by-char, skipping a backslash
6336 // in the source exactly when it escapes the next visible char (a real escape),
6337 // never when it is a literal backslash the parse kept (that case has equal
6338 // lengths and takes the fast path above).
6339 let sb = src.as_bytes();
6340 let mut si = 0usize;
6341 'text: for (_, cluster) in text.grapheme_indices(true) {
6342 for (ci, ch) in cluster.char_indices() {
6343 // The text outlasted the source it is being mapped onto. In a
6344 // consistent document that cannot happen on this path: the slow path
6345 // is only entered when the two lengths differ, and everything that
6346 // makes them differ makes the *source* the longer one — an escape
6347 // backslash the parse ate, or source folded into a neighbouring node.
6348 // A `smart_punctuation` node reports its canonical ASCII spelling
6349 // (`--`, `...`, `"`), which is never longer than what was written.
6350 //
6351 // So reaching here means `span` was measured against a document that
6352 // `source` is no longer, and there is no honest offset left to give
6353 // the remaining characters. Stop: the row comes out short, which is
6354 // a wrong picture of a document that is already inconsistent. The
6355 // alternative was `si` stepping past the end and the slice below
6356 // panicking — which is what it did, in a paint loop.
6357 if si >= sb.len() {
6358 break 'text;
6359 }
6360 // Advance to the source character this one came from, stepping over
6361 // whatever the parse dropped on the way. An escape backslash is the
6362 // common case, but not the only one: a span can cover source that
6363 // was folded into a neighbouring node (smart punctuation next to a
6364 // bracket gives `text: "]"` over a source span of `"…]"`). Advancing
6365 // by the *text* character's length assumed escapes were the only
6366 // divergence, so one dropped multi-byte character desynchronized
6367 // every glyph after it — placing `]` inside the `…` before it.
6368 while si < sb.len() && !src[si..].starts_with(ch) {
6369 si += src[si..].chars().next().map_or(1, char::len_utf8);
6370 }
6371 out.push(Glyph {
6372 ch,
6373 style,
6374 src: span.start + si.min(src.len()),
6375 stop: ci == 0,
6376 });
6377 si += src[si..]
6378 .chars()
6379 .next()
6380 .map_or(ch.len_utf8(), char::len_utf8);
6381 }
6382 }
6383}
6384
6385/// Build synthetic decoration glyphs (a bullet, a gutter) all pointing at `src`,
6386/// each carrying `role` so the frontend can style it (`Role::Body` for plain
6387/// padding). Synthetic glyphs are never caret stops — they share one offset, so
6388/// the caret steps over them (a click still lands at `src`).
6389fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
6390 let style = Style::default().role(role);
6391 text.chars()
6392 .map(|ch| Glyph {
6393 ch,
6394 style,
6395 src,
6396 stop: false,
6397 })
6398 .collect()
6399}
6400
6401fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
6402 let mut v = a.to_vec();
6403 v.extend_from_slice(b);
6404 v
6405}
6406
6407/// The columns a row's prefix (a bullet, a quote gutter, an indent) takes up
6408/// before the text it introduces — what the wrap budget has left to spend.
6409fn prefix_width(prefix: &[Glyph]) -> usize {
6410 glyphs_width(prefix)
6411}
6412
6413/// The label shown for an image with no alt text: the final path segment of its
6414/// destination (`img/cat.png` → `cat.png`), the whole destination when it has no
6415/// separator, and `"image"` when it's empty. A `data:` URI (which has no useful
6416/// tail) shows its scheme so the placeholder isn't a wall of base64.
6417fn media_label(dest: &str) -> String {
6418 if dest.is_empty() {
6419 return "image".to_string();
6420 }
6421 if dest.starts_with("data:") {
6422 return "data:…".to_string();
6423 }
6424 // Trim a query/fragment so a URL's `?v=2#frag` doesn't ride along.
6425 let clean = dest.split(['?', '#']).next().unwrap_or(dest);
6426 let tail = clean
6427 .trim_end_matches('/')
6428 .rsplit(['/', '\\'])
6429 .next()
6430 .unwrap_or(clean);
6431 if tail.is_empty() {
6432 dest.to_string()
6433 } else {
6434 tail.to_string()
6435 }
6436}
6437
6438/// A directive's attributes read as a human label — what a frontend puts on a
6439/// container's tinted panel, and what an attribute-bearing inline directive
6440/// shows in its chip.
6441///
6442/// Reads BOTH conventions diaryx content actually uses: twig's own dot-prefixed
6443/// classes (`{.public .family}`, arriving as one combined `class` attr) and bare
6444/// pandoc-style words with no leading dot (`{public family}` — what
6445/// `diaryx_core::visibility`'s publish-time filter and apps/web's directive
6446/// serializer both write, and which twig parses as one valueless attribute
6447/// each). Reading only `.class` would leave every *existing* diaryx `:::vis{…}`
6448/// block unlabeled. A `key=value` attr is configuration rather than a name, so
6449/// it contributes nothing. `None` when nothing readable is left.
6450fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
6451 let mut parts: Vec<String> = Vec::new();
6452 for (k, v) in attrs {
6453 if k == "class" {
6454 if let Some(v) = v
6455 && !v.is_empty()
6456 {
6457 parts.push(v.clone());
6458 }
6459 } else if v.as_deref().unwrap_or("").is_empty() {
6460 parts.push(k.clone());
6461 }
6462 }
6463 (!parts.is_empty()).then(|| parts.join(" "))
6464}
6465
6466fn heading_style(level: u32) -> Style {
6467 // Just the role — a frontend decides how a heading of this level *looks*
6468 // (the terminal cycles a color and bolds it, the GUI scales the font). The
6469 // author wrote no emphasis here, so core records none. `level as u8` is safe:
6470 // Markdown/Djot cap headings at 6.
6471 Style::default().role(Role::Heading(level.min(255) as u8))
6472}
6473
6474/// Is this `container` node a *directive* (`:::note{…}`, `::embed{…}`,
6475/// `:vis[…]`) rather than an HTML element (`<video>`, `<picture>`, `<div>`)?
6476///
6477/// twig 2.8 folded `div`/`span`/`directive`/`element` into one `container` kind,
6478/// and left nothing that separated them: `kind`, `name` and `directive_form` all
6479/// agree, field for field, on an HTML `<div>` and a Markdown `:::div`. Leaf
6480/// answered it by sniffing the span for whichever of `:` or `<` came first.
6481/// twig 3.0 records the answer at parse time as [`ContainerOrigin`], so this is
6482/// now the parser's own knowledge rather than a guess rebuilt from the bytes it
6483/// consumed.
6484pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
6485 node.origin == Some(ContainerOrigin::Directive)
6486}
6487
6488/// The tag a `container` node carries when it is an HTML element rather than a
6489/// directive — `Some("video")` for a promoted `<video>`, `None` for a `:::note`
6490/// or for any node that is not a container at all.
6491pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
6492 (node.origin == Some(ContainerOrigin::Element))
6493 .then_some(node.name.as_deref())
6494 .flatten()
6495}
6496
6497/// A leaf directive's name and whatever attributes are not part of spelling
6498/// it — the two things a [`DirectiveMark`] carries, which twig hands back
6499/// differently per format and which a frontend must not be able to tell apart.
6500///
6501/// Markdown's `::page-break` is a `Leaf`-form directive *named* `page-break`
6502/// with no attributes, and this returns it verbatim. Djot has no leaf form:
6503/// `insert_directive` writes the same document as an empty `::: page-break`
6504/// fence, whose container is anonymous (a djot div carries no name) and whose
6505/// name arrives as a class. Where the node has no name of its own, then, the
6506/// name is the word on the fence line when there is one — djot appends it
6507/// *after* the classes of an attribute line above (`{.wide}` over `:::
6508/// x-card` reads back as `class="wide x-card"`), so it is found by what the
6509/// fence says rather than by position — and otherwise, for a bare `:::` under
6510/// `{.page-break .wide}`, the first class. Whatever else the class said stays
6511/// an attribute.
6512fn leaf_directive_identity(
6513 node: &FlatNode,
6514 source: &str,
6515) -> (String, Vec<(String, Option<String>)>) {
6516 let named = node.name.clone().unwrap_or_default();
6517 if !named.is_empty() {
6518 return (named, node.attrs.clone());
6519 }
6520 let class = node
6521 .attrs
6522 .iter()
6523 .find(|(k, _)| k == "class")
6524 .and_then(|(_, v)| v.as_deref())
6525 .unwrap_or_default();
6526 let mut tokens = class.split_whitespace().collect::<Vec<_>>();
6527 let fence_word = source
6528 .get(node.span.start..)
6529 .and_then(|rest| rest.lines().next())
6530 .map(|line| line.trim_start_matches(['>', ' ', '\t', ':']).trim())
6531 .and_then(|word| word.split_whitespace().next());
6532 let at = fence_word
6533 .and_then(|word| tokens.iter().rposition(|t| *t == word))
6534 .unwrap_or(0);
6535 if tokens.is_empty() {
6536 return (named, node.attrs.clone());
6537 }
6538 let name = tokens.remove(at).to_string();
6539 let rest = tokens.join(" ");
6540 let attrs = node
6541 .attrs
6542 .iter()
6543 .filter_map(|(k, v)| {
6544 if k != "class" {
6545 return Some((k.clone(), v.clone()));
6546 }
6547 (!rest.is_empty()).then(|| (k.clone(), Some(rest.clone())))
6548 })
6549 .collect();
6550 (name, attrs)
6551}
6552
6553/// Is this inline `container` an **attributed span** — the node leaf's run-level
6554/// vocabulary rides — rather than a named directive?
6555///
6556/// The four formats spell one span four ways and twig hands the name back for
6557/// two of them: HTML's and Markdown's `<span …>` arrive named `span` with
6558/// `Element` origin, while djot's `[text]{…}` and AsciiDoc's `[.a]#text#`
6559/// arrive anonymous (an empty name) with `Directive` origin. All four are the
6560/// same node to `wrap_range_attrs`, which is what writes them, so they are the
6561/// same node here.
6562///
6563/// A *named* directive is not one, whatever its name: a Markdown `:span[…]{…}`
6564/// is a directive the parser read as a directive, twig's own
6565/// `wrap_range_attrs` says so, and it keeps the handling it has.
6566///
6567/// **Anonymous is not enough**, and the form is what finishes the question:
6568/// a djot fenced div (`{.center}` / `:::` / … / `:::`) is anonymous too, with
6569/// the same `Directive` origin, and is a *block* — `Container` form against the
6570/// span's `Text`. Reading one as a span made every gesture and every query lie
6571/// about it: `set_text_color` over a word inside such a div copied the whole
6572/// div's attribute set — its `id` along with the rest — onto the new span, and
6573/// `alignment_at_caret` reported the div's `.center` as a *run's* answer while
6574/// the walker drew none. So the anonymous arm asks the form [`is_inline`] asks.
6575pub(crate) fn is_run_span(node: &FlatNode) -> bool {
6576 if node.kind != Kind::Container {
6577 return false;
6578 }
6579 match node.name.as_deref() {
6580 None | Some("") => node.directive_form == Some(DirectiveForm::Text),
6581 Some("span") => node.origin == Some(ContainerOrigin::Element),
6582 Some(_) => false,
6583 }
6584}
6585
6586/// `base` with an attributed span's three run-level keys written over it — the
6587/// nearest-wins fold [`is_run_span`] describes, for one span. `faces` is the
6588/// build's intern table, as it is for [`Presentation::under`].
6589fn run_style(node: &FlatNode, base: Style, faces: &RefCell<FaceTable>) -> Style {
6590 Style {
6591 size: FontSize::from_attrs(&node.attrs).or(base.size),
6592 font: faces
6593 .borrow_mut()
6594 .face_from_attrs(&node.attrs)
6595 .or(base.font),
6596 color: TextColor::from_attrs(&node.attrs).or(base.color),
6597 ..base
6598 }
6599}
6600
6601pub(crate) fn is_inline(node: &FlatNode) -> bool {
6602 // A directive is inline only in its `text` form (`:name[label]{…}`); the
6603 // `leaf` and `container` forms are blocks. All three report the same `kind`,
6604 // so the form is the only thing telling them apart — and getting it wrong
6605 // costs a whole paragraph: a text directive misread as a block makes its
6606 // paragraph fail the "all children inline" test in `block`, and the line is
6607 // then walked as a container of blocks, rendering as empty rows with no
6608 // caret home at all.
6609 //
6610 // An HTML element shares the `container` kind, and twig sets the same form
6611 // on the two tags the lightweight formats have a generic spelling for: a
6612 // `<span>` is `Text` and a `<div>` is `Container`, while a `<video>` or a
6613 // `<picture>` has no form at all. So the form answers for an element as it
6614 // answers for a directive, and the origin is not consulted — which is what
6615 // makes a `<span …>` inside a paragraph an inline node.
6616 //
6617 // It has to. `wrap_range_attrs` spells an attributed run as exactly that
6618 // span in Markdown and HTML, and a paragraph holding one whose kids were
6619 // not all inline failed the test below and was walked as a container of
6620 // blocks: the text either side of the span rendered as nothing at all.
6621 if node.kind == Kind::Container {
6622 return node.directive_form == Some(DirectiveForm::Text);
6623 }
6624 is_inline_kind(&node.kind)
6625}
6626
6627/// [`is_inline`] by kind alone — for the ancestor walks, whose `QueryMatch`es
6628/// carry no `directive_form`. It answers `false` for every directive, which its
6629/// callers must (and do) reconcile: they pair it with `is_block_container`,
6630/// which claims every directive, so the pair's verdict is the same one a form
6631/// would have given. Anything looking at a *directive itself* wants [`is_inline`]
6632/// and a real node.
6633pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
6634 matches!(
6635 kind,
6636 Kind::Str
6637 | Kind::SoftBreak
6638 | Kind::HardBreak
6639 | Kind::NonBreakingSpace
6640 | Kind::Emph
6641 | Kind::Strong
6642 | Kind::Mark
6643 | Kind::Insert
6644 | Kind::Delete
6645 | Kind::Verbatim
6646 | Kind::InlineMath
6647 | Kind::DisplayMath
6648 | Kind::Url
6649 | Kind::Email
6650 | Kind::Link
6651 | Kind::Image
6652 | Kind::SmartPunctuation
6653 | Kind::Superscript
6654 | Kind::Subscript
6655 | Kind::FootnoteReference
6656 )
6657}
6658
6659/// Assert two maps are identical down to every glyph, stop, and table span — the
6660/// contract `build_cached` and `build_spliced` must hold against `build`. Lives
6661/// at module scope (not in `mod tests`) so the Doc-driven differential test in
6662/// `doc.rs` can reach it and the private `stops` field it compares.
6663#[cfg(test)]
6664pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
6665 assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
6666 for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
6667 assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
6668 assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
6669 // The incremental walk labels a boundary from a query match's kind
6670 // string and the whole-arena walk from a `FlatNode`'s; this is what says
6671 // the two doors reach the same answer.
6672 assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
6673 assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
6674 assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
6675 assert_eq!(ra.align, rb.align, "row {i} align ({ctx})");
6676 assert_eq!(
6677 ra.line_height, rb.line_height,
6678 "row {i} line_height ({ctx})"
6679 );
6680 assert_eq!(
6681 ra.glyphs.len(),
6682 rb.glyphs.len(),
6683 "row {i} glyph count ({ctx})"
6684 );
6685 for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
6686 assert_eq!(
6687 (ga.ch, ga.src, ga.stop, ga.style),
6688 (gb.ch, gb.src, gb.stop, gb.style),
6689 "row {i} glyph {j} ({ctx})"
6690 );
6691 }
6692 }
6693 assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
6694 assert_eq!(a.stops, b.stops, "stops ({ctx})");
6695 assert_eq!(a.mark_ends, b.mark_ends, "mark_ends ({ctx})");
6696 assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
6697 for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
6698 assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
6699 assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
6700 }
6701 assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
6702 assert_eq!(a.media, b.media, "images ({ctx})");
6703}
6704
6705#[cfg(test)]
6706mod tests {
6707 use super::*;
6708 use crate::style::{FontFamily, LineSpacing, SizeStep};
6709 use twig::{Editor, Format, NodeId};
6710
6711 fn map(src: &str) -> VisualMap {
6712 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6713 build_t(&ed.nodes().unwrap(), src, Some(80))
6714 }
6715
6716 /// [`map`] over a Djot source. Djot is the format that spells superscript
6717 /// and subscript at all — Markdown has no syntax for either.
6718 fn map_djot(src: &str) -> VisualMap {
6719 let mut ed = Editor::new_str(src, Format::Djot).unwrap();
6720 build_t(&ed.nodes().unwrap(), src, Some(80))
6721 }
6722
6723 /// The baseline every glyph spelling `ch` was built with, in row order —
6724 /// how a test reads a raised or lowered run off the map without caring
6725 /// which row it landed on.
6726 fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
6727 m.rows
6728 .iter()
6729 .flat_map(|r| r.glyphs.iter())
6730 .filter(|g| g.ch == ch)
6731 .map(|g| g.style.baseline)
6732 .collect()
6733 }
6734
6735 /// [`map`] at a chosen wrap width.
6736 fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
6737 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6738 build_t(&ed.nodes().unwrap(), src, wrap)
6739 }
6740
6741 /// [`map`], but with twig's `directives` extension on (off by twig's own
6742 /// default) — the `:::name{.class}` fenced-div containers leaf-core's
6743 /// `"directive"` wysiwyg arm renders.
6744 fn map_directives(src: &str) -> VisualMap {
6745 let mut ed = Editor::new_ext(
6746 src.as_bytes(),
6747 Format::Markdown,
6748 twig::MarkdownExtensions {
6749 directives: true,
6750 ..Default::default()
6751 },
6752 )
6753 .unwrap();
6754 build_t(&ed.nodes().unwrap(), src, Some(80))
6755 }
6756
6757 /// [`map`] in `format`, parsed the way every leaf document is — the
6758 /// extensions [`crate::doc::parse_extensions`] turns on, which is what
6759 /// pairs a Markdown `<div …>` with its `</div>` into a container and makes
6760 /// `::page-break` a directive rather than a paragraph of colons.
6761 fn map_leaf(src: &str, format: Format) -> VisualMap {
6762 let mut ed =
6763 Editor::new_ext(src.as_bytes(), format, crate::doc::parse_extensions()).unwrap();
6764 build_t(&ed.nodes().unwrap(), src, Some(80))
6765 }
6766
6767 /// The alignment and line spacing of every row that draws text, in order —
6768 /// how a test reads a block property off the map.
6769 fn line_facts(m: &VisualMap) -> Vec<(Option<Align>, Option<LineHeight>)> {
6770 m.rows
6771 .iter()
6772 .filter(|r| r.glyphs.iter().any(|g| !g.ch.is_whitespace()))
6773 .map(|r| (r.align, r.line_height))
6774 .collect()
6775 }
6776
6777 /// The style of the glyph spelling `ch`, first occurrence — how a test reads
6778 /// a run property off the map.
6779 fn style_of(m: &VisualMap, ch: char) -> Style {
6780 m.rows
6781 .iter()
6782 .flat_map(|r| r.glyphs.iter())
6783 .find(|g| g.ch == ch)
6784 .unwrap_or_else(|| panic!("no glyph {ch:?} in the map"))
6785 .style
6786 }
6787
6788 /// [`map`] with soft breaks preserved (`LineFlow::Preserve`).
6789 fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
6790 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6791 build(
6792 &ed.nodes().unwrap(),
6793 src,
6794 wrap,
6795 true,
6796 &Surface::default(),
6797 None,
6798 )
6799 }
6800
6801 /// The cache-free reference [`build`], with no per-image height overrides —
6802 /// every block image stays its default one-row placeholder. The tests that
6803 /// need a taller image drive it through [`crate::Doc::set_media_rows`] instead.
6804 fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
6805 build(nodes, src, wrap, false, &Surface::default(), None)
6806 }
6807
6808 /// An arena and a string that disagree — spans reaching past the source they
6809 /// are built against.
6810 ///
6811 /// [`crate::Doc`] keeps the two in step, so this is a "cannot happen" that
6812 /// nonetheless *did*: `examples/bench` timed a loop of `edit_range` and then
6813 /// went on handing the grown editor's spans to a builder holding the string
6814 /// from before it, and every run ended in a slice panic rather than a
6815 /// number. `push_escaped_text` was already written to survive the mismatch —
6816 /// it clamps the span's end and falls back to an empty slice — and this is
6817 /// the half of that intent it did not carry through.
6818 ///
6819 /// Rendering the wrong thing is the acceptable answer here; panicking in a
6820 /// paint loop is not.
6821 #[test]
6822 fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
6823 // An escape puts the run on `push_escaped_text`'s slow path — the fast
6824 // path is a length comparison that a truncated source fails anyway.
6825 let src = "alpha \\*beta\\* gamma delta epsilon\n";
6826 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6827 let nodes = ed.nodes().unwrap();
6828
6829 // Every truncation of it, so the cut lands before, inside and after the
6830 // escaped run rather than only where one hand-picked index put it.
6831 for cut in 0..=src.len() {
6832 if !src.is_char_boundary(cut) {
6833 continue;
6834 }
6835 let map = build_t(&nodes, &src[..cut], Some(80));
6836 for row in &map.rows {
6837 for g in &row.glyphs {
6838 assert!(
6839 g.src <= src.len(),
6840 "cut {cut}: glyph {:?} points past the source at {}",
6841 g.ch,
6842 g.src
6843 );
6844 }
6845 }
6846 }
6847 }
6848
6849 fn rendered(m: &VisualMap) -> String {
6850 m.rows
6851 .iter()
6852 .map(|r| r.glyphs.iter().map(Glyph::drawn).collect::<String>())
6853 .collect::<Vec<_>>()
6854 .join("\n")
6855 }
6856
6857 /// Render a source both ways: `build` over the whole marshalled arena (the
6858 /// reference), and `build_cached` driven the way [`crate::Doc`] drives it —
6859 /// top-level blocks from `child_spans`, per-block subtrees on a miss.
6860 fn render_both(
6861 ed: &mut Editor,
6862 src: &str,
6863 wrap: Option<usize>,
6864 cache: &mut BlockCache,
6865 ) -> (VisualMap, VisualMap) {
6866 let all = ed.nodes().unwrap();
6867 let surface = Surface::default();
6868 let plain = build(&all, src, wrap, false, &surface, None);
6869 let top = top_blocks(ed);
6870 let cached = build_cached(&top, src, wrap, false, &surface, None, cache, |id| {
6871 ed.subtree(NodeId(id)).unwrap_or_default()
6872 });
6873 (plain, cached)
6874 }
6875
6876 /// The whole correctness claim of the block cache: `build_cached` produces a
6877 /// byte-identical map to `build`, on a fresh cache *and* — the case that
6878 /// actually exercises reuse-and-shift plus per-block subtree marshalling — on
6879 /// a warm cache after the source has been edited underneath it.
6880 /// **Every glyph must stand on the character it claims.** A row's source
6881 /// extent is computed from its last glyph's offset, so a glyph carrying an
6882 /// offset that is not its own character's start yields a row end inside a
6883 /// multi-byte character — and every later slice of the source panics on it.
6884 ///
6885 /// Reproduces a real crash from a journal entry: a bracketed elision inside
6886 /// a blockquote (`[…]`) gave the closing bracket a `text` of `"]"` over a
6887 /// source span covering `"…]"`, because the parse folded the ellipsis into a
6888 /// neighbouring node. `push_escaped_text` walked that span assuming a
6889 /// dropped backslash was the only way text and source could diverge, so the
6890 /// `]` landed on the `…`'s first byte:
6891 /// `byte index 1236 is not a char boundary; it is inside '…'`.
6892 #[test]
6893 fn a_glyph_never_lands_inside_the_character_before_it() {
6894 let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
6895 let vmap = map(src);
6896 for (r, row) in vmap.rows.iter().enumerate() {
6897 assert!(
6898 src.is_char_boundary(row.end_src.min(src.len())),
6899 "row {r} ends at {} — inside a character",
6900 row.end_src
6901 );
6902 for g in &row.glyphs {
6903 assert!(
6904 src.is_char_boundary(g.src.min(src.len())),
6905 "row {r} has {:?} at {}, which is inside a character",
6906 g.ch,
6907 g.src
6908 );
6909 }
6910 }
6911 // The elision survives, and its bracket sits on the real `]`.
6912 let text: String = vmap
6913 .rows
6914 .iter()
6915 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6916 .collect();
6917 assert!(text.contains("[…]"), "the elision should render: {text:?}");
6918 let close = vmap
6919 .rows
6920 .iter()
6921 .flat_map(|r| r.glyphs.iter())
6922 .find(|g| g.ch == ']')
6923 .expect("a closing bracket");
6924 assert_eq!(
6925 src[close.src..].chars().next(),
6926 Some(']'),
6927 "the bracket glyph should stand on the source's own `]`"
6928 );
6929 }
6930
6931 #[test]
6932 fn build_cached_matches_build() {
6933 let docs = [
6934 "# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
6935 "## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
6936 "para one\n\n```\ncode\nlines\n```\n\nafter code\n",
6937 "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
6938 "line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
6939 "> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
6940 "intro\n\n\n\nbetween\n\n\n\nend\n",
6941 "- text item\n- \n- more text\n",
6942 // Footnotes: twig parses each definition as a root beside `doc`, so
6943 // these are the docs where the reference build and the incremental
6944 // one could disagree about what the top-level blocks even are.
6945 "A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
6946 "note[^a]\n\n[^a]: body **bold**\n wrapped on\n three lines\n\nafter\n",
6947 // No trailing newline. twig closes the document's last block on the
6948 // virtual newline it supplies at EOF, so that block's `span.end` is
6949 // `source.len() + 1` — a range that slices no bytes at all. Keying
6950 // the block cache off such a slice made every last block hash alike;
6951 // see [`block_bytes`].
6952 "# Title\n\nThe quick brown fox.\n\nA tail with no newline",
6953 "A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
6954 // Comments draw nothing. The per-block builder the cached path
6955 // renders one with starts at offset 0 and, drawing nothing, never
6956 // moved — so the walk went on from 0 and spelled every line of the
6957 // document as a blank row. One at the start, one between blocks,
6958 // one at the end, so each position is covered.
6959 "<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
6960 // A Markdown `<div>` ends with a hidden `</div>` line the walk
6961 // steps over — between blocks and closing the file, so both the
6962 // separator after it and the trailing count are covered.
6963 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
6964 "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n",
6965 // Link reference definitions: roots beside `doc` like footnotes,
6966 // but drawing nothing. Alone between blocks, glued under a
6967 // paragraph, and closing the file under a comment — the README
6968 // shape.
6969 "see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
6970 // A rule's row ends past the newline under it while the walk
6971 // stands at the rule, and the rule's block is never cached for
6972 // it: an empty line under one mid-document and closing it.
6973 "para\n\n---\n\n\n\nbetween rules\n\n---\n\n",
6974 // Blank lines above the first block draw rows: bare, past
6975 // frontmatter, and past a comment that draws nothing.
6976 "\n\n\nfirst\n\nsecond\n",
6977 "---\ntitle: x\n---\n\n\nafter frontmatter\n",
6978 "<!-- lead -->\n\n\n\nafter a comment\n",
6979 // An empty paragraph at the end of a div draws inside it.
6980 "<div data-line-height=\"1.5\">\n\nI cry\n\nknees\n\n\n\n</div>\n\nafter\n",
6981 ];
6982 for wrap in [None, Some(80usize), Some(20)] {
6983 for src in docs {
6984 let ctx = format!("wrap={wrap:?} src={src:?}");
6985 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6986 let mut cache = BlockCache::default();
6987
6988 // 1) Fresh cache equals the cache-free build.
6989 let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
6990 assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
6991
6992 // 2) Type a char mid-document, reparse, rebuild with the now-warm
6993 // cache: the edited block is re-marshalled and re-rendered,
6994 // every block below it is reused shifted, and the result must
6995 // still match a from-scratch build.
6996 let at = (src.len() / 2..=src.len())
6997 .find(|&i| src.is_char_boundary(i))
6998 .unwrap();
6999 ed.edit_range(at, at, "Z").unwrap();
7000 let src2 = ed.source_str().unwrap();
7001 let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
7002 assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
7003
7004 // 3) Delete it again: offsets shift back the other way, and the
7005 // warm cache must not hand back stale shifted rows.
7006 ed.edit_range(at, at + 1, "").unwrap();
7007 let src3 = ed.source_str().unwrap();
7008 let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
7009 assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
7010 }
7011 }
7012 }
7013
7014 /// A document that does not end in a newline is the one place twig hands
7015 /// leaf a top-level span that addresses no source: the last block is closed
7016 /// on the virtual newline the parser supplies at EOF, so its `span.end` is
7017 /// `source.len() + 1`. The block cache keys on the bytes under that span, and
7018 /// reading the out-of-range slice as *no bytes* broke it two ways at once —
7019 /// [`block_bytes`] has the full account. Both ways are checked here, because
7020 /// they fail independently.
7021 #[test]
7022 fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
7023 // One: two overrunning blocks collide. A footnote definition is a root
7024 // beside `doc` that [`top_blocks`] merges into the top level, while the
7025 // `section` above it spans the definition's bytes too — so when the
7026 // definition ends the file, both blocks end past it. The second was
7027 // served the first's rows, and the definition rendered as a copy of the
7028 // heading.
7029 let src = "A claim[^1] worth checking.\n\n# A heading with a reference[^1] in it\n\n[^1]: The first note.\n[^note]: A note with a word for a label.";
7030 let mut ed = Editor::new_str(src, Format::Djot).unwrap();
7031 let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
7032 assert_maps_eq(&plain, &cached, "a definition ending the file");
7033 let text = rendered(&cached);
7034 assert!(
7035 text.ends_with("[note] A note with a word for a label."),
7036 "the last definition should render itself: {text:?}"
7037 );
7038 assert_eq!(
7039 text.matches("A heading with a reference").count(),
7040 1,
7041 "the heading should render exactly once: {text:?}"
7042 );
7043
7044 // Two: one overrunning block goes stale. Its bytes are its cache key, so
7045 // a block that keeps hashing the same however it is edited is served the
7046 // rows built before the edit — the whole last line frozen as the user
7047 // types in it.
7048 let mut cache = BlockCache::default();
7049 let first = "first para\n\n# A heading\n\nlast para with no newline";
7050 let mut ed = Editor::new_str(first, Format::Djot).unwrap();
7051 let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
7052 assert!(rendered(&warm).ends_with("last para with no newline"));
7053
7054 let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
7055 let mut ed = Editor::new_str(second, Format::Djot).unwrap();
7056 let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
7057 assert_maps_eq(&plain, &cached, "edited last block, warm cache");
7058 let text = rendered(&cached);
7059 assert!(
7060 text.ends_with("DIFFERENT text without a newline"),
7061 "the warm cache served the pre-edit rows: {text:?}"
7062 );
7063 }
7064
7065 #[test]
7066 fn resolves_markup_to_plain_text() {
7067 let text = rendered(&map("# Title\n\na **bold** word\n"));
7068 assert!(!text.contains('#'), "heading marker shown: {text:?}");
7069 assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
7070 assert!(text.contains("Title") && text.contains("bold word"));
7071 }
7072
7073 #[test]
7074 fn every_glyph_points_at_its_source_byte() {
7075 let src = "a **bold** c\n";
7076 let m = map(src);
7077 for row in &m.rows {
7078 for g in &row.glyphs {
7079 // A real (non-synthetic) glyph's source byte is the glyph's char.
7080 if g.src < src.len()
7081 && src.is_char_boundary(g.src)
7082 && let Some(sc) = src[g.src..].chars().next()
7083 && sc == g.ch
7084 {
7085 continue;
7086 }
7087 // Synthetic prefixes (none here) would be the only exceptions.
7088 panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
7089 }
7090 }
7091 }
7092
7093 #[test]
7094 fn offset_and_position_round_trip_on_visible_text() {
7095 let m = map("hello world\n");
7096 let (r, c) = m.pos_of_offset(6); // the 'w'
7097 assert_eq!(m.offset_of_pos(r, c), 6);
7098 }
7099
7100 #[test]
7101 fn visible_utf16_indices_count_the_text_the_system_sees() {
7102 // Hidden delimiters, a two-unit emoji, and a block gap — every way the
7103 // visible text's UTF-16 length parts company with a source byte count.
7104 let src = "a **b\u{1F600}** c\n\nd\n";
7105 let m = map(src);
7106 let end = m.snap_to_stop(src.len());
7107 let text = m.visible_text(0, end);
7108 assert_eq!(text, "a b\u{1F600} c\nd");
7109
7110 // Forward: the index of each offset is where that character sits in
7111 // the visible string, in UTF-16 units.
7112 for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
7113 let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
7114 assert_eq!(
7115 m.visible_utf16_len(0, *src_off),
7116 expect,
7117 "utf16 index of source offset {src_off}"
7118 );
7119 // And back: the index resolves to the offset it came from.
7120 assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
7121 }
7122 // Inside the emoji's surrogate pair resolves to the emoji.
7123 let emoji_src = src.find('\u{1F600}').unwrap();
7124 let emoji_idx = m.visible_utf16_len(0, emoji_src);
7125 assert_eq!(
7126 m.offset_at_visible_utf16(end, emoji_idx + 1),
7127 Some(emoji_src)
7128 );
7129 // At or past the end is nobody's character.
7130 let total = m.visible_utf16_len(0, end);
7131 assert_eq!(total, text.encode_utf16().count());
7132 assert_eq!(m.offset_at_visible_utf16(end, total), None);
7133 }
7134
7135 /// The documents the lookups are checked against their reference scans
7136 /// on: every shape that puts rows out of source order or a stop out of
7137 /// step with a glyph. A wrapped table, whose cells' second lines sit
7138 /// below the next column's first; a wide grapheme and an emoji outside
7139 /// the BMP; hidden delimiters and a link's hidden destination; a list
7140 /// with synthetic markers; a code block; an image row whose label glyphs
7141 /// all share one offset; an empty paragraph, a heading, and a wrapped
7142 /// paragraph — at a narrow width so the table and the prose both wrap,
7143 /// and unwrapped, which is how a GUI builds it.
7144 fn lookup_maps() -> Vec<(String, VisualMap)> {
7145 let table = "| left cell that wraps | right |\n|---|---|\n| a longer cell than the column can hold | b |\n| `k` | 你好 |\n\n";
7146 let srcs = [
7147 "hello world\n",
7148 "a **b\u{1F600}** c\n\nd\n",
7149 "- one *two*\n- three\n\n\n# Title\n\nend [link](https://e.org/x) tail\n",
7150 &format!("{table}```\nx\ny\n```\n\n\n\ntail 你好 **bold** here\n"),
7151 "one two three four five six seven eight nine ten eleven twelve thirteen\n\n| a | b |\n|-|-|\n| c d e f g h | i |\n",
7152 ];
7153 let mut out = Vec::new();
7154 for src in srcs {
7155 for wrap in [Some(14), None] {
7156 out.push((format!("{src:?} at {wrap:?}"), map_at(src, wrap)));
7157 }
7158 }
7159 out
7160 }
7161
7162 /// `pos_of_offset` as it was written before the walk learnt to dismiss a
7163 /// row from its ends: every row read through, the nearest stop kept.
7164 fn pos_of_offset_by_scan(m: &VisualMap, off: usize) -> (usize, usize) {
7165 let mut best: Option<(usize, usize, usize)> = None;
7166 for (r, row) in m.rows.iter().enumerate() {
7167 if row.decoration {
7168 continue;
7169 }
7170 let cand = row
7171 .glyphs
7172 .iter()
7173 .enumerate()
7174 .find(|(_, g)| g.stop && g.src >= off)
7175 .map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
7176 .or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
7177 if let Some(c) = cand
7178 && best.is_none_or(|b| c.0 <= b.0)
7179 {
7180 best = Some(c);
7181 }
7182 }
7183 match best {
7184 Some((_, r, c)) => (r, c),
7185 None => {
7186 let r = m.last_stop_row();
7187 (r, m.row_width(r))
7188 }
7189 }
7190 }
7191
7192 /// `visible_items` as it was written before the spelling was tabulated:
7193 /// every stop glyph in the range gathered, sorted, and paired up.
7194 fn visible_items_by_scan(m: &VisualMap, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
7195 let (lo, hi) = m.visible_span(from, to);
7196 let from = m.snap_to_glyph_stop(from);
7197 let mut glyphs: Vec<(usize, char)> = m
7198 .rows
7199 .iter()
7200 .filter(|r| !r.decoration)
7201 .flat_map(|r| r.glyphs.iter())
7202 .filter(|g| g.stop && g.src >= from && g.src < to)
7203 .map(|g| (g.src, g.ch))
7204 .collect();
7205 glyphs.sort_by_key(|&(src, _)| src);
7206 glyphs.dedup_by_key(|&mut (src, _)| src);
7207 let cell_ends: Vec<usize> = m
7208 .tables
7209 .iter()
7210 .flat_map(|t| t.grid.iter())
7211 .flat_map(|r| r.cells.iter())
7212 .map(|c| c.end)
7213 .collect();
7214 m.stops[lo..hi]
7215 .iter()
7216 .map(|&s| {
7217 let ch = glyphs
7218 .iter()
7219 .find(|&&(src, _)| src == s)
7220 .filter(|_| !cell_ends.contains(&s))
7221 .map(|&(_, ch)| ch);
7222 (s, ch)
7223 })
7224 .collect()
7225 }
7226
7227 #[test]
7228 fn pos_of_offset_agrees_with_reading_every_row_through() {
7229 for (name, m) in lookup_maps() {
7230 let len = m.rows.iter().map(|r| r.end_src).max().unwrap_or(0) + 2;
7231 for off in 0..=len {
7232 assert_eq!(
7233 m.pos_of_offset(off),
7234 pos_of_offset_by_scan(&m, off),
7235 "offset {off} of {name}"
7236 );
7237 }
7238 }
7239 }
7240
7241 #[test]
7242 fn the_tabulated_spelling_agrees_with_gathering_the_glyphs() {
7243 for (name, m) in lookup_maps() {
7244 let end = m.stops.last().copied().unwrap_or(0);
7245 // Whole text, and every window a frontend might ask for.
7246 let mut spans = vec![(0, end)];
7247 for &a in m.stops.iter().step_by(3) {
7248 spans.push((a, end));
7249 spans.push((0, a));
7250 spans.push((a, (a + 5).min(end)));
7251 }
7252 for (from, to) in spans {
7253 let items = visible_items_by_scan(&m, from, to);
7254 assert_eq!(
7255 m.visible_items(from, to),
7256 items,
7257 "items {from}..{to} of {name}"
7258 );
7259 let utf16: usize = items
7260 .iter()
7261 .map(|(_, ch)| ch.map_or(1, char::len_utf16))
7262 .sum();
7263 assert_eq!(
7264 m.visible_utf16_len(from, to),
7265 utf16,
7266 "utf16 {from}..{to} of {name}"
7267 );
7268 }
7269 // And back: every UTF-16 index in the text resolves to the stop
7270 // that spells it, as the scan would have found it.
7271 let items = visible_items_by_scan(&m, 0, end);
7272 let mut seen = 0;
7273 for (src, ch) in &items {
7274 for u in seen..seen + ch.map_or(1, char::len_utf16) {
7275 assert_eq!(
7276 m.offset_at_visible_utf16(end, u),
7277 Some(*src),
7278 "index {u} of {name}"
7279 );
7280 }
7281 seen += ch.map_or(1, char::len_utf16);
7282 }
7283 assert_eq!(
7284 m.offset_at_visible_utf16(end, seen),
7285 None,
7286 "past the end of {name}"
7287 );
7288 }
7289 }
7290
7291 #[test]
7292 fn visible_text_spends_exactly_one_character_on_every_stop() {
7293 // A list (whose items' ends no gap row follows), a table (whose cells'
7294 // ends draw a gutter space), and a code block (one row per line):
7295 // every place the text used to part company with the stop count, in
7296 // both directions. `UITextInput`'s tokenizer indexes this text by
7297 // that count, so the two must agree exactly between any two stops.
7298 let src = "- one\n- two\n\n| a | b |\n| - | - |\n| c | d |\n\n```\nx\ny\n```\n\nend\n";
7299 let m = map(src);
7300 let end = m.snap_to_stop(src.len());
7301 // The table's trailing stop draws no glyph, so it is spelled as a line
7302 // end too: to the system the table ends on a blank line, which is
7303 // where the caret past it stands.
7304 assert_eq!(m.visible_text(0, end), "one\ntwo\na\nb\nc\nd\n\nx\ny\nend");
7305 // Between any two stops, one character per hop.
7306 let first = m.snap_to_glyph_stop(0);
7307 let stops: Vec<usize> = std::iter::successors(Some(first), |&o| m.stop_after(o)).collect();
7308 for (i, &a) in stops.iter().enumerate() {
7309 for (j, &b) in stops.iter().enumerate().skip(i) {
7310 assert_eq!(
7311 m.visible_text(a, b).chars().count(),
7312 j - i,
7313 "text between stops {a} and {b}"
7314 );
7315 }
7316 }
7317 // A cell's end is spelled as a line end, not the space it draws, so a
7318 // tap landing past `a`'s last letter has nothing to step over into `b`.
7319 let a_end = src.find("a |").unwrap() + 1;
7320 assert_eq!(m.visible_text(a_end, a_end + 1), "\n");
7321 }
7322
7323 #[test]
7324 fn unwrapped_mode_emits_one_row_per_paragraph() {
7325 // A long paragraph that would wrap under a column budget stays a single
7326 // row when wrap is None (the GUI wraps it at pixel width instead).
7327 let long = "one two three four five six seven eight nine ten eleven twelve\n";
7328 let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
7329 let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
7330 let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
7331 assert!(wrapped.num_rows() > 1, "narrow column should wrap");
7332 assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
7333 // Every glyph's source byte is preserved in the single row.
7334 let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
7335 assert_eq!(text.trim_end(), long.trim_end());
7336 }
7337
7338 fn line_texts(m: &VisualMap) -> Vec<String> {
7339 m.rows
7340 .iter()
7341 .map(|r| {
7342 // Trim the trailing whitespace a row may carry — the zero-width
7343 // '\n' that closes a preserved line, and any space glyph left at
7344 // a wrap boundary (both real caret stops, neither visible text).
7345 r.glyphs
7346 .iter()
7347 .map(|g| g.ch)
7348 .collect::<String>()
7349 .trim_end()
7350 .to_string()
7351 })
7352 .collect()
7353 }
7354
7355 #[test]
7356 fn preserve_lays_each_soft_break_on_its_own_row() {
7357 // A soft break (a bare newline inside a paragraph) folds into a space by
7358 // default — the whole paragraph is one reflowed row...
7359 let src = "one two\nthree four\n";
7360 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7361 let folded = build_t(&ed.nodes().unwrap(), src, None);
7362 assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
7363 assert_eq!(
7364 line_texts(&folded),
7365 vec!["one two three four"],
7366 "break folded to a space"
7367 );
7368
7369 // ...and under Preserve it renders where it was written, a row per line.
7370 let kept = map_preserve(src, None);
7371 assert_eq!(
7372 line_texts(&kept),
7373 vec!["one two", "three four"],
7374 "preserve: a row per line"
7375 );
7376 }
7377
7378 #[test]
7379 fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
7380 // The break must leave a caret stop at the newline byte, or the caret
7381 // could not rest at the end of the first line. The '\n' glyph is dropped
7382 // from the row (so nothing stray renders); its offset (7 here) becomes the
7383 // row's end stop instead — the same offset the folded space would carry.
7384 let src = "one two\nthree four\n";
7385 let m = map_preserve(src, None);
7386 assert!(
7387 !m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
7388 "the break glyph is dropped"
7389 );
7390 assert_eq!(
7391 m.rows[0].end_src, 7,
7392 "the first row ends at the newline byte"
7393 );
7394 assert!(m.is_stop(7), "the newline offset is a caret stop");
7395 // Row end offsets stay strictly ascending — no two rows pin one offset.
7396 let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
7397 assert!(
7398 offs.windows(2).all(|w| w[0] < w[1]),
7399 "offsets not unique: {offs:?}"
7400 );
7401 }
7402
7403 #[test]
7404 fn preserved_lines_wrap_independently() {
7405 // Each preserved line wraps to the column on its own; the break between
7406 // them is hard, so a word never crosses it — "gamma" and "delta" could
7407 // share a row on width alone but the soft break keeps them apart.
7408 let src = "alpha beta gamma\ndelta epsilon\n";
7409 let m = map_preserve(src, Some(12));
7410 assert_eq!(
7411 line_texts(&m),
7412 vec!["alpha beta", "gamma", "delta", "epsilon"],
7413 "each source line wraps on its own"
7414 );
7415 }
7416
7417 #[test]
7418 fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
7419 // "A", then two blank lines (an empty paragraph opened with Enter), then
7420 // "B": the empty paragraph must be navigable rows, not collapsed onto B.
7421 // Rows: "A", spacer, empty-paragraph, spacer, "B" — each blank row a
7422 // distinct source offset.
7423 let m = map("A\n\n\n\nB\n");
7424 let text: Vec<String> = m
7425 .rows
7426 .iter()
7427 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7428 .collect();
7429 assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
7430 let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
7431 // Strictly ascending — no two rows share an offset (else the caret pins).
7432 assert!(
7433 offs.windows(2).all(|w| w[0] < w[1]),
7434 "offsets not unique: {offs:?}"
7435 );
7436 }
7437
7438 #[test]
7439 fn a_tight_block_boundary_still_gets_one_separator() {
7440 // A heading directly above text (no blank line between) keeps the single
7441 // conventional separator row, as before.
7442 let m = map("# H\ntext\n");
7443 let text: Vec<String> = m
7444 .rows
7445 .iter()
7446 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7447 .collect();
7448 assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
7449 }
7450
7451 #[test]
7452 fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
7453 // `a\*b` renders the three visible chars `a * b` — the escape backslash
7454 // is hidden — and every glyph points at its real source byte, so a caret
7455 // past the escape lands right (the `*` at source 2, `b` at source 3, not
7456 // the drifted 1/2 the naive text-offset mapping gave).
7457 let m = map("a\\*b\n");
7458 let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
7459 assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
7460 }
7461
7462 #[test]
7463 fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
7464 // `\# hi` is a paragraph beginning with a literal `#`, not a heading —
7465 // the backslash is hidden, the `#` shown at its true offset.
7466 let m = map("\\# hi\n");
7467 let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
7468 assert_eq!(text, "# hi");
7469 assert_eq!(
7470 m.rows[0].glyphs[0].src, 1,
7471 "the # is at source byte 1, past the \\"
7472 );
7473 }
7474
7475 #[test]
7476 fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
7477 // A list item's own text and the sub-list nested under it are written on
7478 // adjacent source lines, so the rich view butts them together — no
7479 // fabricated blank row. Regression: the synthetic "breathe" separator
7480 // used to open a gap between `• a` and its ` • b`.
7481 assert_eq!(rendered(&map("- a\n - b\n")), "• a\n • b");
7482 }
7483
7484 #[test]
7485 fn a_loose_nested_list_keeps_its_real_blank_line() {
7486 // A genuine blank source line (a loose list) still parts the item from
7487 // its sub-list — only the *fabricated* separator is suppressed, never a
7488 // real one the author typed. The gap row wears the item's continuation
7489 // prefix (the two-space indent), so it renders as " ", not empty.
7490 assert_eq!(rendered(&map("- a\n\n - b\n")), "• a\n \n • b");
7491 }
7492
7493 #[test]
7494 fn a_code_block_in_a_list_item_wears_one_marker() {
7495 // The item's marker goes on the block's first line and its indent on
7496 // the rest, as a wrapped paragraph's rows do. Every line wore the
7497 // marker once, so a two-line block in an item read as two items.
7498 assert_eq!(
7499 rendered(&map("- ```\n one\n two\n ```\n")),
7500 "• one\n two"
7501 );
7502 assert_eq!(
7503 rendered(&map("1. ```\n one\n two\n ```\n")),
7504 "1. one\n two"
7505 );
7506 assert_eq!(
7507 rendered(&map("- [ ] ```\n one\n two\n ```\n")),
7508 "☐ one\n two"
7509 );
7510 }
7511
7512 #[test]
7513 fn an_items_later_rows_wear_its_marker_as_a_blank_indent() {
7514 // Spelled with the marker's characters so a proportional face gives
7515 // the indent exactly the marker's width; drawn blank everywhere.
7516 for (src, marker) in [
7517 ("- a\n\n b\n", "• "),
7518 ("1. a\n\n b\n", "1. "),
7519 ("- [ ] a\n\n b\n", "☐ "),
7520 ] {
7521 let m = map(src);
7522 let last = m.rows.last().unwrap();
7523 let indent: String = last
7524 .glyphs
7525 .iter()
7526 .take_while(|g| g.style.role == Role::ListIndent)
7527 .map(|g| g.ch)
7528 .collect();
7529 assert_eq!(indent, marker, "{src:?}");
7530 assert!(rendered(&m).ends_with(&format!("{}b", " ".repeat(marker.chars().count()))));
7531 }
7532 }
7533
7534 #[test]
7535 fn a_code_block_in_a_quote_draws_no_row_for_its_closing_fence() {
7536 // The gutter is the same on every row. The closing fence is markup,
7537 // and drew an empty quoted line under the code as if the writer had
7538 // typed one, at the end of the document or before more.
7539 assert_eq!(
7540 rendered(&map("> ```\n> one\n> two\n> ```\n")),
7541 "│ one\n│ two"
7542 );
7543 assert_eq!(
7544 rendered(&map("> ```\n> one\n> ```\n\nafter\n")),
7545 "│ one\n\nafter"
7546 );
7547 // A quoted line the writer added under the fence is still one.
7548 assert_eq!(rendered(&map("> ```\n> one\n> ```\n>\n")), "│ one\n│ ");
7549 }
7550
7551 #[test]
7552 fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
7553 // Leading YAML frontmatter renders nothing — no phantom blank rows for
7554 // its lines, no leading gap — and `content_start` points at the first
7555 // real block so the caret floor can keep out of the hidden metadata.
7556 let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
7557 let src = format!("{fm}# leaf\n\nA line.\n");
7558 let m = map(&src);
7559 let text = rendered(&m);
7560 assert!(
7561 !text.contains("config"),
7562 "frontmatter body leaked: {text:?}"
7563 );
7564 assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
7565 assert_eq!(
7566 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7567 "leaf"
7568 );
7569 assert_eq!(
7570 m.content_start,
7571 fm.len(),
7572 "floor should be the first real block"
7573 );
7574 }
7575
7576 #[test]
7577 fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
7578 // Nothing to render, so the caret floor is the end of the hidden
7579 // frontmatter — not 0, which is *before* the opening `---` and made the
7580 // first keystroke in a fresh metadata-only note land ahead of it. And
7581 // the frontmatter's own newlines are not trailing blank lines: they used
7582 // to open phantom rows at offsets 1..4, inside the metadata.
7583 let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
7584 let m = map(src);
7585 assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
7586 assert!(
7587 m.rows.is_empty(),
7588 "frontmatter must render no rows: {:?}",
7589 rendered(&m)
7590 );
7591 assert!(
7592 m.stops.is_empty(),
7593 "no stop may sit inside the metadata: {:?}",
7594 m.stops
7595 );
7596 }
7597
7598 #[test]
7599 fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
7600 // Two blank lines after the frontmatter are the author's empty paragraph
7601 // and still render, counted from the metadata's end rather than from 0.
7602 let fm = "---\ntitle: n\n---\n";
7603 let m = map(&format!("{fm}\n\n"));
7604 assert_eq!(m.content_start, fm.len());
7605 assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
7606 assert!(
7607 m.rows.iter().all(|r| r.end_src > fm.len()),
7608 "rows must sit past the frontmatter"
7609 );
7610 }
7611
7612 #[test]
7613 fn a_document_without_frontmatter_has_a_zero_floor() {
7614 let m = map("# leaf\n\nbody\n");
7615 assert_eq!(m.content_start, 0);
7616 }
7617
7618 #[test]
7619 fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
7620 // Markdown/Djot drop the trailing space in `hello ` from the `str` node,
7621 // so without help the row would end at `hello` and the caret couldn't be
7622 // drawn past column 5 — typing a space at a line's end wouldn't move it
7623 // on screen until the next visible character reparsed the space into an
7624 // interior node. The builder recovers it from the block's span/content_span
7625 // gap and emits it as a real, caret-stoppable glyph.
7626 let m = map("hello \n");
7627 assert_eq!(
7628 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7629 "hello "
7630 );
7631 assert_eq!(
7632 m.rows[0].end_src, 6,
7633 "the row now ends past the trailing space"
7634 );
7635 // The caret can rest both on and past the space.
7636 assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
7637 assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
7638 // Two trailing spaces, both stops.
7639 let m = map("hello \n");
7640 assert_eq!(
7641 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7642 "hello "
7643 );
7644 assert_eq!(m.pos_of_offset(7), (0, 7));
7645 }
7646
7647 #[test]
7648 fn a_headings_trailing_space_is_a_caret_stop_too() {
7649 // The hidden `# ` marker means `# hi ` renders as `hi ` in three columns;
7650 // the caret past the trailing space lands on the third.
7651 let m = map("# hi \n");
7652 assert_eq!(
7653 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7654 "hi "
7655 );
7656 assert_eq!(m.pos_of_offset(5), (0, 3));
7657 }
7658
7659 #[test]
7660 fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
7661 // A cell's own `span` is the whole row, so the trailing-whitespace
7662 // recovery must not run for cells or it would swallow the `│` delimiters
7663 // and neighbours between the cell text and the row's end. The grid stays
7664 // exactly as before.
7665 let text = rendered(&map(TABLE));
7666 assert!(
7667 text.contains("│ Pear │ 3 │"),
7668 "cell padding disturbed:\n{text}"
7669 );
7670 }
7671
7672 #[test]
7673 fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
7674 // A drag into the empty space under a short document used to resolve to
7675 // offset 0 — the wrong direction, and not even a caret stop when the
7676 // document opens on hidden frontmatter (its `content_start` floor is not
7677 // a stop), which crashed the caret invariant. It now lands on the last
7678 // stop: the end of the document, where dragging downward should reach.
7679 let fm = "---\ntitle: n\n---\n";
7680 let m = map(&format!("{fm}# Hi\n\nbody\n"));
7681 let below = m.num_rows() + 5;
7682 let off = m.offset_of_pos(below, 0);
7683 assert!(
7684 m.is_stop(off),
7685 "offset {off} from a below-content click is not a stop"
7686 );
7687 assert_eq!(
7688 off,
7689 m.stops.last().copied().unwrap(),
7690 "should be the document's last stop"
7691 );
7692 assert!(
7693 off > fm.len(),
7694 "must not fall onto the hidden frontmatter floor"
7695 );
7696 }
7697
7698 #[test]
7699 fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
7700 // The invariant the caret motion asserts: whatever cell a click names,
7701 // the offset it resolves to is one the caret can actually rest at.
7702 for src in [
7703 "hello \n",
7704 "# A heading here \n\nbody text goes on \n",
7705 "---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
7706 ] {
7707 let m = map(src);
7708 for row in 0..m.num_rows() + 3 {
7709 for col in 0..30 {
7710 let off = m.offset_of_pos(row, col);
7711 assert!(
7712 m.is_stop(off),
7713 "row {row} col {col} → {off} is not a stop in {src:?}"
7714 );
7715 }
7716 }
7717 }
7718 }
7719
7720 /// `| Name | Qty |` with Name left-aligned and Qty right-aligned.
7721 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
7722
7723 #[test]
7724 fn a_table_renders_as_an_aligned_grid() {
7725 let text = rendered(&map(TABLE));
7726 assert_eq!(
7727 text,
7728 "┌──────┬─────┐\n\
7729 │ Name │ Qty │\n\
7730 ├──────┼─────┤\n\
7731 │ Pear │ 3 │\n\
7732 │ Fig │ 12 │\n\
7733 └──────┴─────┘",
7734 "got:\n{text}"
7735 );
7736 }
7737
7738 #[test]
7739 fn table_columns_honour_their_alignment() {
7740 // Centre and default(left) come straight from twig's cell.alignment —
7741 // the delimiter row it's spelled in is consumed and has no node.
7742 let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
7743 assert!(text.contains("│ x │ y │"), "centred column: {text:?}");
7744 }
7745
7746 #[test]
7747 fn table_borders_are_decoration_the_caret_never_lands_on() {
7748 let m = map(TABLE);
7749 // The top and header rules are whole decoration rows.
7750 for r in [0, 2] {
7751 assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
7752 assert!(
7753 !m.rows[r].glyphs.iter().any(|g| g.stop),
7754 "row {r} has a stop"
7755 );
7756 }
7757 // The bottom border is the exception: no glyph of it is a stop, but
7758 // its end is the table's trailing caret home — the one place the caret
7759 // can stand past the last cell.
7760 let bottom = &m.rows[5];
7761 assert!(
7762 !bottom.decoration,
7763 "the bottom border holds the trailing stop"
7764 );
7765 assert!(
7766 !bottom.glyphs.iter().any(|g| g.stop),
7767 "the bottom border's glyphs are not stops"
7768 );
7769 assert!(m.is_stop(bottom.end_src), "the trailing stop is a stop");
7770 assert!(m.table_end_stop(bottom.end_src));
7771 assert_eq!(
7772 bottom.end_src,
7773 TABLE.trim_end_matches('\n').len(),
7774 "the trailing stop is the table's own end, before its newline"
7775 );
7776 assert!(
7777 !m.table_end_stop(TABLE.rfind("12").unwrap() + 2),
7778 "a cell's end is not the trailing stop"
7779 );
7780 // A content row's `│` and padding are decoration; only the cell text
7781 // and each cell's one end-stop are stops.
7782 let header = &m.rows[1];
7783 assert!(!header.decoration);
7784 for g in &header.glyphs {
7785 if g.ch == '│' {
7786 assert!(!g.stop, "a border is not a caret stop");
7787 }
7788 }
7789 let stops: String = header
7790 .glyphs
7791 .iter()
7792 .filter(|g| g.stop)
7793 .map(|g| g.ch)
7794 .collect();
7795 assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
7796 }
7797
7798 #[test]
7799 fn a_cell_maps_to_its_own_source_text() {
7800 let m = map(TABLE);
7801 // "Pear" starts at byte 32 in TABLE; the caret there draws on the 'P'.
7802 let pear = TABLE.find("Pear").unwrap();
7803 let (r, c) = m.pos_of_offset(pear);
7804 assert_eq!(m.rows[r].glyphs[c].ch, 'P');
7805 assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
7806 }
7807
7808 #[test]
7809 fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
7810 // Columns wider than the surface used to run off the right edge, where
7811 // nothing could reach them. They're cut to the budget instead, and the
7812 // text wraps down inside the column — the header rule stays put, and
7813 // an alignment holds on every line of a wrapped cell, not just the first.
7814 let src = "| Ingredient | Notes |\n|---|---:|\n\
7815 | flour milled coarse | sift it twice |\n| salt | a pinch |\n";
7816 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7817 let m = build_t(&ed.nodes().unwrap(), src, Some(30));
7818 let text = rendered(&m);
7819 assert_eq!(
7820 text,
7821 "┌──────────────┬─────────────┐\n\
7822 │ Ingredient │ Notes │\n\
7823 ├──────────────┼─────────────┤\n\
7824 │ flour milled │ sift it │\n\
7825 │ coarse │ twice │\n\
7826 │ salt │ a pinch │\n\
7827 └──────────────┴─────────────┘",
7828 "got:\n{text}"
7829 );
7830 for (r, row) in m.rows.iter().enumerate() {
7831 assert!(
7832 row.glyphs.len() <= 30,
7833 "row {r} overflows: {}",
7834 row.glyphs.len()
7835 );
7836 }
7837 }
7838
7839 #[test]
7840 fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
7841 // A paragraph lets an overlong word trail off the end of the line; a
7842 // table column can't — a glyph past the border lands on the border.
7843 let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
7844 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7845 let m = build_t(&ed.nodes().unwrap(), src, Some(20));
7846 for (r, row) in m.rows.iter().enumerate() {
7847 assert!(
7848 row.glyphs.len() <= 20,
7849 "row {r} overflows: {}",
7850 row.glyphs.len()
7851 );
7852 }
7853 // Broken across lines, but whole: every letter is still drawn, at its
7854 // own source byte, where the caret can reach it.
7855 let word = "antidisestablishmentarianism";
7856 let at = src.find(word).unwrap();
7857 for (i, ch) in word.char_indices() {
7858 assert!(
7859 m.rows
7860 .iter()
7861 .flat_map(|r| r.glyphs.iter())
7862 .any(|g| g.stop && g.src == at + i && g.ch == ch),
7863 "{ch:?} at {} was lost to the break",
7864 at + i
7865 );
7866 }
7867 }
7868
7869 #[test]
7870 fn a_code_block_maps_each_line_to_its_own_source_text() {
7871 // Every glyph used to point at the block's start, which made the whole
7872 // block one offset — visible, but impossible to put a caret inside.
7873 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
7874 let m = map(src);
7875 for row in &m.rows {
7876 for g in row.glyphs.iter().filter(|g| g.stop) {
7877 assert_eq!(
7878 src[g.src..].chars().next(),
7879 Some(g.ch),
7880 "glyph {:?} at {} isn't the source byte it claims",
7881 g.ch,
7882 g.src
7883 );
7884 }
7885 }
7886 }
7887
7888 #[test]
7889 fn an_indented_code_block_maps_past_its_stripped_indent() {
7890 // twig strips the four-space indent, so `text` isn't a source slice and
7891 // the lines have to be re-found. Offsets land on the code, not the indent.
7892 let src = " indented\n code\n";
7893 let m = map(src);
7894 let stops: Vec<(char, usize)> = m
7895 .rows
7896 .iter()
7897 .flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
7898 .collect();
7899 assert_eq!(
7900 stops[0],
7901 ('i', 4),
7902 "first line should start past the indent"
7903 );
7904 assert!(
7905 stops.contains(&('c', 17)),
7906 "second line misplaced: {stops:?}"
7907 );
7908 }
7909
7910 #[test]
7911 fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
7912 // The one case that defeats a forward search: the opening fence
7913 // ```` ```rust ```` ends with the same text as the code under it.
7914 let src = "```rust\nrust\n```\n";
7915 let m = map(src);
7916 let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
7917 assert_eq!(first.src, 8, "matched the info string, not the code");
7918 }
7919
7920 #[test]
7921 fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
7922 // The old `▏ ` gutter is gone: a code row is the block prefix (none, at
7923 // the top level) plus the code text, and the whole run is named in
7924 // `code_blocks` so a frontend can box it.
7925 let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
7926 let m = map(src);
7927 assert_eq!(m.code_blocks.len(), 1, "one code block");
7928 let span = m.code_blocks[0].rows_span.clone();
7929 let rows: Vec<String> = m.rows[span.clone()]
7930 .iter()
7931 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7932 .collect();
7933 assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
7934 assert!(!rendered(&m).contains('▏'), "gutter still drawn");
7935 assert!(
7936 m.rows[span].iter().all(|r| r.code),
7937 "every row in the span is flagged code"
7938 );
7939 }
7940
7941 #[test]
7942 fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
7943 // `trim_end_matches('\n')` cut the block's terminator *and* the newline
7944 // that spells a trailing empty line, so the row the Return had just made
7945 // never appeared and the caret on it fell through to the block below.
7946 // Every empty line is a row, wherever in the block it falls.
7947 let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
7948 let m = map(src);
7949 let span = m.code_blocks[0].rows_span.clone();
7950 let rows: Vec<String> = m.rows[span.clone()]
7951 .iter()
7952 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7953 .collect();
7954 assert_eq!(
7955 rows,
7956 vec!["alpha".to_string(), "beta".to_string(), String::new()],
7957 "the empty last line gets a row"
7958 );
7959 assert!(
7960 m.rows[span.clone()].iter().all(|r| r.code),
7961 "the empty row is flagged code like the rest of the block"
7962 );
7963 // And it is the *source's* empty line, not a coarse fallback to the
7964 // block start: the offset the caret resolves to is the one Return made.
7965 let empty = span.end - 1;
7966 assert_eq!(
7967 m.rows[empty].end_src,
7968 src.find("beta\n\n").unwrap() + "beta\n".len(),
7969 "the empty row maps to the line the Return opened"
7970 );
7971
7972 // Nothing is invented where there is no empty line, and a second one is
7973 // a second row.
7974 assert_eq!(
7975 map("```\nalpha\nbeta\n```\n").code_blocks[0]
7976 .rows_span
7977 .len(),
7978 2,
7979 "a block that ends at its last code line keeps two rows"
7980 );
7981 assert_eq!(
7982 map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
7983 3,
7984 "two trailing empty lines are two rows"
7985 );
7986 }
7987
7988 #[test]
7989 fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
7990 // diaryx's `:::vis{.public .family}` visibility block, and any other
7991 // `:::name{.class}` fenced div — core is agnostic of `name`.
7992 let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
7993 let m = map_directives(src);
7994
7995 let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
7996 assert!(!content_rows.is_empty(), "some row is flagged directive");
7997
7998 let after_rows: Vec<usize> = (0..m.rows.len())
7999 .filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
8000 .collect();
8001 assert!(
8002 after_rows.iter().all(|&i| !m.rows[i].directive),
8003 "content outside the fence isn't tinted"
8004 );
8005
8006 let labels: Vec<&str> = content_rows
8007 .iter()
8008 .filter_map(|&i| m.rows[i].directive_label.as_deref())
8009 .collect();
8010 assert_eq!(
8011 labels,
8012 vec!["public family"],
8013 "only the first row carries the label"
8014 );
8015
8016 assert_eq!(
8017 rendered(&m)
8018 .lines()
8019 .filter(|l| !l.is_empty())
8020 .collect::<Vec<_>>(),
8021 vec!["hello", "world", "after"],
8022 "fence markers don't leak into the rendered text"
8023 );
8024 }
8025
8026 #[test]
8027 fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
8028 // diaryx_core::visibility's own `:::vis{public family}` — no leading
8029 // dots — is what apps/web's directive serializer and the native
8030 // publish-time filter both actually write today, distinct from twig's
8031 // `.class` convention. Both must label the same way so every existing
8032 // diaryx `:::vis{...}` block reads, not just newly dot-authored ones.
8033 let src = ":::vis{public family}\nhello\n:::\n";
8034 let m = map_directives(src);
8035 let label = m.rows.iter().find_map(|r| r.directive_label.clone());
8036 assert_eq!(label.as_deref(), Some("public family"));
8037 }
8038
8039 #[test]
8040 fn a_text_directive_keeps_its_paragraph_visible() {
8041 // Regression: an inline `:name[label]{…}` used to make its paragraph
8042 // fail the "all children inline" test, so the whole line was walked as
8043 // a container of blocks and rendered as empty rows with NO caret stops —
8044 // the text vanished from the editor and the caret couldn't enter it.
8045 // diaryx's inline `:vis[…]` is exactly this shape.
8046 let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
8047 let m = map_directives(src);
8048 assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
8049 // Every character of the line is a caret home, markup excluded — the
8050 // label reads as ordinary text, the way a link's does.
8051 let stops: usize = m
8052 .rows
8053 .iter()
8054 .map(|r| r.glyphs.iter().filter(|g| g.stop).count())
8055 .sum();
8056 assert_eq!(stops, "Text with HTML inline.".chars().count());
8057 // It is inline, so it is not the container form's tinted panel.
8058 assert!(m.rows.iter().all(|r| !r.directive));
8059 }
8060
8061 #[test]
8062 fn a_text_directives_label_maps_to_its_true_source_bytes() {
8063 // Regression (needs twig-doc >= 2.5.0): twig parses a `[label]` as a
8064 // detached slice, and until it rebased the enclosing scan's segments
8065 // onto it every node inside the label reported a span of `(0,0)`. Read
8066 // by anything that trusts a span that means "byte 0", so the label's
8067 // glyphs mapped to the START OF THE DOCUMENT — a click on the label put
8068 // the caret at the top of the file, its stops collided with the real
8069 // first line's, and an edit there landed on the wrong bytes entirely.
8070 //
8071 // The sibling test `a_text_directive_keeps_its_paragraph_visible` only
8072 // counts stops, which is exactly why this went unnoticed: the right
8073 // NUMBER of stops at completely wrong offsets.
8074 let src = "x :abbr[HTML]{title=\"y\"} z\n";
8075 let m = map_directives(src);
8076 let stops: Vec<(char, usize)> = m
8077 .rows
8078 .iter()
8079 .flat_map(|r| &r.glyphs)
8080 .filter(|g| g.stop)
8081 .map(|g| (g.ch, g.src))
8082 .collect();
8083 // `HTML` sits at 8..12. The name, brackets and `{…}` are hidden markup
8084 // the caret steps over, so the line's stops run 0, 1, 8..12, then 24.
8085 assert_eq!(
8086 stops,
8087 [
8088 ('x', 0),
8089 (' ', 1),
8090 ('H', 8),
8091 ('T', 9),
8092 ('M', 10),
8093 ('L', 11),
8094 (' ', 24),
8095 ('z', 25)
8096 ]
8097 );
8098 }
8099
8100 #[test]
8101 fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
8102 // The `every_glyph_points_at_its_source_byte` invariant, extended over
8103 // directive labels now that their offsets are real. Nested markup is
8104 // included: its delimiters are hidden, so the visible glyphs must skip
8105 // them and still name their own bytes.
8106 let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
8107 let m = map_directives(src);
8108 for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
8109 let at = src[g.src..].chars().next();
8110 assert_eq!(
8111 at,
8112 Some(g.ch),
8113 "glyph {:?} claims byte {}, which is {at:?}",
8114 g.ch,
8115 g.src
8116 );
8117 }
8118 assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
8119 }
8120
8121 #[test]
8122 fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
8123 let src = "x :abbr[a *b* c] y\n";
8124 let m = map_directives(src);
8125 let b = m
8126 .rows
8127 .iter()
8128 .flat_map(|r| &r.glyphs)
8129 .find(|g| g.ch == 'b')
8130 .expect("the emphasised char");
8131 assert!(b.style.italic, "the label's *b* lost its emphasis");
8132 assert_eq!(b.src, 11, "the label's *b* lost its source byte");
8133 }
8134
8135 #[test]
8136 fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
8137 // Regression: twig matches a colon followed by any letter-led word, so
8138 // ordinary prose is full of "text directives" nobody meant to write.
8139 // With no `[label]` there are no children, and the arm recursed into
8140 // them — rendering *nothing*. The word vanished from the document with
8141 // no caret stop left behind, so it could not even be deleted.
8142 for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
8143 let m = map_directives(src);
8144 assert_eq!(
8145 rendered(&m).trim_end(),
8146 src.trim_end(),
8147 "prose was eaten: {src:?}"
8148 );
8149 }
8150 }
8151
8152 #[test]
8153 fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
8154 let src = "a :word b\n";
8155 let m = map_directives(src);
8156 // Nothing here is markup, so nothing is hidden: each byte maps to
8157 // itself and can be stood on, which is what makes the colon deletable.
8158 let stops: Vec<(char, usize)> = m
8159 .rows
8160 .iter()
8161 .flat_map(|r| &r.glyphs)
8162 .filter(|g| g.stop)
8163 .map(|g| (g.ch, g.src))
8164 .collect();
8165 assert_eq!(
8166 stops,
8167 "a :word b"
8168 .chars()
8169 .enumerate()
8170 .map(|(i, c)| (c, i))
8171 .collect::<Vec<_>>()
8172 );
8173 }
8174
8175 #[test]
8176 fn an_attribute_bearing_text_directive_draws_a_chip() {
8177 // `{…}` is deliberate in a way a bare colon is not — diaryx writes
8178 // `:vis{.family}` inline — so this one reads as an embed, on the same
8179 // `⧉ label` recipe the leaf form's placeholder row uses.
8180 // Both attribute conventions label it: twig's dot-prefixed classes and
8181 // the bare pandoc-style words diaryx also writes.
8182 for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
8183 let m = map_directives(src);
8184 assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
8185 }
8186 // A `key=value` attr is configuration, not a name, so it adds nothing.
8187 let m = map_directives("a :foo{title=\"x\"} b\n");
8188 assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
8189 }
8190
8191 #[test]
8192 fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
8193 let src = "a :vis{.family} b\n";
8194 let m = map_directives(src);
8195 let stops: Vec<usize> = m
8196 .rows
8197 .iter()
8198 .flat_map(|r| &r.glyphs)
8199 .filter(|g| g.stop)
8200 .map(|g| g.src)
8201 .collect();
8202 // The chip contributes exactly one stop, at the directive's start (2),
8203 // so the caret steps over it whole instead of walking hidden markup a
8204 // byte at a time. `{.family}`'s bytes (3..15) are never stood on.
8205 assert_eq!(stops, [0, 1, 2, 15, 16]);
8206 }
8207
8208 #[test]
8209 fn a_paragraph_holding_only_a_chip_is_still_navigable() {
8210 // With no stop of its own the row would be unreachable — the caret
8211 // could never be put on the line to edit or delete the directive.
8212 let m = map_directives(":vis{.family}\n");
8213 assert!(
8214 m.row_is_navigable(0),
8215 "a chip-only paragraph has no caret home"
8216 );
8217 assert_eq!(
8218 m.offset_of_pos(0, 0),
8219 0,
8220 "its caret home isn't the directive's start"
8221 );
8222 }
8223
8224 #[test]
8225 fn a_ratio_or_a_clock_time_is_never_a_directive() {
8226 // twig requires a letter after the colon, so these stay prose — the
8227 // verbatim arm must not be reached for them at all.
8228 let src = "ratio 3:4 and 10:30\n";
8229 assert_eq!(
8230 rendered(&map_directives(src)).trim_end(),
8231 "ratio 3:4 and 10:30"
8232 );
8233 }
8234
8235 #[test]
8236 fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
8237 // `::name{…}` is a standalone block with no body — an embed, a table of
8238 // contents. It used to emit no rows at all: invisible, no caret home,
8239 // vertical motion crossing a void. Now it draws the image recipe's
8240 // placeholder and publishes what the host app needs to paint the real
8241 // thing.
8242 let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
8243 let m = map_directives(src);
8244
8245 let row = m
8246 .rows
8247 .iter()
8248 .position(|r| r.leaf_directive.is_some())
8249 .expect("a placeholder row");
8250 assert_eq!(
8251 m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
8252 "⧉ embed"
8253 );
8254 assert!(
8255 m.rows[row].glyphs.iter().any(|g| g.stop),
8256 "the caret can land on it"
8257 );
8258 assert!(
8259 m.rows[row].directive,
8260 "a frontend frames it like the container form"
8261 );
8262
8263 assert_eq!(m.directives.len(), 1);
8264 let info = &m.directives[0];
8265 assert_eq!(info.name, "embed");
8266 assert_eq!(info.rows_span, row..row + 1);
8267 assert_eq!(info.attr("src"), Some("demo.html"));
8268 assert_eq!(info.attr("height"), Some("400"));
8269 assert_eq!(info.attr("nope"), None);
8270 // The prose around it is untouched.
8271 assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
8272 }
8273
8274 #[test]
8275 fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
8276 // A `[label]` names the placeholder (the way an image's alt does), and a
8277 // quoted directive keeps the quote's gutter — it is a block like any
8278 // other, not a special case that escapes its container.
8279 let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
8280 assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
8281 assert_eq!(m.directives[0].label, "Audience demo");
8282
8283 let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
8284 assert_eq!(rendered("ed).trim_end(), "│ ⧉ embed");
8285 assert_eq!(quoted.directives[0].name, "embed");
8286 }
8287
8288 #[test]
8289 fn a_container_directive_is_still_a_panel_not_a_placeholder() {
8290 // The three forms must not bleed into each other: only the leaf form is
8291 // a placeholder, and only the container form tints the blocks it wraps.
8292 let m = map_directives(":::note{.warning}\nBody\n:::\n");
8293 assert!(
8294 m.directives.is_empty(),
8295 "a container publishes no placeholder"
8296 );
8297 assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
8298 assert_eq!(rendered(&m).trim_end(), "Body");
8299 assert!(
8300 m.rows
8301 .iter()
8302 .any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
8303 );
8304 }
8305
8306 /// A production-path build with both extensions on — the only way to put a
8307 /// promoted HTML element and a directive in one document, which is what the
8308 /// `container` kind made necessary to tell apart. Returns the whole `Doc`
8309 /// because [`VisualMap`] is not `Clone`; read `doc.vmap`.
8310 fn doc_built(src: &str) -> crate::Doc {
8311 let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
8312 doc.build_visual(80);
8313 doc
8314 }
8315
8316 /// Every `container` node in `src`, parsed the way production does (both
8317 /// extensions on), paired with what [`container_is_directive`] makes of it.
8318 fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
8319 let mut ed = Editor::new_ext(
8320 src.as_bytes(),
8321 Format::Markdown,
8322 twig::MarkdownExtensions {
8323 directives: true,
8324 html_elements: true,
8325 ..Default::default()
8326 },
8327 )
8328 .unwrap();
8329 ed.nodes()
8330 .unwrap()
8331 .iter()
8332 .filter(|n| n.kind == Kind::Container)
8333 .map(|n| {
8334 (
8335 n.name.clone().unwrap_or_default(),
8336 container_is_directive(n),
8337 n.directive_form,
8338 )
8339 })
8340 .collect()
8341 }
8342
8343 #[test]
8344 fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
8345 // twig 2.8 folded `div`/`span`/`directive`/`element` into one `container`
8346 // kind. `directive_form` reads as though it separates them and does not:
8347 // a block-level `<div>` reports `Some(DirectiveForm::Container)` exactly
8348 // as a `:::note` does. Trusting it would draw directive chrome — a tinted
8349 // panel, a `.class` audience label — on every pasted Slack/Docs div.
8350 for (src, name, want) in [
8351 (":::note{.a}\nbody\n:::\n", "note", true),
8352 ("::embed{src=x}\n", "embed", true),
8353 ("a :vis[hi]{.b} b\n", "vis", true),
8354 ("<div class=\"x\">\nhi\n</div>\n", "div", false),
8355 ("<video src=\"v.mp4\" controls></video>\n", "video", false),
8356 ("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
8357 ("<figure>\n\nhi\n\n</figure>\n", "figure", false),
8358 // The `:` in an attribute must not read as a directive opener: the
8359 // `<` of the tag comes first, and first one wins.
8360 (
8361 "<video src=\"http://x.test/v.mp4\" controls></video>\n",
8362 "video",
8363 false,
8364 ),
8365 (
8366 "<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
8367 "source",
8368 false,
8369 ),
8370 ] {
8371 let found = containers(src);
8372 let hit = found.iter().find(|(n, ..)| n == name);
8373 let Some((_, is_directive, form)) = hit else {
8374 panic!("no `{name}` container in {src:?} — found {found:?}");
8375 };
8376 assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
8377 }
8378
8379 // And the reason this can't just read the field: for the one collision
8380 // that matters, the field says the same thing for both.
8381 let div = containers("<div class=\"x\">\nhi\n</div>\n");
8382 let note = containers(":::note{.a}\nbody\n:::\n");
8383 assert_eq!(
8384 div[0].2, note[0].2,
8385 "if these ever differ, `directive_form` became usable and this rule can go"
8386 );
8387 }
8388
8389 #[test]
8390 fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
8391 // A container's span opens with its *block prefix*, not its own markup —
8392 // `> ::embed{…}` starts at the `>`. Reading only the first byte to tell a
8393 // directive from an element (both `container` since 2.8) therefore misses
8394 // every nested one, and the placeholder silently renders as nothing.
8395 for (src, ctx) in [
8396 ("> ::embed{src=\"x\"}\n", "quoted"),
8397 ("- ::embed{src=\"x\"}\n", "listed"),
8398 (">> ::embed{src=\"x\"}\n", "twice quoted"),
8399 ] {
8400 let m = map_directives(src);
8401 assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
8402 assert_eq!(m.directives[0].name, "embed", "{ctx}");
8403 }
8404 }
8405
8406 #[test]
8407 fn a_video_is_still_media_and_not_a_directive() {
8408 // The other side of the same coin: `<video>` is a `container` too, and
8409 // must reach `block_media` rather than the directive arms.
8410 let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
8411 assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
8412 assert!(
8413 doc.vmap.rows.iter().all(|r| !r.directive),
8414 "the video drew directive chrome"
8415 );
8416 }
8417
8418 #[test]
8419 fn a_directive_needs_the_extension_flag() {
8420 // `map` (twig's default extensions) leaves `directives` off — the fence
8421 // renders as literal paragraph text, same as any other unrecognized
8422 // punctuation, never corrupting or panicking.
8423 let src = ":::vis{.public}\nhello\n:::\n";
8424 let m = map(src);
8425 assert!(m.rows.iter().all(|r| !r.directive));
8426 assert!(rendered(&m).contains(":::vis{.public}"));
8427 }
8428
8429 #[test]
8430 fn a_footnote_reference_keeps_its_paragraph_visible() {
8431 // Regression: `footnote_reference` was in neither `is_inline_kind` nor
8432 // the inline walker, so a paragraph carrying one failed the "all children
8433 // inline" test, was walked as a container of blocks, and rendered as
8434 // empty rows with no caret stop anywhere — the whole line vanished.
8435 let src = "A claim[^1] and more.\n";
8436 let m = map(src);
8437 assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
8438 // The `^` is spelling, not text: hidden the way a link's `](dest)` is.
8439 assert!(!rendered(&m).contains('^'));
8440 }
8441
8442 #[test]
8443 fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
8444 // What makes `[1]` read as a reference rather than as bracketed text.
8445 // The brackets ride with the label: the chip is one raised mark.
8446 let m = map("A claim[^1] and more.\n");
8447 assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
8448 assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
8449 assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
8450 assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
8451 }
8452
8453 #[test]
8454 fn a_footnote_reference_keeps_the_link_role_it_had() {
8455 // The raised baseline is added to the role, not swapped for it: every
8456 // frontend already paints `Role::Link`, and a reference is one.
8457 let m = map("A claim[^1].\n");
8458 let label = m
8459 .rows
8460 .iter()
8461 .flat_map(|r| &r.glyphs)
8462 .find(|g| g.ch == '1')
8463 .unwrap();
8464 assert_eq!(label.style.role, Role::Link);
8465 assert_eq!(label.style.baseline, Baseline::Super);
8466 }
8467
8468 /// The [`Role`] of the first glyph spelling `ch` — how a test reads one
8469 /// run's styling off a map without caring which row it landed on.
8470 fn role_of(m: &VisualMap, ch: char) -> Role {
8471 m.rows
8472 .iter()
8473 .flat_map(|r| r.glyphs.iter())
8474 .find(|g| g.ch == ch)
8475 .unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
8476 .style
8477 .role
8478 }
8479
8480 #[test]
8481 fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
8482 // twig 3.3's `highlight`/`highlight_colors`, which `parse_extensions`
8483 // turns on for every leaf document: `==text==` is a `mark` in Markdown
8484 // and not the literal `==` it used to be, and `==🔴 text==` is one
8485 // carrying a colour.
8486 //
8487 // `doc_built` rather than `map`, deliberately — the extensions are
8488 // leaf's choice, not twig's default, so a test that parsed bare
8489 // Markdown here would be testing a document leaf never builds.
8490 let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
8491 assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
8492 assert_eq!(
8493 role_of(&doc.vmap, 'r'),
8494 Role::Mark(Some(MarkColor::Red)),
8495 "the `data-color` twig stripped the emoji into"
8496 );
8497 assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
8498 }
8499
8500 #[test]
8501 fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
8502 // The colour is *spelling*: twig strips the emoji out of the mark's
8503 // content, so the reader sees the words and the wash, never the circle.
8504 // Drawing it would put a character in the rendered text that the author
8505 // wrote as syntax — the same mistake as drawing an emphasis's `*`.
8506 let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
8507 let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8508 assert_eq!(drawn, "Plain yes and red ok");
8509 }
8510
8511 #[test]
8512 fn a_superscript_and_a_subscript_sit_off_the_baseline() {
8513 // Regression: both rendered flat, so the toolbar's superscript button
8514 // produced markup that looked exactly like the text around it.
8515 let m = map_djot("H~2~O and x^2^\n");
8516 assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
8517 assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
8518 assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
8519 }
8520
8521 #[test]
8522 fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
8523 // Why this is a `Baseline` and not a `Role`: raising a glyph says where
8524 // it sits, and must not cost it what it already was.
8525 let m = map_djot("# Heading x^2^\n");
8526 let two = m
8527 .rows
8528 .iter()
8529 .flat_map(|r| &r.glyphs)
8530 .find(|g| g.ch == '2')
8531 .unwrap();
8532 assert_eq!(two.style.baseline, Baseline::Super);
8533 assert_eq!(two.style.role, Role::Heading(1), "still heading text");
8534 }
8535
8536 #[test]
8537 fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
8538 let src = "see[^note] here\n";
8539 let m = map(src);
8540 // `[^note]` spans 3..10, its label `note` 5..9. The caret walks the
8541 // label; the brackets are drawn but never stood on, as a table's are,
8542 // and the `[^`/`]` bytes are stepped over like any hidden delimiter.
8543 let stops: Vec<usize> = m
8544 .rows
8545 .iter()
8546 .flat_map(|r| &r.glyphs)
8547 .filter(|g| g.stop)
8548 .map(|g| g.src)
8549 .collect();
8550 for off in 5..9 {
8551 assert!(
8552 stops.contains(&off),
8553 "label byte {off} isn't a caret stop: {stops:?}"
8554 );
8555 }
8556 for off in [3usize, 4, 9] {
8557 assert!(
8558 !stops.contains(&off),
8559 "delimiter byte {off} is a caret stop: {stops:?}"
8560 );
8561 }
8562 }
8563
8564 #[test]
8565 fn a_task_item_draws_its_box_where_the_bullet_would_be() {
8566 // Regression: the `[ ] ` is markup twig consumes — the item's paragraph
8567 // content starts past it — so a task item used to render as `• todo`,
8568 // identical to a plain bullet and with no way to see it was ticked.
8569 let m = map("- [ ] todo\n- [x] done\n- plain\n");
8570 assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
8571
8572 // The tick rides the item's first row, for a GUI that paints its own box.
8573 let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
8574 assert_eq!(ticks, [Some(false), Some(true), None]);
8575 }
8576
8577 #[test]
8578 fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
8579 let m = map_at(
8580 "- [x] a much longer task that has to wrap somewhere\n",
8581 Some(20),
8582 );
8583 assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
8584 assert_eq!(m.rows[0].task, Some(true));
8585 assert!(
8586 m.rows[1..].iter().all(|r| r.task.is_none()),
8587 "only the first row"
8588 );
8589 // The continuation lines hang under the box, not under column zero.
8590 assert!(
8591 rendered(&m)
8592 .lines()
8593 .nth(1)
8594 .is_some_and(|l| l.starts_with(" "))
8595 );
8596 }
8597
8598 #[test]
8599 fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
8600 // `task_checked` finds the box past the list marker; a plain item whose
8601 // text merely contains a bracket has none, and must keep its bullet.
8602 let m = map("- see [1] below\n");
8603 assert_eq!(rendered(&m), "• see [1] below");
8604 assert_eq!(m.rows[0].task, None);
8605 }
8606
8607 #[test]
8608 fn a_footnote_definition_renders_where_it_was_written() {
8609 // Regression: twig parses `[^1]: …` as a root *beside* `doc` — not a
8610 // child of it — so the walk from `doc` never reached one and every byte
8611 // of the note's body rendered as nothing at all.
8612 let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
8613 let m = map(src);
8614 let text = rendered(&m);
8615 assert!(
8616 text.contains("The note body."),
8617 "the note body is invisible: {text:?}"
8618 );
8619 // In source order — between the paragraph that cites it and the one
8620 // after — not hoisted to the end, and marked to match its reference.
8621 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
8622 assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
8623 }
8624
8625 #[test]
8626 fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
8627 let src = "x[^a].\n\n[^a]: body\n";
8628 let m = map(src);
8629 // `body` sits at 14..18. Its glyphs must map there — a marker that ate
8630 // the offsets would put the caret in the wrong place on every click.
8631 let body: Vec<(char, usize)> = m
8632 .rows
8633 .iter()
8634 .flat_map(|r| &r.glyphs)
8635 .filter(|g| g.stop && g.src >= 14)
8636 .map(|g| (g.ch, g.src))
8637 .collect();
8638 assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
8639 }
8640
8641 #[test]
8642 fn an_empty_footnote_definition_still_shows_its_marker() {
8643 // The instant `[^1]: ` has been typed and nothing after it. `blocks`
8644 // renders no child, so without the explicit marker row the definition
8645 // wouldn't appear at all until something was typed into it.
8646 let src = "x[^1]\n\n[^1]:\n";
8647 let m = map(src);
8648 assert!(
8649 rendered(&m).contains("[1] "),
8650 "no marker row: {:?}",
8651 rendered(&m)
8652 );
8653 }
8654
8655 #[test]
8656 fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
8657 let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
8658 let m = map_at(src, Some(24));
8659 let text = rendered(&m);
8660 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
8661 // Continuation lines hang under the marker, as a list item's do — the
8662 // indent is the marker's own width, not a fixed one.
8663 assert_eq!(lines[1].trim_end(), "[src] one two three four");
8664 assert!(
8665 lines[2].starts_with(" "),
8666 "body doesn't hang: {:?}",
8667 lines[2]
8668 );
8669 assert_eq!(lines[2].trim(), "five six seven");
8670 }
8671
8672 #[test]
8673 fn a_code_block_leaves_exactly_one_blank_row_below_it() {
8674 // The closing fence line used to be miscounted as a blank separator,
8675 // opening a phantom second gap under the block. One block boundary is
8676 // one blank row, code block or not.
8677 let src = "para\n\n```\ncode\n```\n\nafter\n";
8678 let m = map(src);
8679 let code_end = m.code_blocks[0].rows_span.end;
8680 let after = m
8681 .rows
8682 .iter()
8683 .position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
8684 .unwrap();
8685 assert_eq!(
8686 after - code_end,
8687 1,
8688 "exactly one row between code and 'after'"
8689 );
8690 }
8691
8692 #[test]
8693 fn a_fenced_block_publishes_its_language_on_its_code_block() {
8694 // The info string becomes the block's label; a bare fence and an indented
8695 // block carry none.
8696 assert_eq!(
8697 map("```rust\nlet x = 1;\n```\n").code_blocks[0]
8698 .lang
8699 .as_deref(),
8700 Some("rust")
8701 );
8702 assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
8703 assert_eq!(map(" indented\n").code_blocks[0].lang, None);
8704 }
8705
8706 fn lang_of(src: &str) -> Option<String> {
8707 map(src).code_blocks[0].lang.clone()
8708 }
8709
8710 #[test]
8711 fn a_fence_in_a_quote_has_its_language() {
8712 // The block's span starts at the line, `> ` and all; the fence is past
8713 // the quote marker, and so is its info string.
8714 let src = "> ```rust\n> let x = 1;\n> ```\n";
8715 assert_eq!(lang_of(src).as_deref(), Some("rust"));
8716 let info = code_info_span(src, 0).unwrap();
8717 assert_eq!(&src[info], "rust", "the info string's exact bytes");
8718 assert_eq!(
8719 lang_of("> > ~~~ py\n> > x\n> > ~~~\n").as_deref(),
8720 Some("py")
8721 );
8722 }
8723
8724 #[test]
8725 fn a_fence_in_a_list_item_has_its_language() {
8726 // On the item's own line, past its marker…
8727 assert_eq!(
8728 lang_of("- ```rust\n let x = 1;\n ```\n").as_deref(),
8729 Some("rust")
8730 );
8731 assert_eq!(
8732 lang_of("10. ```rust\n let x = 1;\n ```\n").as_deref(),
8733 Some("rust")
8734 );
8735 // …and on a line of its own inside the item, where the indent is the
8736 // item's content column and not the fence's.
8737 assert_eq!(
8738 lang_of("10. a\n\n ```rust\n let x = 1;\n ```\n").as_deref(),
8739 Some("rust")
8740 );
8741 assert_eq!(
8742 lang_of("- a\n - b\n\n ```rust\n x\n ```\n").as_deref(),
8743 Some("rust")
8744 );
8745 // In a list in a quote.
8746 assert_eq!(
8747 lang_of("> - a\n>\n> ```rust\n> x\n> ```\n").as_deref(),
8748 Some("rust")
8749 );
8750 }
8751
8752 #[test]
8753 fn an_indented_block_in_a_list_item_still_has_no_language() {
8754 // Four spaces past the item's content column is an indented code
8755 // block, whatever its text looks like — the allowance is measured from
8756 // the item, not dropped.
8757 let src = "- a\n\n ```rust\n";
8758 assert_eq!(map(src).code_blocks.len(), 1);
8759 assert_eq!(lang_of(src), None);
8760 assert_eq!(lang_of("- a\n\n indented\n"), None);
8761 }
8762
8763 /// The token every glyph spelling `ch` carries, in row order — how a test
8764 /// reads a block's highlighting off the map.
8765 fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
8766 m.rows
8767 .iter()
8768 .flat_map(|r| r.glyphs.iter())
8769 .filter(|g| g.ch == ch)
8770 .map(|g| g.style.token)
8771 .collect()
8772 }
8773
8774 #[cfg(feature = "syntax")]
8775 #[test]
8776 fn a_fenced_block_in_a_known_language_carries_tokens() {
8777 // `let` is a keyword, the string literal a string, and the plain
8778 // identifier `x` nothing at all — it draws in the code colour. Every
8779 // glyph is still `Role::Code`: a token is beside the role, not instead.
8780 let m = map("```rust\nlet x = \"s\";\n```\n");
8781 assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
8782 assert_eq!(tokens_of(&m, 'x'), vec![None]);
8783 assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
8784 assert!(
8785 m.rows
8786 .iter()
8787 .filter(|r| r.code)
8788 .flat_map(|r| r.glyphs.iter())
8789 .all(|g| g.style.role == Role::Code),
8790 "a token replaced the code role"
8791 );
8792 }
8793
8794 #[cfg(feature = "syntax")]
8795 #[test]
8796 fn a_token_changes_nothing_about_where_a_glyph_is() {
8797 // The same block with and without a language it can be highlighted in
8798 // lays out identically: same rows, same offsets, same stops. Only the
8799 // token differs, so the caret walks a highlighted block as it walked an
8800 // unhighlighted one.
8801 let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
8802 let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
8803 assert_eq!(hl.rows.len(), plain.rows.len());
8804 for (a, b) in hl.rows.iter().zip(&plain.rows) {
8805 assert_eq!(a.end_src, b.end_src);
8806 assert_eq!(a.glyphs.len(), b.glyphs.len());
8807 for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
8808 assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
8809 assert_eq!(ga.style.token(None), gb.style);
8810 }
8811 }
8812 assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
8813 assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
8814 }
8815
8816 #[test]
8817 fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
8818 // A bare fence, an indented block, a fence in a language no grammar
8819 // covers, and inline code all draw as plain code — and so does a
8820 // `rust` fence when the `syntax` feature is off.
8821 for src in [
8822 "```\nlet x = 1;\n```\n",
8823 " let x = 1;\n",
8824 "```no-such-language\nlet x = 1;\n```\n",
8825 "a `let x` b\n",
8826 ] {
8827 assert!(
8828 tokens_of(&map(src), 'l').iter().all(Option::is_none),
8829 "{src:?} was highlighted"
8830 );
8831 }
8832 #[cfg(not(feature = "syntax"))]
8833 assert!(
8834 tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
8835 .iter()
8836 .all(Option::is_none)
8837 );
8838 }
8839
8840 #[test]
8841 fn inline_code_is_not_a_code_block() {
8842 // A `code` span inside prose is styled by role, not boxed: it's part of a
8843 // normal paragraph row, so it names no `code_blocks` entry.
8844 let m = map("a `snippet` b\n");
8845 assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
8846 assert!(
8847 m.rows.iter().all(|r| !r.code),
8848 "inline code flagged a code row"
8849 );
8850 }
8851
8852 #[test]
8853 fn caret_steps_over_hidden_delimiters() {
8854 // "a **bold** c": bytes 8,9 are the closing ** — no glyph. Moving right
8855 // from 'd' (src 7) lands on the space before 'c' (src 10), not inside **.
8856 let m = map("a **bold** c\n");
8857 let (r, c) = m.pos_of_offset(7);
8858 assert_eq!(m.offset_of_pos(r, c + 1), 10);
8859 }
8860
8861 // ── the structural view of a table ───────────────────────────────────────
8862
8863 #[test]
8864 fn a_table_is_published_structurally_beside_its_picture() {
8865 let m = map(TABLE);
8866 let t = &m.tables[0];
8867 let cell = |r: usize, c: usize| -> String {
8868 t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
8869 };
8870 assert_eq!(t.grid.len(), 3, "head + two body rows");
8871 assert_eq!(
8872 (cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
8873 ("Name".into(), "Qty".into(), "Pear".into(), "12".into())
8874 );
8875 assert_eq!(
8876 t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
8877 [true, false, false]
8878 );
8879 // The alignment the delimiter row spelled, carried per cell — the only
8880 // place it survives, since the parser consumes that row.
8881 assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
8882 assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
8883 }
8884
8885 #[test]
8886 fn a_block_media_is_published_structurally_beside_its_placeholder() {
8887 let m = map("intro\n\n\n\nend\n");
8888 assert_eq!(m.media.len(), 1, "one block image");
8889 let img = &m.media[0];
8890 assert_eq!(img.destination, "img/cat.png");
8891 assert_eq!(img.alt, "a cat");
8892 // The placeholder row named by `rows_span` carries the label a plain
8893 // surface paints and a capable frontend replaces.
8894 let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
8895 assert_eq!(
8896 img.rows_span.end - img.rows_span.start,
8897 1,
8898 "one placeholder row"
8899 );
8900 assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
8901 // The row carries the mark `media_spans` derives the side-table from.
8902 assert!(m.rows[img.rows_span.start].media.is_some());
8903 }
8904
8905 #[test]
8906 fn an_image_without_alt_labels_itself_with_its_filename() {
8907 let m = map("\n");
8908 let row = &m.rows[m.media[0].rows_span.start];
8909 assert_eq!(
8910 row.glyphs.iter().map(|g| g.ch).collect::<String>(),
8911 "🖼 beach.jpg"
8912 );
8913 assert_eq!(m.media[0].alt, "");
8914 }
8915
8916 #[test]
8917 fn an_empty_cells_home_is_read_from_either_shape_of_span() {
8918 // A whole-row span: the cell's pipes are the `col`-th and next.
8919 let row = "| | |";
8920 assert_eq!(empty_cell_offset(row, 10, 0), 12);
8921 assert_eq!(empty_cell_offset(row, 10, 1), 15);
8922 // A cell's own span, opening pipe to closing pipe exclusive: the same
8923 // homes, each read from its own span.
8924 assert_eq!(empty_cell_offset("| ", 10, 0), 12);
8925 assert_eq!(empty_cell_offset("| ", 13, 1), 15);
8926 // Nothing to stand in: just inside the pipe, never past the span.
8927 assert_eq!(empty_cell_offset("|", 10, 0), 11);
8928 assert_eq!(empty_cell_offset("", 10, 1), 10);
8929 }
8930
8931 #[test]
8932 fn a_hidden_marks_content_end_is_a_caret_home_but_not_a_glyph_stop() {
8933 // `a **bold** b`: the `d` is at 7, the content ends at 8, the closing
8934 // `**` draws nothing, and the space after it is at 10. Two homes at one
8935 // spot on screen: 8 (inside the bold) and 10 (past it).
8936 let m = map("a **bold** b\n");
8937 assert!(
8938 !m.stops.contains(&8),
8939 "8 has no glyph, so it is no glyph stop"
8940 );
8941 assert_eq!(m.mark_ends, vec![8]);
8942 assert!(m.is_stop(8), "but the caret may rest there");
8943 assert_eq!(m.snap_to_stop(8), 8, "and is left there when placed there");
8944 // Left/Right take both homes; the character-pairing walk takes one.
8945 assert_eq!(m.caret_stop_after(7), Some(8));
8946 assert_eq!(m.caret_stop_after(8), Some(10));
8947 assert_eq!(m.caret_stop_before(10), Some(8));
8948 assert_eq!(m.caret_stop_before(8), Some(7));
8949 assert_eq!(m.stop_after(7), Some(10));
8950 assert_eq!(m.stop_before(10), Some(7));
8951 // Drawn where the next glyph is: after the `d`, not on it.
8952 assert_eq!(m.pos_of_offset(8), m.pos_of_offset(10));
8953 }
8954
8955 #[test]
8956 fn every_hidden_inline_mark_gives_its_content_end_a_home() {
8957 // One end per mark, whatever it is spelled with; nested marks closing
8958 // together share the outer's end and the inner's alike.
8959 assert_eq!(
8960 map("*em* `code` [link](u) ~~del~~\n").mark_ends,
8961 vec![3, 10, 17, 27]
8962 );
8963 assert_eq!(map("***both***\n").mark_ends, vec![7]);
8964 // A mark that closes at its row's end coincides with the row's own end
8965 // stop — one offset, in both tables.
8966 let m = map("**bold**\n");
8967 assert_eq!(m.mark_ends, vec![6]);
8968 assert!(m.stops.contains(&6));
8969 // Revealed, the delimiter is glyphs of its own and the end is an
8970 // ordinary glyph stop: nothing to add.
8971 let mut ed = Editor::new_str("a **bold** b\n", Format::Markdown).unwrap();
8972 let src = "a **bold** b\n";
8973 let revealed = build(
8974 &ed.nodes().unwrap(),
8975 src,
8976 Some(80),
8977 false,
8978 &Surface::default(),
8979 Some(Reveal::full(0..src.len())),
8980 );
8981 assert!(revealed.mark_ends.is_empty());
8982 assert!(revealed.stops.contains(&8));
8983 }
8984
8985 #[test]
8986 fn a_marks_content_end_is_a_home_inside_a_table_cell() {
8987 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
8988 let m = map(src);
8989 let end = src.find("bold").unwrap() + 4; // 32, before the closing `**`
8990 assert_eq!(m.mark_ends, vec![end]);
8991 assert_eq!(m.snap_to_stop(end), end);
8992 // Drawn after the `d`, in this cell — where the cell's own end stop is.
8993 assert_eq!(m.pos_of_offset(end), m.pos_of_offset(end + 2));
8994 }
8995
8996 #[test]
8997 fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
8998 // `` on its own line: the caret can rest in front of the image
8999 // (its start) and just past it (the row end), and nowhere inside the
9000 // markup — the same coarse mapping a thematic break uses.
9001 let src = "\n";
9002 let m = map(src);
9003 let img = &m.rows[m.media[0].rows_span.start];
9004 let start = 0; // the image opens the document
9005 let end = "".len();
9006 // Every placeholder glyph maps to the image start and is a stop there.
9007 assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
9008 assert_eq!(img.end_src, end, "the row ends past the image");
9009 assert_eq!(m.stops.first(), Some(&start));
9010 assert!(m.stops.contains(&end), "a stop sits after the image");
9011 // Nothing inside the markup is a stop.
9012 assert!(!m.stops.iter().any(|&s| s > start && s < end));
9013 }
9014
9015 #[test]
9016 fn an_inline_image_amid_text_is_not_a_block_media() {
9017 // An image sharing its line with prose isn't block-level: it stays in the
9018 // inline path (rendered as its alt text), and publishes no MediaInfo.
9019 let m = map("see  here\n");
9020 assert!(m.media.is_empty(), "not a block image");
9021 assert!(
9022 rendered(&m).contains("a cat"),
9023 "alt text still renders inline"
9024 );
9025 }
9026
9027 /// The block images `Doc` publishes for `src`, driven through the real
9028 /// production build (`build_visual` → `build_cached`) with `html_elements`
9029 /// on — the path a `<picture>` actually travels. Not the raw `build` the
9030 /// other tests use: the editor's flat whole-arena snapshot tangles the links
9031 /// of inline-promoted HTML (phantom roots, dangling `parent`s), which only
9032 /// the per-block subtree walk `build_cached` does untangles.
9033 fn doc_media(src: &str) -> Vec<MediaInfo> {
9034 let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
9035 doc.build_visual(80);
9036 doc.vmap.media.clone()
9037 }
9038
9039 #[test]
9040 fn a_video_block_is_media_with_its_src_poster_and_kind() {
9041 // The load-bearing assumption of video support: twig has no `video` node
9042 // kind, so `html_elements` promotion must land a `<video>` as a generic
9043 // `element` whose tag name and attributes survive onto `FlatNode` — the
9044 // same treatment `<picture>` gets. If that ever stops holding, this is
9045 // the test that says so.
9046 let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
9047 assert_eq!(m.len(), 1, "the video is one block media");
9048 assert_eq!(m[0].kind, MediaKind::Video);
9049 assert_eq!(m[0].destination, "clip.mp4");
9050 assert_eq!(m[0].poster, "still.png");
9051 }
9052
9053 #[test]
9054 fn a_single_line_video_is_a_block_too() {
9055 // The spelling everyone actually writes. It used to parse as a paragraph
9056 // of raw inline HTML — CommonMark opens a block on a complete tag only
9057 // when the line ends there, and its fixed tag list predates `<video>` —
9058 // so the tags never reached core as an element at all. twig 2.5.1 widened
9059 // that list under `html_elements`; this is the test that would catch the
9060 // pin sliding back.
9061 let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
9062 assert_eq!(m.len(), 1, "single-line <video> is a block");
9063 assert_eq!(m[0].kind, MediaKind::Video);
9064 assert_eq!(m[0].destination, "clip.mp4");
9065 }
9066
9067 #[test]
9068 fn a_single_line_picture_is_a_block_with_its_alternatives() {
9069 // `<picture>` had the identical gap and it went unnoticed because the
9070 // conventional spelling breaks the lines. Same twig fix covers it.
9071 let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
9072 <img src=\"l.svg\" alt=\"banner\"></picture>\n";
9073 let m = doc_media(src);
9074 assert_eq!(m.len(), 1);
9075 assert_eq!(m[0].kind, MediaKind::Image);
9076 assert_eq!(m[0].destination, "l.svg");
9077 assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
9078 }
9079
9080 #[test]
9081 fn an_audio_block_is_media_with_no_poster() {
9082 let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
9083 assert_eq!(m.len(), 1);
9084 assert_eq!(m[0].kind, MediaKind::Audio);
9085 assert_eq!(m[0].destination, "take.mp3");
9086 assert!(m[0].poster.is_empty(), "audio has no poster frame");
9087 }
9088
9089 #[test]
9090 fn a_videos_source_children_are_its_candidates_typed_by_mime() {
9091 // A `<video>` with no `src` of its own — the common shape, since it's how
9092 // you offer more than one codec. The candidates come from `<source src>`
9093 // (not `srcset`, which is `<picture>`'s spelling) and carry their MIME.
9094 let src = "<video controls>\n\
9095 <source src=\"a.webm\" type=\"video/webm\">\n\
9096 <source src=\"a.mp4\" type=\"video/mp4\">\n\
9097 fallback\n\
9098 </video>\n";
9099 let m = doc_media(src);
9100 assert_eq!(m.len(), 1);
9101 assert!(
9102 m[0].destination.is_empty(),
9103 "no src attribute on the element"
9104 );
9105 assert_eq!(m[0].sources.len(), 2);
9106 assert_eq!(m[0].sources[0].srcset, "a.webm");
9107 assert_eq!(m[0].sources[0].mime, "video/webm");
9108 assert_eq!(m[0].sources[1].srcset, "a.mp4");
9109 // With an empty destination, `resolve` falls through to the first
9110 // candidate rather than handing the frontend nothing to load.
9111 assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
9112 }
9113
9114 #[test]
9115 fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
9116 // The placeholder contract images already hold, now for a video: the row
9117 // renders as a labelled stand-in a plain surface can paint as-is, and
9118 // carries the mark a capable frontend replaces it from.
9119 let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
9120 let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
9121 doc.build_visual(80);
9122 let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
9123 let text: String = row.glyphs.iter().map(|g| g.ch).collect();
9124 assert!(
9125 text.starts_with('🎬'),
9126 "video sigil, not the image one: {text:?}"
9127 );
9128 assert!(row.media.is_some(), "the mark rides the placeholder row");
9129 }
9130
9131 #[test]
9132 fn a_picture_block_carries_its_source_alternatives() {
9133 // A `<picture>` with a dark-mode `<source>`: one block image, whose
9134 // fallback destination is the `<img>` and whose `sources` carry the
9135 // `<source>`'s media + srcset for a theme-aware frontend to pick.
9136 let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
9137 let images = doc_media(src);
9138 assert_eq!(images.len(), 1, "the picture is one block image");
9139 let img = &images[0];
9140 assert_eq!(img.destination, "light.svg", "fallback is the <img>");
9141 assert_eq!(img.alt, "banner");
9142 assert_eq!(
9143 img.sources,
9144 vec![MediaSource {
9145 media: "(prefers-color-scheme: dark)".into(),
9146 srcset: "dark.svg".into(),
9147 mime: String::new(),
9148 }],
9149 );
9150 }
9151
9152 #[test]
9153 fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
9154 // fig.md's shape: the banner is an `<h1>` wrapping the `<picture>`.
9155 let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
9156 let images = doc_media(src);
9157 assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
9158 assert_eq!(images[0].destination, "l.svg");
9159 assert_eq!(images[0].sources.len(), 1);
9160 assert_eq!(images[0].sources[0].srcset, "d.svg");
9161 }
9162
9163 #[test]
9164 fn a_plain_image_has_no_media_sources() {
9165 // A bare Markdown image carries an empty `sources` — nothing to pick from.
9166 let images = doc_media("\n");
9167 assert_eq!(images.len(), 1);
9168 assert!(
9169 images[0].sources.is_empty(),
9170 "no <picture>, no alternatives"
9171 );
9172 }
9173
9174 #[test]
9175 fn resolve_picks_the_source_matching_the_scheme() {
9176 let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
9177 let images = doc_media(src);
9178 let img = &images[0];
9179 // Dark theme takes the dark source; light falls through to the <img>.
9180 assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
9181 assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
9182 }
9183
9184 #[test]
9185 fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
9186 // A plain image ignores the scheme.
9187 let plain = doc_media("\n");
9188 assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
9189
9190 // A <source> with an unrecognized media query is skipped; a light source
9191 // is taken under a light theme.
9192 let m = doc_media(
9193 "<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
9194 );
9195 assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
9196 assert_eq!(
9197 m[0].resolve(ColorScheme::Dark),
9198 "f.svg",
9199 "no dark source → <img>"
9200 );
9201 }
9202
9203 #[test]
9204 fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
9205 // A comma/descriptor srcset resolves to its first URL.
9206 assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
9207 assert_eq!(first_srcset_url(" solo.svg "), Some("solo.svg"));
9208 assert_eq!(first_srcset_url(""), None);
9209 // An empty (unconditional) media always matches.
9210 assert!(media_matches("", ColorScheme::Light));
9211 assert!(media_matches(
9212 "(prefers-color-scheme:dark)",
9213 ColorScheme::Dark
9214 ));
9215 assert!(!media_matches(
9216 "(prefers-color-scheme: dark)",
9217 ColorScheme::Light
9218 ));
9219 }
9220
9221 #[test]
9222 fn a_block_media_carries_its_list_prefix() {
9223 // An image that is a list item's body opens past the bullet, like every
9224 // other block does.
9225 let m = map("- \n");
9226 let row = &m.rows[m.media[0].rows_span.start];
9227 let text: String = row.glyphs.iter().map(|g| g.ch).collect();
9228 assert!(
9229 text.starts_with("• "),
9230 "the list marker prefixes the image row: {text:?}"
9231 );
9232 assert!(text.contains("🖼 alt"));
9233 }
9234
9235 #[test]
9236 fn the_structural_table_spans_exactly_its_drawn_rows() {
9237 // A frontend drawing its own grid skips `rows_span` and renders from
9238 // `grid`. If the span were short the leftover border rows would be
9239 // painted as text under the real table; if long it would eat a
9240 // neighbouring paragraph. Both are silent, so pin it to the picture.
9241 let m = map(&format!("before\n\n{TABLE}\nafter\n"));
9242 let t = &m.tables[0];
9243 let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
9244 assert!(
9245 row_text(t.rows_span.start).starts_with('┌'),
9246 "opens on the top border"
9247 );
9248 assert!(
9249 row_text(t.rows_span.end - 1).starts_with('└'),
9250 "closes on the bottom border"
9251 );
9252 assert!(
9253 !row_text(t.rows_span.start - 1).contains('┌'),
9254 "the row before the span is not the table's"
9255 );
9256 assert_eq!(
9257 row_text(t.rows_span.end),
9258 "",
9259 "the span ends before the gap row"
9260 );
9261 }
9262
9263 #[test]
9264 fn a_nested_tables_structure_carries_the_block_prefix() {
9265 // The picture puts the quote's gutter on every row of the grid. A
9266 // frontend drawing its own table has to draw that too and start past it,
9267 // so the prefix has to travel with the structure — without it a quoted
9268 // table renders flush at the margin and leaves the quote it's in.
9269 let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
9270 let t = &m.tables[0];
9271 let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
9272 assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
9273 // And it matches what the picture actually drew.
9274 let drawn: String = m.rows[t.rows_span.start]
9275 .glyphs
9276 .iter()
9277 .map(|g| g.ch)
9278 .collect();
9279 assert!(
9280 drawn.starts_with(&prefix),
9281 "picture and structure disagree: {drawn:?}"
9282 );
9283 }
9284
9285 #[test]
9286 fn a_top_level_table_carries_no_prefix() {
9287 assert!(map(TABLE).tables[0].prefix.is_empty());
9288 }
9289
9290 #[test]
9291 fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
9292 // The picture wraps a cell to its column; a frontend laying the grid out
9293 // in pixels needs the text as the document spells it, before that
9294 // decision. Narrow enough that the drawn cell must break.
9295 let src = "| Name |\n|------|\n| alpha beta gamma |\n";
9296 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
9297 let m = build_t(&ed.nodes().unwrap(), src, Some(12));
9298 let drawn = rendered(&m);
9299 let cell: String = m.tables[0].grid[1].cells[0]
9300 .glyphs
9301 .iter()
9302 .map(|g| g.ch)
9303 .collect();
9304 assert_eq!(
9305 cell, "alpha beta gamma",
9306 "structure must not carry the wrap"
9307 );
9308 assert!(
9309 drawn.lines().count() > 5,
9310 "the picture should have wrapped, else this proves nothing:\n{drawn}"
9311 );
9312 }
9313
9314 // ── display columns ──────────────────────────────────────────────────────
9315
9316 #[test]
9317 fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
9318 // A column sized by counting characters is drawn narrower than the text
9319 // it has to hold — `你好` is two characters in four cells — and the cell
9320 // spills over the border it is supposed to sit inside, taking the whole
9321 // grid out of square with it. Squareness is the property: every row of a
9322 // grid is drawn to the same column, whatever its cells are spelled with.
9323 for src in [
9324 "| A | B |\n|---|---|\n| 你好 | y |\n",
9325 "| A | B |\n|---|---|\n| a👨👩👧b | y |\n",
9326 "| A | 漢字 |\n|---|---|\n| x | y |\n",
9327 ] {
9328 let m = map(src);
9329 let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
9330 assert!(
9331 widths.windows(2).all(|w| w[0] == w[1]),
9332 "ragged grid {widths:?} for {src:?}:\n{}",
9333 rendered(&m)
9334 );
9335 }
9336 }
9337
9338 #[test]
9339 fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
9340 // A column too narrow for its cell hard-breaks the text, and every line
9341 // of it is given an end stop just past its last glyph. Broken into runs
9342 // of four glyphs, the first line of this cell ends between `👨👩` and the
9343 // joiner holding `👧` on — so its end stop lands inside a character,
9344 // where a click or Down can reach it and the next Backspace takes the
9345 // cluster apart from the middle.
9346 let src = "| A |\n|---|\n| 👨👩👧👨👩👧 |\n";
9347 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
9348 let m = build_t(&ed.nodes().unwrap(), src, Some(8));
9349 let boundaries: Vec<usize> = src
9350 .grapheme_indices(true)
9351 .map(|(i, _)| i)
9352 .chain(std::iter::once(src.len()))
9353 .collect();
9354 for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
9355 assert!(
9356 boundaries.contains(&off),
9357 "stop at {off} is inside a character:\n{}",
9358 rendered(&m)
9359 );
9360 }
9361 }
9362
9363 #[test]
9364 fn a_wrapped_cell_keeps_every_line_inside_its_column() {
9365 // The width is a promise in a table, where a glyph past the column lands
9366 // on the border or in the next cell — and it is a promise about cells,
9367 // which is not what a count of glyphs measures.
9368 let src = "| A |\n|---|\n| 你好世界漢字 |\n";
9369 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
9370 let m = build_t(&ed.nodes().unwrap(), src, Some(14));
9371 for r in &m.rows {
9372 assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
9373 }
9374 }
9375
9376 #[test]
9377 fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
9378 let glyphs = |s: &str| {
9379 let mut out = Vec::new();
9380 push_text(&mut out, s, 0, Style::default());
9381 out
9382 };
9383 let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
9384
9385 // Six cells of CJK broken at four: two characters, then one — never
9386 // between the two cells of `好`.
9387 let w = glyphs("你好世");
9388 let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
9389 assert_eq!(pieces, ["你好", "世"]);
9390
9391 // A character wider than the column has nowhere legal to break, so it
9392 // keeps its cells rather than being cut in half.
9393 let w = glyphs("你好");
9394 let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
9395 assert_eq!(pieces, ["你", "好"]);
9396
9397 // An empty word yields no pieces at all — a double space stays a space.
9398 assert!(hard_break(&[], 4).is_empty());
9399 }
9400
9401 #[test]
9402 fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
9403 // Pressing Enter at the end of a list item opens a new, empty item —
9404 // a childless `list_item`. Without a row of its own the new bullet
9405 // wouldn't appear until something was typed into it (the caret would be
9406 // stranded on an offset no row draws). It now renders as one prefixed
9407 // row whose end is a caret stop, so the bullet shows and the caret lands
9408 // just past the marker.
9409 let m = map("- item\n- \n");
9410 assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
9411 assert_eq!(
9412 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
9413 "• ",
9414 "the empty item draws just its bullet",
9415 );
9416 // Its end is the caret home (past the `- ` marker), and it's a real stop.
9417 assert!(
9418 m.is_stop(m.rows[1].end_src),
9419 "the empty item's caret home is not a stop"
9420 );
9421 assert_eq!(
9422 m.pos_of_offset(m.rows[1].end_src),
9423 (1, 2),
9424 "caret sits after '• '"
9425 );
9426 }
9427
9428 #[test]
9429 fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
9430 // The peek bug: a note whose body ends in a link has its last byte
9431 // inside the hidden destination, so mapping `end - 1` through
9432 // `pos_of_offset` snapped *forward* — past its own row, past the drawn
9433 // gap, and onto the next note's row. The popover then drew both notes.
9434 let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
9435 let m = map(src);
9436 let body = src.find("[title]").unwrap();
9437 let end = src.find("\n\n[^3]").unwrap();
9438
9439 let (first, last) = m.row_range_for(body..end);
9440 assert_eq!(
9441 first, last,
9442 "a one-block note is one row, not a span onto the next"
9443 );
9444
9445 // The old arithmetic, kept here as the thing that must stay wrong: it
9446 // is what this method exists instead of.
9447 assert_ne!(
9448 m.pos_of_offset(end - 1).0,
9449 last,
9450 "the forward snap still leaves the note's row — that is the whole point",
9451 );
9452
9453 // A note ending in *visible* text was never broken, and still isn't:
9454 // both readings agree there, which is why the original test missed it.
9455 let plain = src.find("bare text").unwrap();
9456 let plain_end = src.find("\n\n[^2]").unwrap();
9457 let (pf, pl) = m.row_range_for(plain..plain_end);
9458 assert_eq!(pf, pl);
9459 assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
9460 }
9461
9462 #[test]
9463 fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
9464 // The range is a span, not a point: a quote of two paragraphs covers its
9465 // gap row and both of its text rows, so a peek draws the whole thing.
9466 let src = "> one\n>\n> two\n\nafter\n";
9467 let m = map(src);
9468 let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
9469 assert_eq!((first, last), (0, 2));
9470
9471 // And a range with no visible byte at all still covers the row it opened
9472 // on, rather than collapsing to nothing.
9473 let (f, l) = m.row_range_for(0..1);
9474 assert_eq!((f, l), (0, 0));
9475 }
9476
9477 #[test]
9478 fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
9479 // The peer of the empty list item, and the case that made an empty line
9480 // in a quote draw as plain body text: a childless `block_quote` — a bare
9481 // `> `, which is what the toolbar's Quote button leaves on a blank line —
9482 // has no inner block to carry the gutter, so the whole quote used to
9483 // render as *nothing*. It didn't merely lose its bar; the row went away
9484 // and the caret had no home on it.
9485 let m = map("a\n\n> \n\nb\n");
9486 assert_eq!(
9487 m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
9488 "│ ",
9489 "the empty quote draws just its gutter",
9490 );
9491 assert!(
9492 m.rows[2]
9493 .glyphs
9494 .iter()
9495 .all(|g| g.style.role == Role::QuoteGutter)
9496 );
9497 assert!(
9498 !m.rows[2].decoration,
9499 "it is a line text can go on, not a drawn gap"
9500 );
9501 assert!(
9502 m.is_stop(m.rows[2].end_src),
9503 "the empty quote's caret home is not a stop"
9504 );
9505 assert_eq!(
9506 m.pos_of_offset(m.rows[2].end_src),
9507 (2, 2),
9508 "caret sits after '│ '"
9509 );
9510
9511 // And a document that is *only* an empty quote still renders a row — it
9512 // used to render none at all, leaving the caret nowhere to stand.
9513 let m = map("> \n");
9514 assert_eq!(m.num_rows(), 1);
9515 assert_eq!(
9516 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
9517 "│ "
9518 );
9519 }
9520
9521 #[test]
9522 fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
9523 // Enter at the end of `> a` writes `> a\n>\n> \n`. Those last two lines
9524 // hold no block — a quote's `content_span` stops at its last child — so
9525 // the children walk never reaches them, and they used to fall through to
9526 // the document-level trailing pass, which knows no prefix: the gutter
9527 // stopped and the writer's new line drew as plain prose. Fixable only
9528 // since twig 3.2.0, where the quote's *span* covers its own marker lines
9529 // (`0..3` before, `0..8` now) and there is finally a node saying they
9530 // are the quote's.
9531 let m = map("> a\n>\n> \n");
9532 assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
9533 for (i, row) in m.rows.iter().enumerate() {
9534 let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
9535 assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
9536 assert!(
9537 !row.decoration,
9538 "row {i} is a line to type on, not a drawn gap"
9539 );
9540 assert!(m.is_stop(row.end_src), "row {i} has no caret home");
9541 }
9542 assert_eq!(
9543 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
9544 "│ a"
9545 );
9546 // Distinct offsets, so ↑/↓ between them moves the caret rather than
9547 // landing twice on the same byte.
9548 assert!(m.rows[0].end_src < m.rows[1].end_src);
9549 assert!(m.rows[1].end_src < m.rows[2].end_src);
9550
9551 // A blank line *after* the quote is not the quote's: it is spelled with
9552 // no marker, so it stays an ordinary boundary and the gutter ends.
9553 let m = map("> a\n\nb\n");
9554 assert_eq!(m.num_rows(), 3);
9555 assert_eq!(
9556 m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
9557 "b"
9558 );
9559 assert!(
9560 !m.rows[1]
9561 .glyphs
9562 .iter()
9563 .any(|g| g.style.role == Role::QuoteGutter)
9564 );
9565
9566 // Nesting is the case this could get wrong, and the depth has to come
9567 // from which quote's span the line falls in rather than from the row
9568 // above it. A trailing `>` under `> > a` matches only the OUTER quote,
9569 // so it wears one gutter; spell it `> >` and it wears two.
9570 let m = map("> > a\n>\n");
9571 assert_eq!(
9572 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
9573 "│ │ a"
9574 );
9575 assert_eq!(
9576 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
9577 "│ "
9578 );
9579 let m = map("> > a\n> >\n");
9580 assert_eq!(
9581 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
9582 "│ │ "
9583 );
9584
9585 // And a marker line BETWEEN two quoted paragraphs is untouched: that is
9586 // the boundary `emit_separators_before` spells, and it stays a drawn gap
9587 // rather than becoming a line to type on.
9588 let m = map("> a\n>\n> b\n");
9589 assert_eq!(m.num_rows(), 3);
9590 assert!(
9591 m.rows[1].decoration,
9592 "the gap between two quoted blocks is still a gap"
9593 );
9594 }
9595
9596 #[test]
9597 fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
9598 let m = map("1. item\n2. \n");
9599 assert_eq!(m.num_rows(), 2);
9600 assert_eq!(
9601 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
9602 "2. "
9603 );
9604 assert!(m.is_stop(m.rows[1].end_src));
9605 assert_eq!(
9606 m.pos_of_offset(m.rows[1].end_src),
9607 (1, 3),
9608 "caret sits after '2. '"
9609 );
9610 }
9611
9612 #[test]
9613 fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
9614 // The toolbar's H1 on a blank line writes `# ` and nothing else. The row
9615 // it renders is empty (the marker is hidden), so its end *is* its only
9616 // caret stop — and it has to be the offset past the `# `, where typing
9617 // continues the heading. Anchored at the block's start instead, the caret
9618 // drew in front of the hashes and the first character typed there landed
9619 // before them (`x# `), which isn't a heading at all.
9620 let m = map("# \n");
9621 assert_eq!(m.num_rows(), 1);
9622 assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
9623 assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
9624 assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
9625 }
9626
9627 #[test]
9628 fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
9629 // The row-level fact a proportional frontend sizes a whole line by. An
9630 // empty heading has no glyph to read a `Role::Heading` off, so a renderer
9631 // scanning glyphs drew `# ` (and its caret) at body height until the
9632 // first character landed.
9633 let m = map("# \n");
9634 assert_eq!(
9635 m.rows[0].heading,
9636 Some(1),
9637 "the empty heading knows its level"
9638 );
9639
9640 // Every row of one that wraps, not just the first — and nothing else.
9641 let m = map_at(
9642 "## a heading long enough to wrap over two rows\n\nbody\n",
9643 Some(20),
9644 );
9645 let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
9646 assert!(
9647 heads.iter().filter(|h| **h == Some(2)).count() >= 2,
9648 "got {heads:?}"
9649 );
9650 assert_eq!(
9651 m.rows.last().and_then(|r| r.heading),
9652 None,
9653 "the paragraph under it is not a heading",
9654 );
9655 }
9656
9657 #[test]
9658 fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
9659 // The row's end is also what the *next* row's separator is measured from,
9660 // so an empty heading that under-reported it shifted every offset below —
9661 // and the blank line under the heading then claimed the same offset as the
9662 // heading's own end. `pos_of_offset` resolves such a tie downstream (a
9663 // soft wrap belongs to the row below), so the caret at the end of the
9664 // heading was drawn two rows lower, on the blank line.
9665 // `text\n\n# \n\n`: the heading's content opens at 8, and the two rows
9666 // under it end at 9 and 10 — the blank line and the document's end.
9667 let m = map("text\n\n# \n\n");
9668 let end = m.rows.last().expect("a trailing blank row").end_src;
9669 assert_eq!(end, 10, "the trailing rows must end at their real offsets");
9670 // The heading's caret home is its own row's, not one shared with a row
9671 // below — the tie that drew the caret two rows down.
9672 assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
9673 assert!(
9674 m.rows[3..].iter().all(|r| r.end_src > 8),
9675 "rows below own later offsets"
9676 );
9677 }
9678
9679 // ── block boundaries ─────────────────────────────────────────────────────
9680
9681 /// Every drawn boundary in `src`, in order, as `(above, below)`.
9682 fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
9683 m.rows
9684 .iter()
9685 .filter_map(|r| r.boundary)
9686 .map(|b| (b.above, b.below))
9687 .collect()
9688 }
9689
9690 #[test]
9691 fn a_boundary_says_which_blocks_it_divides() {
9692 use BlockClass::*;
9693 let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n\n");
9694 assert_eq!(
9695 boundaries(&m),
9696 vec![
9697 (Paragraph, Paragraph),
9698 (Paragraph, Heading),
9699 (Heading, Paragraph),
9700 (Paragraph, Quote),
9701 (Quote, Code),
9702 // The blank line the document trails off with is a boundary too
9703 // — it closes the last block above the empty paragraph the caret
9704 // rests on. See `emit_trailing_blank_lines`.
9705 (Code, Paragraph),
9706 ],
9707 "each gap names the pair it falls between, in document order"
9708 );
9709 }
9710
9711 #[test]
9712 fn a_closing_fence_at_the_end_of_the_document_opens_no_phantom_row() {
9713 // The code block's last row ends at its last line of code; the closing
9714 // fence under it has no row. Counted from the row, the fence's line read
9715 // as a trailing blank line and opened an empty paragraph whose offset
9716 // was inside the fence — typing on it wrote into the backticks.
9717 let m = map("para\n\n```\ncode\n```\n");
9718 let texts: Vec<String> = m
9719 .rows
9720 .iter()
9721 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9722 .collect();
9723 assert_eq!(texts, vec!["para", "", "code"], "a row under the fence");
9724 assert!(
9725 m.rows.last().is_some_and(|r| r.code),
9726 "the last row is the code"
9727 );
9728 // One more newline is the real empty paragraph, past the fence.
9729 let m = map("para\n\n```\ncode\n```\n\n");
9730 let last = m.rows.last().expect("a trailing row");
9731 assert_eq!(last.end_src, 20, "the trailing row stands past the fence");
9732 assert!(!last.decoration, "the trailing row is somewhere to type");
9733 // A setext underline is the same shape: markup under the last row.
9734 let m = map("Head\n====\n");
9735 assert_eq!(
9736 m.rows.len(),
9737 1,
9738 "a row under the underline: {:?}",
9739 m.rows.len()
9740 );
9741 }
9742
9743 /// Each row as whether it is drawn-only and where it ends — the shape of
9744 /// the blank lines around a block, which is what its text can't show.
9745 fn row_shape(m: &VisualMap) -> Vec<(bool, usize)> {
9746 m.rows.iter().map(|r| (r.decoration, r.end_src)).collect()
9747 }
9748
9749 #[test]
9750 fn the_lines_above_the_first_block_draw_as_the_lines_under_the_last_do() {
9751 // No row was drawn above the first block, so an empty paragraph opened
9752 // there drew nothing. As at the document's end, one blank line is only
9753 // the gap, and every line above that gap is somewhere to type.
9754 assert_eq!(row_shape(&map("\nb\n")), [(false, 2)]);
9755 let m = map("\n\nb\n");
9756 assert_eq!(row_shape(&m), [(false, 0), (true, 1), (false, 3)]);
9757 assert_eq!(m.content_start, 0, "the caret can reach the empty line");
9758 assert_eq!(m.pos_of_offset(0), (0, 0));
9759 assert_eq!(map("b\n").content_start, 0);
9760 assert_eq!(
9761 row_shape(&map("\n\n\n# H\n")),
9762 [(false, 0), (false, 1), (true, 2), (false, 6)]
9763 );
9764
9765 // Past frontmatter, the line under it is the frontmatter's.
9766 let fm = "---\nt: x\n---\n";
9767 let m = map(&format!("{fm}\nb\n"));
9768 assert_eq!(row_shape(&m), [(false, 15)]);
9769 assert_eq!(m.content_start, 14);
9770 let m = map(&format!("{fm}\n\nb\n"));
9771 assert_eq!(row_shape(&m), [(false, 13), (true, 14), (false, 16)]);
9772 assert_eq!(m.content_start, 13);
9773
9774 // Preserve flow draws every line, but the frontmatter's.
9775 let preserve = |src: &str| {
9776 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
9777 build(
9778 &ed.nodes().unwrap(),
9779 src,
9780 Some(80),
9781 true,
9782 &Surface::default(),
9783 None,
9784 )
9785 };
9786 assert_eq!(row_shape(&preserve("\nb\n")), [(false, 0), (false, 2)]);
9787 assert_eq!(row_shape(&preserve(&format!("{fm}\nb\n"))), [(false, 15)]);
9788 assert_eq!(
9789 row_shape(&preserve(&format!("{fm}\n\nb\n"))),
9790 [(false, 14), (false, 16)]
9791 );
9792
9793 // Past a comment, counted from the line after it.
9794 assert_eq!(row_shape(&map("<!-- c -->\n\nb\n")), [(false, 13)]);
9795 assert_eq!(
9796 row_shape(&map("<!-- c -->\n\n\nb\n")),
9797 [(false, 11), (true, 12), (false, 14)]
9798 );
9799 }
9800
9801 #[test]
9802 fn the_lines_under_a_rule_are_counted_from_the_rule() {
9803 // A rule's row ends at the caret's home past the newline under it, and
9804 // the counts of the blank lines below used to start from there too, so
9805 // every gap under a rule came up a line short: the empty paragraph
9806 // Enter opens beneath a rule drew as two gaps and no line, and the
9807 // caret put on it was drawn on the next block. They count from the
9808 // rule itself, as under any other block's last line.
9809 let src = "a\n\n---\n\n\n\nb\n";
9810 let m = map(src);
9811 assert_eq!(
9812 row_shape(&m),
9813 [
9814 (false, 1),
9815 (true, 2),
9816 (false, 7), // the rule, its home past the newline under it
9817 (true, 7),
9818 (false, 8), // the empty paragraph
9819 (true, 9),
9820 (false, 11),
9821 ]
9822 );
9823 assert_eq!(m.pos_of_offset(8), (4, 0), "the caret on the empty line");
9824 // The one blank line under a rule is still the one gap, not two.
9825 assert_eq!(
9826 row_shape(&map("a\n\n---\n\nb\n")),
9827 [(false, 1), (true, 2), (false, 7), (true, 7), (false, 9)]
9828 );
9829
9830 // Closing the document, the line under the gap is somewhere to type.
9831 let m = map("a\n\n---\n\n");
9832 assert_eq!(
9833 row_shape(&m),
9834 [(false, 1), (true, 2), (false, 7), (true, 7), (false, 8)]
9835 );
9836 assert_eq!(m.pos_of_offset(8), (4, 0));
9837 // And a lone newline after the rule is only the rule's own.
9838 assert_eq!(
9839 row_shape(&map("a\n\n---\n")),
9840 [(false, 1), (true, 2), (false, 7)]
9841 );
9842
9843 // A quote's own trailing lines under a rule: each a row, the first too.
9844 let m = map("> ---\n>\n> ");
9845 assert_eq!(row_shape(&m), [(false, 6), (false, 7), (false, 10)]);
9846 }
9847
9848 #[test]
9849 fn the_home_past_a_djot_rule_is_the_line_under_it() {
9850 // djot's span takes in the newline that ends the rule, and the home
9851 // past the rule was measured from that end — one newline further on,
9852 // across the blank line, to the next block's first character. A caret
9853 // left after the rule was drawn on that block.
9854 let m = map_djot("a\n\n* * *\n\nb\n");
9855 assert_eq!(
9856 row_shape(&m),
9857 [(false, 1), (true, 2), (false, 9), (true, 9), (false, 11)]
9858 );
9859 assert_eq!(m.pos_of_offset(9).0, 2, "beside the rule, not on `b`");
9860 let m = map_djot("a\n\n* * *\n\n\n\nb\n");
9861 assert_eq!(m.pos_of_offset(10), (4, 0), "the caret on the empty line");
9862 }
9863
9864 // ── hidden blocks ────────────────────────────────────────────────────────
9865
9866 /// The row texts of `m`, one string per row.
9867 fn row_texts(m: &VisualMap) -> Vec<String> {
9868 m.rows
9869 .iter()
9870 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
9871 .collect()
9872 }
9873
9874 #[test]
9875 fn a_div_s_closing_tag_is_not_a_blank_row() {
9876 // The `</div>` sits on a line of its own under the div's last child and
9877 // draws nothing. Counting the separator from the child's end read that
9878 // line as a blank line between the div and the block below — a
9879 // navigable empty row the author never opened — and at the end of the
9880 // file, as an empty trailing paragraph.
9881 let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n");
9882 assert_eq!(row_texts(&m), ["above", "", "hello", "", "below"]);
9883 assert!(!m.is_stop(36), "the `</div>` line is not a caret home");
9884 assert_eq!(
9885 m.stop_after(34),
9886 Some(44),
9887 "from `hello` the next stop is `below`"
9888 );
9889
9890 let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n");
9891 assert_eq!(row_texts(&m), ["above", "", "hello"], "no trailing rows");
9892 }
9893
9894 #[test]
9895 fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
9896 // `<!-- exec -->` is a top-level block that draws no rows. The blocks
9897 // either side of it meet across the one boundary a paragraph and a code
9898 // block always meet across — not that boundary *plus* one blank row per
9899 // line of the comment, which is what counting the separator from the
9900 // paragraph's end used to spell.
9901 let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
9902 assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
9903 assert_eq!(
9904 boundaries(&m),
9905 vec![
9906 (BlockClass::Paragraph, BlockClass::Code),
9907 (BlockClass::Code, BlockClass::Paragraph),
9908 ],
9909 "the boundary names the drawn blocks either side, not the comment"
9910 );
9911 // The gap stands past the comment, so the caret's row lookup never
9912 // resolves inside it.
9913 assert_eq!(
9914 m.rows[1].end_src, 23,
9915 "the gap row ends at the comment's end"
9916 );
9917 }
9918
9919 #[test]
9920 fn a_comment_opening_the_document_draws_no_leading_gap() {
9921 let m = map("<!-- lead -->\n\npara\n");
9922 assert_eq!(row_texts(&m), ["para"]);
9923 assert_eq!(m.content_start, 0, "the comment is still the first block");
9924 }
9925
9926 #[test]
9927 fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
9928 // Its lines are not blank lines the author opened with Enter, so no
9929 // gap-plus-empty-paragraph is fabricated under the last drawn block.
9930 let m = map("para\n\n<!-- trail -->\n");
9931 assert_eq!(row_texts(&m), ["para"]);
9932 // Enter at the end of the document still opens the empty paragraph the
9933 // caret rests on: the newlines *after* the comment count as they would
9934 // after any block.
9935 let m = map("para\n\n<!-- trail -->\n\n");
9936 assert_eq!(row_texts(&m), ["para", "", ""]);
9937 }
9938
9939 #[test]
9940 fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
9941 // The first *drawn* child wears the item's marker; a hidden first child
9942 // would otherwise take it and leave the text without one.
9943 let m = map("- <!-- note -->\n\n text\n- two\n");
9944 let texts = row_texts(&m);
9945 assert!(
9946 texts.iter().any(|t| t == "• text"),
9947 "the text wears the bullet: {texts:?}"
9948 );
9949 assert!(
9950 !texts.iter().any(|t| t == "• "),
9951 "no empty bullet row for the comment: {texts:?}"
9952 );
9953 }
9954
9955 #[test]
9956 fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
9957 // The bug as seen: a 200-line document with one comment in it rendered
9958 // ~200 blank rows after the comment, one per source line, because the
9959 // comment's per-block builder handed back a `last_off` of 0. Parity with
9960 // `build` alone would not catch a *shared* wrong answer, so the count is
9961 // pinned outright.
9962 let body = (0..200)
9963 .map(|i| format!("line {i}"))
9964 .collect::<Vec<_>>()
9965 .join("\n\n");
9966 let src = format!("intro\n\n<!-- exec -->\n{body}\n");
9967 let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
9968 let mut cache = BlockCache::default();
9969 let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
9970 assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
9971 // intro, then 200 × (gap, paragraph): 401 rows and not a row more.
9972 assert_eq!(cached.rows.len(), 401);
9973 }
9974
9975 #[test]
9976 fn a_link_reference_definition_is_stepped_over_like_a_comment() {
9977 // `[a]: /a` is a root beside `doc` with no rows of its own. Merged into
9978 // the walk it is a hidden block: the blocks either side meet across one
9979 // boundary, and its line is not a blank row.
9980 let m = map("see [a]\n\n[a]: /a\n\nafter\n");
9981 assert_eq!(row_texts(&m), ["see a", "", "after"]);
9982 assert_eq!(
9983 boundaries(&m),
9984 vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
9985 );
9986 }
9987
9988 #[test]
9989 fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
9990 // The README shape: prose, then a `[links]` block nobody reads. Its
9991 // lines used to be counted as blank ones, an empty paragraph per
9992 // definition under the last real block.
9993 let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
9994 assert_eq!(row_texts(&m), ["see a and b"]);
9995 }
9996
9997 #[test]
9998 fn a_definition_glued_under_a_paragraph_stays_inside_it() {
9999 // `[a]: /a` at the front of a paragraph's lines is stripped from the
10000 // paragraph's text, but the paragraph's span still starts on its line.
10001 // Both blocks start at the same offset; the definition, sorted first,
10002 // is stepped over, and the paragraph draws as it always did — one gap
10003 // above it, none inside.
10004 let m = map("intro\n\n[a]: /a\ntext [a]\n");
10005 assert_eq!(row_texts(&m), ["intro", "", "text a"]);
10006 }
10007
10008 #[test]
10009 fn a_definition_with_no_span_is_left_out_of_the_walk() {
10010 // twig before 3.3.3 reported `0..0` for every link reference
10011 // definition. One of those has nowhere to be merged: sorted first by
10012 // its zero start it would open the document with a phantom block, and
10013 // the walk would step back to offset 0. It is simply not a block. A
10014 // footnote definition is always placed; it has a body to draw.
10015 assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
10016 assert!(is_placed_definition(&Kind::Reference, &(7..14)));
10017 assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
10018 assert!(!is_placed_definition(&Kind::Str, &(7..14)));
10019 }
10020
10021 #[test]
10022 fn the_trailing_gap_closes_the_last_block() {
10023 // Two Enters at the end of a document: a drawn gap, then the navigable
10024 // empty paragraph. Only the gap is labelled, so a frontend that shrinks
10025 // boundaries shrinks the spacer and leaves the row being typed on alone.
10026 let m = map("# Head\n\n\n");
10027 assert_eq!(
10028 boundaries(&m),
10029 vec![(BlockClass::Heading, BlockClass::Paragraph)]
10030 );
10031 }
10032
10033 #[test]
10034 fn only_the_drawn_gap_rows_carry_a_boundary() {
10035 let m = map("one\n\ntwo\n");
10036 for row in &m.rows {
10037 assert_eq!(
10038 row.boundary.is_some(),
10039 row.decoration,
10040 "a boundary is exactly a drawn gap row: {:?}",
10041 row.glyphs.iter().map(|g| g.ch).collect::<String>()
10042 );
10043 }
10044 }
10045
10046 #[test]
10047 fn preserve_flow_labels_no_boundary() {
10048 // Every blank line is a caret home there — somewhere text can go, not a
10049 // gap between blocks — so nothing is drawn-only and nothing is labelled.
10050 // A frontend keying its spacing off `boundary` can't shrink a row the
10051 // author is about to type on.
10052 let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
10053 assert!(boundaries(&m).is_empty());
10054 }
10055
10056 #[test]
10057 fn a_list_draws_no_boundary_between_its_items() {
10058 // Tight or loose, core puts no gap row between two items of one list —
10059 // so an item↔item boundary is a shape no frontend will ever be handed,
10060 // and spacing one is spacing something that isn't there.
10061 for src in ["- one\n- two\n", "- one\n\n- two\n"] {
10062 let m = map(src);
10063 assert!(
10064 boundaries(&m).is_empty(),
10065 "no gap row inside the list of {src:?}"
10066 );
10067 }
10068 // Leaving the list is an ordinary boundary, and the list is named as
10069 // what sits above it.
10070 let m = map("- one\n- two\n\npara\n");
10071 assert_eq!(
10072 boundaries(&m),
10073 vec![(BlockClass::List, BlockClass::Paragraph)]
10074 );
10075 }
10076
10077 #[test]
10078 fn a_nested_boundary_names_the_blocks_inside_the_container() {
10079 // Two paragraphs inside a blockquote are divided by a Paragraph↔Paragraph
10080 // boundary — the quote is the container they're both in, not what the gap
10081 // separates.
10082 let m = map("> one\n>\n> two\n");
10083 assert_eq!(
10084 boundaries(&m),
10085 vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
10086 );
10087 }
10088
10089 #[test]
10090 fn a_directive_container_draws_one_boundary_like_every_other_block() {
10091 // A container's rows stop at its last *child*, so without anchoring
10092 // `last_off` past the closing `:::` the separator logic counted the fence
10093 // line as a blank row of its own and drew the gap twice — one authored
10094 // blank line, two boundaries, and a frontend spacing each of them put
10095 // double margin under every fenced div. The code-block arm anchors past
10096 // its ``` for exactly this reason; compare the two here.
10097 let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
10098 assert_eq!(
10099 boundaries(&fenced),
10100 vec![(BlockClass::Directive, BlockClass::Paragraph)],
10101 "one authored gap, one boundary row"
10102 );
10103 let code = map("```\nc\n```\n\ntwo\n");
10104 assert_eq!(
10105 boundaries(&code).len(),
10106 boundaries(&fenced).len(),
10107 "a fenced div spaces like a fenced code block"
10108 );
10109 // Nesting closes several fences at once; still one gap.
10110 let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
10111 assert_eq!(
10112 boundaries(&nested),
10113 vec![(BlockClass::Directive, BlockClass::Paragraph)]
10114 );
10115 }
10116
10117 #[test]
10118 fn a_block_media_names_itself_in_the_boundaries_either_side() {
10119 use BlockClass::*;
10120 // A block image is never a node of its own — `media_only` promotes the
10121 // *paragraph* wrapping it — so classifying the node the walk stands on
10122 // called the picture `Paragraph` and left `BlockClass::Media` unreachable:
10123 // a frontend could not give a photo more air than a line of prose.
10124 // `label_media_boundaries` reads it back off the finished rows instead.
10125 let m = map("one\n\n\n\ntwo\n");
10126 assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
10127 // At the edges of the document too: the leading gap has no boundary of
10128 // its own, and the trailing one is `emit_trailing_blank_lines`'.
10129 let edges = map("\n\nmid\n\n\n");
10130 assert_eq!(
10131 boundaries(&edges),
10132 vec![(Media, Paragraph), (Paragraph, Media)]
10133 );
10134 // One gap spelled with several rows — the row closing the block above and
10135 // the row opening the one below, with the author's spare blank line
10136 // navigable between them — carries the same pair on every drawn row.
10137 let roomy = map("one\n\n\n\n\n");
10138 assert_eq!(
10139 boundaries(&roomy),
10140 vec![(Paragraph, Media), (Paragraph, Media)]
10141 );
10142 }
10143
10144 #[test]
10145 fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
10146 // Worse than the image case before `label_media_boundaries`: a `<video>`
10147 // arrives as twig's generic `container`, which classifies `Directive` —
10148 // the one class a frontend reads as "draw a tinted panel here". A movie
10149 // got the chrome of a fenced div.
10150 let mut doc = crate::Doc::from_source(
10151 "one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
10152 Format::Markdown,
10153 )
10154 .unwrap();
10155 doc.build_visual(80);
10156 assert_eq!(
10157 boundaries(&doc.vmap),
10158 vec![
10159 (BlockClass::Paragraph, BlockClass::Media),
10160 (BlockClass::Media, BlockClass::Paragraph),
10161 ]
10162 );
10163 }
10164
10165 #[test]
10166 fn the_incremental_walk_labels_boundaries_like_the_full_one() {
10167 // `assert_maps_eq` compares boundaries too, so this pins the two doors
10168 // into `BlockClass::from_node_kind` — a `FlatNode`'s kind on the full
10169 // build, a query match's on the cached one — against a document with one
10170 // of every boundary in it.
10171 let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
10172 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
10173 let mut cache = BlockCache::default();
10174 let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
10175 assert_maps_eq(&full, &cached, "boundary labelling");
10176 assert!(
10177 !boundaries(&full).is_empty(),
10178 "the fixture has boundaries to compare"
10179 );
10180 }
10181
10182 #[test]
10183 fn every_caret_stop_opens_a_cluster_of_its_row() {
10184 // The two ways of finding a cluster have to agree. `push_text` marks the
10185 // stops by segmenting one run of text; the column mapping segments the
10186 // whole row, decoration and all. A stop that came out as the *middle* of
10187 // some row-level cluster would be a caret with no column of its own —
10188 // drawn at the column of whatever swallowed it.
10189 let src = "# 標題\n\na **bold** e\u{0301}mo👨👩👧ji `x` 你好\n\n\
10190 - 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
10191 | A | 值 |\n|---|---|\n| 你好 | 👩🚀 |\n";
10192 let m = map(src);
10193 for (r, row) in m.rows.iter().enumerate() {
10194 let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
10195 for (i, g) in row.glyphs.iter().enumerate() {
10196 assert!(
10197 !g.stop || openers.contains(&i),
10198 "row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
10199 so it is drawn at another glyph's column",
10200 g.ch
10201 );
10202 }
10203 }
10204 }
10205
10206 // ── the presentation vocabulary ─────────────────────────────────────────
10207
10208 /// A block's own attributes, in the three formats that spell one on the
10209 /// block itself: HTML's tag, djot's `{…}` line, and — the odd one — a
10210 /// Markdown `<div>` around it, which is where twig has to put a Markdown
10211 /// block's attributes because the format has nowhere else.
10212 #[test]
10213 fn a_block_carries_its_alignment_on_every_row_it_draws() {
10214 // HTML, on the paragraph. `lead` is somebody else's class and is
10215 // neither read nor in the way.
10216 let html = map_leaf("<p class=\"lead center\">hi</p>\n", Format::Html);
10217 assert_eq!(line_facts(&html), vec![(Some(Align::Center), None)]);
10218
10219 // djot's attribute line, on the block.
10220 let dj = map_leaf("{.right}\nhi\n", Format::Djot);
10221 assert_eq!(line_facts(&dj), vec![(Some(Align::Right), None)]);
10222
10223 // A heading carries it too, and on every row a wrapped one draws.
10224 let h = map_leaf("{.center}\n# a heading\n", Format::Djot);
10225 assert_eq!(line_facts(&h), vec![(Some(Align::Center), None)]);
10226 assert_eq!(h.rows[0].heading, Some(1));
10227
10228 // Both keys at once, and the line spacing is read the same way.
10229 let both = map_leaf("{.justify data-line-height=\"1.5\"}\nhi\n", Format::Djot);
10230 assert_eq!(
10231 line_facts(&both),
10232 vec![(
10233 Some(Align::Justify),
10234 Some(LineHeight::Step(LineSpacing::OneHalf))
10235 )]
10236 );
10237
10238 // An unknown token is somebody else's and the block draws at the
10239 // theme's alignment; a ratio outside the menu's three is the author's
10240 // own and draws at exactly what they wrote.
10241 let other = map_leaf("{.lead data-line-height=\"1.3\"}\nhi\n", Format::Djot);
10242 assert_eq!(line_facts(&other), vec![(None, LineHeight::ratio(1.3))]);
10243
10244 // A value the grammar does not cover is neither: carried by the
10245 // document, drawn at the theme's spacing, and read as nothing at all.
10246 let em = map_leaf("{data-line-height=\"1.3em\"}\nhi\n", Format::Djot);
10247 assert_eq!(line_facts(&em), vec![(None, None)]);
10248 }
10249
10250 /// `<div class="center">` around three paragraphs centres all three, which
10251 /// is what the author of that HTML meant — and around one is the sole-child
10252 /// shape twig's `set_block_attrs` writes in Markdown.
10253 #[test]
10254 fn a_div_lends_its_alignment_to_every_block_inside_it() {
10255 let m = map_leaf(
10256 "<div class=\"center\" data-line-height=\"2\">\n\none\n\ntwo\n\n</div>\n",
10257 Format::Markdown,
10258 );
10259 assert_eq!(
10260 line_facts(&m),
10261 vec![
10262 (
10263 Some(Align::Center),
10264 Some(LineHeight::Step(LineSpacing::Double))
10265 ),
10266 (
10267 Some(Align::Center),
10268 Some(LineHeight::Step(LineSpacing::Double))
10269 ),
10270 ]
10271 );
10272
10273 // The nearer node wins, and the block after the div is untouched — the
10274 // context is restored, not left running.
10275 let nested = map_leaf(
10276 "<div class=\"center\">\n\n<div class=\"right\">\n\ninner\n\n</div>\n\nouter\n\n</div>\n\nafter\n",
10277 Format::Markdown,
10278 );
10279 assert_eq!(
10280 line_facts(&nested),
10281 vec![
10282 (Some(Align::Right), None),
10283 (Some(Align::Center), None),
10284 (None, None),
10285 ]
10286 );
10287 }
10288
10289 /// Size, face and colour are the run's, and the block's when the whole
10290 /// block is meant — read at both levels with the nearer winning.
10291 #[test]
10292 fn a_span_s_size_beats_its_block_s_and_its_face_falls_through() {
10293 // `<div data-font>` over `<p data-size>` over `<span data-size>`: the
10294 // span wins on size, the block is still what says the face.
10295 // The span is not first on its line: a `<span …>` opening one is an
10296 // HTML *block* to CommonMark, which is a fact about Markdown and not
10297 // about this.
10298 let m = map_leaf(
10299 "<div data-font=\"serif\">\n\nc <span data-size=\"small\">a</span> b\n\n</div>\n",
10300 Format::Markdown,
10301 );
10302 let a = style_of(&m, 'a');
10303 assert_eq!(a.size, Some(FontSize::Step(SizeStep::Small)));
10304 assert_eq!(a.font, Some(FaceRef::Generic(FontFamily::Serif)));
10305 // The text outside the span keeps the div's face and no size at all.
10306 let b = style_of(&m, 'b');
10307 assert_eq!(b.size, None);
10308 assert_eq!(b.font, Some(FaceRef::Generic(FontFamily::Serif)));
10309
10310 // djot spells the same span anonymously and it reads identically.
10311 let dj = map_leaf(
10312 "{data-size=\"large\"}\nx [y]{data-size=\"xx-large\" data-color=\"blue\"} z\n",
10313 Format::Djot,
10314 );
10315 assert_eq!(
10316 style_of(&dj, 'x').size,
10317 Some(FontSize::Step(SizeStep::Large))
10318 );
10319 assert_eq!(
10320 style_of(&dj, 'y').size,
10321 Some(FontSize::Step(SizeStep::XxLarge))
10322 );
10323 assert_eq!(
10324 style_of(&dj, 'y').color,
10325 Some(TextColor::Named(MarkColor::Blue))
10326 );
10327 // The block's size is still the block's outside the span.
10328 assert_eq!(
10329 style_of(&dj, 'z').size,
10330 Some(FontSize::Step(SizeStep::Large))
10331 );
10332 assert_eq!(style_of(&dj, 'z').color, None);
10333 }
10334
10335 /// The exact half of the vocabulary reaches a glyph and a row by the same
10336 /// doors the names do — the fold has one rule, not one per form. A named
10337 /// family is the one that cannot ride the glyph as itself: the walker
10338 /// interns it and the glyph carries the id.
10339 #[test]
10340 fn an_exact_size_face_and_colour_reach_the_glyph_and_the_row() {
10341 let m = map_leaf(
10342 "<div data-line-height=\"1.3\">\n\nc <span data-size=\"14pt\" \
10343 data-color=\"#c03030\" data-font=\"Garamond\">a</span> b\n\n</div>\n",
10344 Format::Markdown,
10345 );
10346 let a = style_of(&m, 'a');
10347 assert_eq!(a.size, FontSize::points(14.0));
10348 assert_eq!(
10349 a.color,
10350 Some(TextColor::Rgb {
10351 r: 0xc0,
10352 g: 0x30,
10353 b: 0x30
10354 })
10355 );
10356 assert_eq!(a.font, Some(FaceRef::Named(FaceId::of("Garamond"))));
10357 assert_eq!(m.face_name(FaceId::of("Garamond")), Some("Garamond"));
10358 // The div's ratio is the row's, on every row the block draws.
10359 assert_eq!(line_facts(&m), vec![(None, LineHeight::ratio(1.3))]);
10360 // And the text outside the span has none of the span's three.
10361 let b = style_of(&m, 'b');
10362 assert_eq!((b.size, b.font, b.color), (None, None, None));
10363
10364 // One name, one entry, however many spans wear it — the table is what
10365 // keeps a `Style` `Copy` and it should not grow per run.
10366 let twice = map_leaf(
10367 "x <span data-font=\"Garamond\">a</span> y <span data-font=\"Garamond\">b</span>\n",
10368 Format::Markdown,
10369 );
10370 assert_eq!(twice.faces().len(), 1);
10371 assert_eq!(
10372 style_of(&twice, 'a').font,
10373 style_of(&twice, 'b').font,
10374 "one family, one id"
10375 );
10376
10377 // A generic needs no entry at all: it names itself.
10378 let generic = map_leaf(
10379 "<div data-font=\"serif\">\n\nhi\n\n</div>\n",
10380 Format::Markdown,
10381 );
10382 assert_eq!(
10383 style_of(&generic, 'h').font,
10384 Some(FaceRef::Generic(FontFamily::Serif))
10385 );
10386 assert!(generic.faces().is_empty());
10387 }
10388
10389 /// The one key two nodes share. `data-color` on a `mark` is the highlight's
10390 /// *background* and reaches a glyph through [`Role::Mark`]; the same key on
10391 /// an attributed span is the text's foreground. Same vocabulary, same enum,
10392 /// no collision — and a mark inside a coloured span wears both.
10393 #[test]
10394 fn a_mark_keeps_its_highlight_colour_and_a_span_colours_the_text() {
10395 let m = map_leaf("a ==\u{1f534} red== b\n", Format::Markdown);
10396 let r = style_of(&m, 'r');
10397 assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)));
10398 assert_eq!(r.color, None, "a highlight is not a text colour");
10399
10400 let both = map_leaf(
10401 "<span data-color=\"blue\">a ==\u{1f534} red== b</span>\n",
10402 Format::Markdown,
10403 );
10404 let r = style_of(&both, 'r');
10405 assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)), "the highlight");
10406 assert_eq!(
10407 r.color,
10408 Some(TextColor::Named(MarkColor::Blue)),
10409 "the letters"
10410 );
10411 }
10412
10413 /// A page break is the `::page-break` leaf directive, and djot spells the
10414 /// same document as an empty `::: page-break` fence whose name comes back
10415 /// as a class, HTML as a `<page-break>` element and AsciiDoc as `<<<`. All
10416 /// four draw the placeholder row every leaf directive gets and carry the
10417 /// same [`DirectiveMark`], because a frontend that opens a page at one
10418 /// must not be able to tell which format the file is in.
10419 #[test]
10420 fn a_page_break_reads_the_same_in_every_format() {
10421 for (fmt, src) in [
10422 (Format::Markdown, "a\n\n::page-break\n\nb\n"),
10423 (Format::Djot, "a\n\n::: page-break\n:::\n\nb\n"),
10424 (
10425 Format::Html,
10426 "<p>a</p>\n\n<page-break></page-break>\n\n<p>b</p>\n",
10427 ),
10428 (Format::Asciidoc, "a\n\n<<<\n\nb\n"),
10429 ] {
10430 let m = map_leaf(src, fmt);
10431 let marks: Vec<&DirectiveMark> = m
10432 .rows
10433 .iter()
10434 .filter_map(|r| r.leaf_directive.as_ref())
10435 .collect();
10436 assert_eq!(marks.len(), 1, "{fmt:?} draws one placeholder");
10437 assert_eq!(marks[0].name, "page-break", "{fmt:?}");
10438 assert!(marks[0].attrs.is_empty(), "{fmt:?}: {:?}", marks[0].attrs);
10439 assert!(
10440 m.rows
10441 .iter()
10442 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>()
10443 == "\u{29c9} page-break"),
10444 "{fmt:?} draws the label, got {:?}",
10445 m.rows
10446 .iter()
10447 .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
10448 .collect::<Vec<_>>()
10449 );
10450 }
10451
10452 // A Markdown `:::note` with nothing in it is *not* this: its name is
10453 // its own, and nothing about it says "a block with no body" the way
10454 // djot's spelling of a leaf directive does.
10455 let empty_fence = map_leaf("::: note\n:::\n", Format::Markdown);
10456 assert!(
10457 empty_fence.rows.iter().all(|r| r.leaf_directive.is_none()),
10458 "a named empty fence keeps the reading it has"
10459 );
10460 }
10461
10462 /// A djot fence carrying more than its name keeps the rest as an attribute
10463 /// rather than folding it into the name: with a bare `:::` fence, which
10464 /// names nothing itself, the *first* class token is the name.
10465 #[test]
10466 fn a_djot_fence_s_first_class_is_the_directive_s_name_and_the_rest_is_attributes() {
10467 let m = map_leaf("{.page-break .wide}\n:::\n:::\n", Format::Djot);
10468 let mark = m
10469 .rows
10470 .iter()
10471 .find_map(|r| r.leaf_directive.as_ref())
10472 .expect("a placeholder");
10473 assert_eq!(mark.name, "page-break");
10474 assert_eq!(
10475 mark.attrs,
10476 vec![("class".to_string(), Some("wide".to_string()))]
10477 );
10478 }
10479
10480 /// With the name on the fence line, djot appends it *after* the classes of
10481 /// the attribute line above, so the name is the fence's word wherever it
10482 /// lands in the class — here, and inside a quote.
10483 #[test]
10484 fn a_djot_fence_s_word_is_the_directive_s_name_behind_an_attribute_line() {
10485 for src in [
10486 "{.wide src=\"u\"}\n::: x-card\n:::\n",
10487 "> {.wide src=\"u\"}\n> ::: x-card\n> :::\n",
10488 ] {
10489 let m = map_leaf(src, Format::Djot);
10490 let mark = m
10491 .rows
10492 .iter()
10493 .find_map(|r| r.leaf_directive.as_ref())
10494 .unwrap_or_else(|| panic!("a placeholder for {src:?}"));
10495 assert_eq!(mark.name, "x-card", "{src:?}");
10496 assert_eq!(
10497 mark.attrs,
10498 vec![
10499 ("class".to_string(), Some("wide".to_string())),
10500 ("src".to_string(), Some("u".to_string())),
10501 ],
10502 "{src:?}"
10503 );
10504 }
10505 }
10506
10507 // ── math ─────────────────────────────────────────────────────────────────
10508
10509 /// [`map_leaf`] on a chosen surface and reveal — the whole of what a math
10510 /// rendering turns on.
10511 fn map_math(src: &str, format: Format, surface: &Surface, reveal: Option<Reveal>) -> VisualMap {
10512 let mut ed =
10513 Editor::new_ext(src.as_bytes(), format, crate::doc::parse_extensions()).unwrap();
10514 build(&ed.nodes().unwrap(), src, Some(80), false, surface, reveal)
10515 }
10516
10517 fn pictures() -> Surface {
10518 Surface {
10519 inline_pictures: true,
10520 ..Default::default()
10521 }
10522 }
10523
10524 #[test]
10525 fn markdown_reads_math_and_a_dollar_before_whitespace_stays_prose() {
10526 // The `math` extension is on for every leaf document; twig's own rule
10527 // keeps a price out of it.
10528 let m = map_leaf("Say $E = mc^2$ for $5 and $6.\n", Format::Markdown);
10529 assert_eq!(row_texts(&m), vec!["Say E = mc^2 for $5 and $6."]);
10530 let e = m.rows[0].glyphs.iter().find(|g| g.ch == 'E').unwrap();
10531 assert_eq!(
10532 e.style.role,
10533 Role::Code,
10534 "the formula's TeX, in the code style"
10535 );
10536 assert_eq!(e.src, 5, "at its own byte, past the `$`");
10537 let five = m.rows[0].glyphs.iter().find(|g| g.ch == '5').unwrap();
10538 assert_eq!(five.style.role, Role::Body);
10539 assert!(m.math.is_empty(), "no picture stands in on a plain surface");
10540 }
10541
10542 #[test]
10543 fn a_plain_surface_draws_inline_math_as_code_with_the_delimiters_hidden() {
10544 // The verbatim treatment, in both languages — what `inline_math` always
10545 // rendered as — with the caret's home at the content's end.
10546 for (src, fmt) in [
10547 ("a $x+y$ b\n", Format::Markdown),
10548 ("a $`x+y` b\n", Format::Djot),
10549 ] {
10550 let m = map_leaf(src, fmt);
10551 assert_eq!(row_texts(&m), vec!["a x+y b"], "{src:?}");
10552 let x = m.rows[0].glyphs.iter().find(|g| g.ch == 'x').unwrap();
10553 assert_eq!(x.style.role, Role::Code);
10554 assert!(!m.mark_ends.is_empty(), "the content end is a caret home");
10555 }
10556 }
10557
10558 #[test]
10559 fn display_math_in_a_line_of_prose_puts_its_text_at_the_text_s_own_offset() {
10560 // `display_math` had no arm and fell to the default one, which pushed
10561 // the text at the *node's* start: three bytes short in djot, past `$$`
10562 // and the backtick.
10563 let m = map_leaf("Before $$`x`$$ after\n", Format::Djot);
10564 let x = m.rows[0].glyphs.iter().find(|g| g.ch == 'x').unwrap();
10565 assert_eq!(x.src, "Before $$`".len());
10566 assert_eq!(x.style.role, Role::Code);
10567 let m = map_leaf("Before $$x$$ after\n", Format::Markdown);
10568 let x = m.rows[0].glyphs.iter().find(|g| g.ch == 'x').unwrap();
10569 assert_eq!(x.src, "Before $$".len());
10570 }
10571
10572 #[test]
10573 fn a_surface_that_paints_in_a_line_gets_one_atom_per_inline_formula() {
10574 let src = "Say $E = mc^2$ and $a$.\n";
10575 let m = map_math(src, Format::Markdown, &pictures(), None);
10576 assert_eq!(row_texts(&m), vec!["Say ∑ and ∑."]);
10577 assert_eq!(m.math.len(), 2);
10578 let first = &m.math[0];
10579 assert_eq!(first.tex, "E = mc^2");
10580 assert!(!first.display);
10581 assert_eq!(first.row, 0);
10582 assert_eq!(first.rows_span, 0..1);
10583 assert_eq!(first.glyph, Some(4));
10584 assert_eq!(first.src, 4, "the formula's start, where a click lands");
10585 let atom = &m.rows[0].glyphs[4];
10586 assert_eq!(atom.ch, MATH_ATOM);
10587 assert_eq!(atom.style.role, Role::Math);
10588 assert!(atom.stop);
10589 assert_eq!(atom.src, 4);
10590 // The caret has a stop on the atom and the next glyph's past it, and
10591 // nothing inside the markup.
10592 assert_eq!(m.stop_after(4), Some("Say $E = mc^2$".len()));
10593 assert!(m.mark_ends.is_empty(), "no home inside the hidden markup");
10594 // The row carries the marks the side-table is derived from.
10595 assert_eq!(m.rows[0].math.len(), 2);
10596 assert_eq!(m.rows[0].math[1].glyph, Some(m.math[1].glyph.unwrap()));
10597 assert_eq!(m.math[1].tex, "a");
10598 }
10599
10600 #[test]
10601 fn a_display_formula_written_inline_is_an_atom_in_display_style() {
10602 let m = map_math("Before $$x$$ after\n", Format::Markdown, &pictures(), None);
10603 assert_eq!(row_texts(&m), vec!["Before ∑ after"]);
10604 assert_eq!(m.math.len(), 1);
10605 assert!(m.math[0].display);
10606 assert_eq!(m.math[0].tex, "x");
10607 }
10608
10609 #[test]
10610 fn an_atom_follows_its_glyph_across_a_wrap() {
10611 // Fifteen words, then a formula that wraps onto the second row: the
10612 // mark is drained onto the row the glyph landed on, at its index there.
10613 let src = format!("{}$x$ end\n", "word ".repeat(15));
10614 let mut ed = Editor::new_ext(
10615 src.as_bytes(),
10616 Format::Markdown,
10617 crate::doc::parse_extensions(),
10618 )
10619 .unwrap();
10620 let m = build(
10621 &ed.nodes().unwrap(),
10622 &src,
10623 Some(40),
10624 false,
10625 &pictures(),
10626 None,
10627 );
10628 assert!(m.rows.len() >= 2);
10629 assert_eq!(m.math.len(), 1);
10630 let info = &m.math[0];
10631 let g = &m.rows[info.row].glyphs[info.glyph.unwrap()];
10632 assert_eq!(g.ch, MATH_ATOM);
10633 assert_eq!(g.src, src.find("$x$").unwrap());
10634 assert!(m.rows[..info.row].iter().all(|r| r.math.is_empty()));
10635 }
10636
10637 #[test]
10638 fn an_atom_in_a_table_cell_rides_the_cell_s_row() {
10639 let m = map_math(
10640 "| a | b |\n|---|---|\n| $x$ | c |\n",
10641 Format::Markdown,
10642 &pictures(),
10643 None,
10644 );
10645 assert_eq!(m.math.len(), 1);
10646 let info = &m.math[0];
10647 assert_eq!(m.rows[info.row].glyphs[info.glyph.unwrap()].ch, MATH_ATOM);
10648 }
10649
10650 #[test]
10651 fn a_display_formula_on_its_own_lines_is_a_block_placeholder_on_every_surface() {
10652 for (src, fmt) in [
10653 (
10654 "intro\n\n$$\n\\int_0^1 x\\,dx\n$$\n\nend\n",
10655 Format::Markdown,
10656 ),
10657 ("intro\n\n$$`\\int_0^1 x\\,dx`\n\nend\n", Format::Djot),
10658 ] {
10659 for surface in [Surface::default(), pictures()] {
10660 let m = map_math(src, fmt, &surface, None);
10661 assert_eq!(
10662 row_texts(&m),
10663 vec!["intro", "", "∑ \\int_0^1 x\\,dx", "", "end"],
10664 "{src:?}"
10665 );
10666 assert_eq!(m.math.len(), 1);
10667 let info = &m.math[0];
10668 assert!(info.display);
10669 assert_eq!(info.glyph, None, "a block, not an atom");
10670 assert_eq!(info.rows_span, 2..3);
10671 assert_eq!(info.tex.trim(), "\\int_0^1 x\\,dx");
10672 let start = src.find("$$").unwrap();
10673 let end = start + src[start..].find("\n\nend").unwrap();
10674 assert_eq!(info.src, start);
10675 // Every label glyph at the formula's start, a stop there and
10676 // one past the block, nothing inside — a picture's two homes.
10677 let row = &m.rows[2];
10678 assert!(
10679 row.glyphs
10680 .iter()
10681 .all(|g| g.src == start && g.style.role == Role::Math)
10682 );
10683 assert_eq!(row.end_src, end);
10684 assert_eq!(m.stop_after(start), Some(end));
10685 assert_eq!(m.stop_before(end), Some(start));
10686 // The gaps either side are labelled as a formula's.
10687 assert_eq!(m.rows[1].boundary.map(|b| b.below), Some(BlockClass::Math));
10688 assert_eq!(m.rows[3].boundary.map(|b| b.above), Some(BlockClass::Math));
10689 }
10690 }
10691 }
10692
10693 #[test]
10694 fn a_display_block_reserves_the_rows_the_frontend_measured() {
10695 let src = "$$\nx\n$$\n\nend\n";
10696 let surface = Surface {
10697 math_rows: HashMap::from([("\nx\n".to_string(), 4)]),
10698 ..Default::default()
10699 };
10700 let m = map_math(src, Format::Markdown, &surface, None);
10701 assert_eq!(m.math[0].rows_span, 0..4);
10702 assert_eq!(row_texts(&m)[..5], ["∑ x", "", "", "", ""]);
10703 // The fillers are decoration: drawn, no caret, anchored past the block.
10704 for r in &m.rows[1..4] {
10705 assert!(r.decoration);
10706 assert_eq!(r.end_src, 7);
10707 }
10708 assert_eq!(m.stop_after(0), Some(7));
10709 // A height keyed by TeX that does not match reserves nothing.
10710 let surface = Surface {
10711 math_rows: HashMap::from([("x".to_string(), 4)]),
10712 ..Default::default()
10713 };
10714 let m = map_math(src, Format::Markdown, &surface, None);
10715 assert_eq!(m.math[0].rows_span, 0..1);
10716 }
10717
10718 #[test]
10719 fn a_paragraph_with_prose_beside_a_display_formula_is_not_a_block() {
10720 let m = map_math("see\n$$\nx\n$$\n", Format::Markdown, &pictures(), None);
10721 assert!(
10722 m.math.iter().all(|i| i.glyph.is_some()),
10723 "an atom, not a placeholder"
10724 );
10725 let m = map_math(
10726 "$$\nx\n$$\n$$\ny\n$$\n",
10727 Format::Markdown,
10728 &pictures(),
10729 None,
10730 );
10731 assert_eq!(m.math.len(), 2);
10732 assert!(
10733 m.math.iter().all(|i| i.glyph.is_some()),
10734 "two formulas is prose with math in it"
10735 );
10736 }
10737
10738 #[test]
10739 fn a_formula_on_the_reveal_line_is_its_tex_in_every_mode() {
10740 let src = "Say $E = mc^2$ here.\n";
10741 // The hidden modes' reveal: only the formula shows its markup.
10742 let m = map_math(
10743 src,
10744 Format::Markdown,
10745 &pictures(),
10746 Some(Reveal::math(0..src.len() - 1)),
10747 );
10748 assert_eq!(row_texts(&m), vec!["Say $E = mc^2$ here."]);
10749 assert!(
10750 m.math.is_empty(),
10751 "nothing stands in for a revealed formula"
10752 );
10753 let dollar = m.rows[0].glyphs.iter().find(|g| g.ch == '$').unwrap();
10754 assert_eq!(dollar.style.role, Role::Delimiter);
10755 let e = m.rows[0].glyphs.iter().find(|g| g.ch == 'E').unwrap();
10756 assert_eq!(e.style.role, Role::Code);
10757 // Full's reveal draws the same, and an emphasis beside it reveals too
10758 // where the hidden modes' does not.
10759 let src = "*a* $x$\n";
10760 let hidden = map_math(
10761 src,
10762 Format::Markdown,
10763 &pictures(),
10764 Some(Reveal::math(0..src.len() - 1)),
10765 );
10766 assert_eq!(row_texts(&hidden), vec!["a $x$"]);
10767 let full = map_math(
10768 src,
10769 Format::Markdown,
10770 &pictures(),
10771 Some(Reveal::full(0..src.len() - 1)),
10772 );
10773 assert_eq!(row_texts(&full), vec!["*a* $x$"]);
10774 // A reveal line that does not meet the formula leaves the atom.
10775 let other = map_math(
10776 "$x$\n\ntext\n",
10777 Format::Markdown,
10778 &pictures(),
10779 Some(Reveal::math(5..9)),
10780 );
10781 assert_eq!(row_texts(&other)[0], "∑");
10782 }
10783
10784 #[test]
10785 fn a_display_block_on_the_reveal_line_is_its_source_lines_in_the_code_style() {
10786 let src = "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n";
10787 // Any line of the block reveals the whole of it: here the middle one.
10788 let mid = src.find("\\int").unwrap();
10789 let line = mid..mid + "\\int_0^1 x".len();
10790 let m = map_math(src, Format::Markdown, &pictures(), Some(Reveal::math(line)));
10791 assert_eq!(
10792 row_texts(&m),
10793 vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
10794 );
10795 assert!(m.math.is_empty());
10796 let fence = m.rows[2].glyphs.iter().find(|g| g.ch == '$').unwrap();
10797 assert_eq!(fence.style.role, Role::Delimiter);
10798 assert_eq!(fence.src, 7);
10799 let x = m.rows[3].glyphs.iter().find(|g| g.ch == 'x').unwrap();
10800 assert_eq!(x.style.role, Role::Code);
10801 assert_eq!(x.src, src.find("x\n$$").unwrap());
10802 // Every byte of the source is reachable: a stop on each fence and each
10803 // character between.
10804 assert!(m.is_stop(7));
10805 assert!(m.is_stop(mid));
10806 // The closing fence's line too, and the same for djot.
10807 let close = src.rfind("$$").unwrap();
10808 let m = map_math(
10809 src,
10810 Format::Markdown,
10811 &pictures(),
10812 Some(Reveal::math(close..close + 2)),
10813 );
10814 assert_eq!(row_texts(&m)[2], "$$");
10815 let src = "$$`\n\\int\n`\n";
10816 let m = map_math(src, Format::Djot, &pictures(), Some(Reveal::math(4..8)));
10817 assert_eq!(row_texts(&m), vec!["$$`", "\\int", "`"]);
10818 }
10819
10820 #[test]
10821 fn a_block_that_holds_a_formula_says_so_to_the_cache() {
10822 let src = "plain\n\nwith $x$ in it\n\n$$\ny\n$$\n";
10823 let mut ed = Editor::new_ext(
10824 src.as_bytes(),
10825 Format::Markdown,
10826 crate::doc::parse_extensions(),
10827 )
10828 .unwrap();
10829 let mut cache = BlockCache::default();
10830 let top = top_blocks(&mut ed);
10831 let _ = build_cached(
10832 &top,
10833 src,
10834 None,
10835 false,
10836 &pictures(),
10837 None,
10838 &mut cache,
10839 |id| ed.subtree(NodeId(id)).unwrap_or_default(),
10840 );
10841 assert!(!cache.math_meets(&(0..5)), "the plain paragraph");
10842 assert!(cache.math_meets(&(7..21)), "the one with an atom");
10843 assert!(
10844 cache.math_meets(&(26..27)),
10845 "the middle line of the display block"
10846 );
10847 // A hit carries the answer without a walk: build again from the cache.
10848 let _ = build_cached(
10849 &top,
10850 src,
10851 None,
10852 false,
10853 &pictures(),
10854 None,
10855 &mut cache,
10856 |_| Vec::new(),
10857 );
10858 assert!(cache.math_meets(&(7..21)));
10859 assert!(!cache.math_meets(&(0..5)));
10860 }
10861
10862 #[test]
10863 fn incremental_builds_agree_with_the_reference_on_math() {
10864 for src in [
10865 "a $x$ b\n\n$$\ny\n$$\n\nc\n",
10866 "one\n\ntwo $\\frac{a}{b}$ three\n",
10867 "$$\n\\int\n$$\n",
10868 ] {
10869 for surface in [Surface::default(), pictures()] {
10870 let mut ed = Editor::new_ext(
10871 src.as_bytes(),
10872 Format::Markdown,
10873 crate::doc::parse_extensions(),
10874 )
10875 .unwrap();
10876 let all = ed.nodes().unwrap();
10877 let plain = build(&all, src, Some(80), false, &surface, None);
10878 let mut cache = BlockCache::default();
10879 let top = top_blocks(&mut ed);
10880 let cached = build_cached(
10881 &top,
10882 src,
10883 Some(80),
10884 false,
10885 &surface,
10886 None,
10887 &mut cache,
10888 |id| ed.subtree(NodeId(id)).unwrap_or_default(),
10889 );
10890 assert_eq!(row_texts(&plain), row_texts(&cached), "{src:?}");
10891 assert_eq!(plain.math, cached.math, "{src:?}");
10892 assert_eq!(plain.stops, cached.stops, "{src:?}");
10893 }
10894 }
10895 }
10896}