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;
26
27use twig::{Alignment, ContainerOrigin, DirectiveForm, Editor, FlatNode, Kind, QueryMatch};
28use unicode_segmentation::UnicodeSegmentation;
29use unicode_width::UnicodeWidthStr;
30
31use crate::style::{
32 Align, Baseline, FontFamily, LineSpacing, MarkColor, Role, SizeStep, Style, Token,
33};
34
35/// One rendered character plus the source byte offset it originates from.
36/// Synthetic glyphs (a list bullet, a quote gutter) point at their block's
37/// start, so clicking one lands the caret at the start of that block.
38#[derive(Clone)]
39pub struct Glyph {
40 pub ch: char,
41 pub style: Style,
42 pub src: usize,
43 /// Whether the caret may *rest* on this glyph. Decoration — a table border
44 /// or a cell's alignment padding — is visible but isn't text, so the caret
45 /// steps over it instead of into it. It also can't be a stop even in
46 /// principle: a run of decoration shares one `src`, and a caret can only
47 /// move by changing offset, so resting on it would pin horizontal motion.
48 /// A click still maps through `src`, which is why decoration points at the
49 /// text it decorates.
50 ///
51 /// Real text is a stop once per *grapheme cluster*, on the glyph that opens
52 /// it: the continuation glyphs of an emoji or an accented letter are drawn,
53 /// but standing between them is standing inside a character.
54 pub stop: bool,
55}
56
57/// One visual line. `end_src` is the source offset a caret sits at when placed
58/// at the line's end (past its last glyph) — the anchor for end-of-line and
59/// click-past-content.
60///
61/// `Clone` so a block's rows can be cached and re-emitted at a shifted offset
62/// across an edit — see [`BlockCache`].
63#[derive(Clone)]
64pub struct VRow {
65 pub glyphs: Vec<Glyph>,
66 pub end_src: usize,
67 /// A row that is drawn but holds no caret: a table's `├───┼───┤` rules, and
68 /// the blank gap a block boundary is spelled with. Vertical motion steps
69 /// over it, `pos_of_offset` never resolves onto it, and its stops (it has
70 /// none) and `end_src` stay out of the map's stop table.
71 ///
72 /// Emptiness isn't the test — an empty paragraph is a blank row too, and a
73 /// real caret stop. The test is whether the row is somewhere text can go.
74 pub decoration: bool,
75 /// This row is one line of a fenced or indented code block. Set on every row
76 /// the `"code_block"` arm emits — including its blank lines, which carry no
77 /// glyph to tell them apart otherwise. A frontend draws its own chrome (a
78 /// border and a tinted background) around each maximal run of these, and
79 /// scrolls them horizontally instead of wrapping; see
80 /// [`VisualMap::code_blocks`]. Survives the row shuffling of [`BlockCache`]
81 /// reuse and [`build_spliced`] because it rides on the row, not on a
82 /// row-index span the way a table's picture does.
83 pub code: bool,
84 /// A fenced code block's info string (its language), carried on the *first*
85 /// row of the block so it survives row reuse the way [`code`](Self::code)
86 /// does. `None` on every other row, and on an indented block (which has no
87 /// fence to label). A frontend paints it as a small label on the block's box
88 /// and edits it through a prompt — see [`CodeBlockInfo::lang`]. It's a plain
89 /// display string, not a source slice, so it needs no offset shifting; the
90 /// label re-derives from twig on the next build.
91 pub code_lang: Option<String>,
92 /// This row belongs to a `:::name{.class}` directive container — twig's
93 /// generic fenced-div block, whose meaning is entirely up to the host app
94 /// (diaryx's `:::vis{.audience}` visibility blocks, say). Set on every row
95 /// the `"directive"` arm emits, the same way [`code`](Self::code) marks a
96 /// code block's rows, so a frontend can draw a tinted panel around each
97 /// maximal run of these.
98 pub directive: bool,
99 /// A directive container's space-joined attrs — dot-prefixed classes
100 /// (`.public .family` → `"public family"`) unioned with bare pandoc-style
101 /// words (`public family`, no leading dot — diaryx's other `:::vis{...}`
102 /// convention), carried on the block's *first* row only — the
103 /// [`code_lang`](Self::code_lang) pattern. `None` on every other row, and
104 /// when the directive carries no such attrs. A frontend paints it as a
105 /// small label on the block's panel; it's a plain display string, not a
106 /// source slice, so it rides row reuse untouched.
107 pub directive_label: Option<String>,
108 /// Set on the single placeholder row a block-level image renders to, carrying
109 /// the image's destination and alt text; `None` on every other row. The row's
110 /// glyphs are the default `🖼 alt` label (which a plain surface paints as-is);
111 /// an image-capable frontend reads this to paint the real picture instead,
112 /// skipping the row named by [`MediaInfo::rows_span`]. Like
113 /// [`code_lang`](Self::code_lang) it's plain display strings, not source
114 /// slices, so it rides row reuse and needs no offset shifting; the map's
115 /// [`media`](VisualMap::media) side-table is derived from it once the rows
116 /// are final, the same way [`code_blocks`](VisualMap::code_blocks) is.
117 pub media: Option<MediaMark>,
118 /// Set on the **first** row of a task list item, carrying whether its box is
119 /// ticked; `None` on every other row, including a plain `list_item`'s. The
120 /// row's glyphs already draw the box as `☐ `/`☑ ` in the marker's place, so a
121 /// plain surface needs nothing further; a GUI reads this to paint a real
122 /// checkbox widget and to know which way it is facing.
123 ///
124 /// A `bool` rather than a source span, for the reason
125 /// [`code_lang`](Self::code_lang) is a plain string: it rides [`BlockCache`]
126 /// reuse and [`build_spliced`] untouched, needing no offset shifting. To
127 /// *toggle* the box, a frontend maps its click to a source offset the way it
128 /// maps any other — the marker's glyphs carry the item's own `src` — and
129 /// hands that to [`crate::Doc::toggle_task_at`].
130 pub task: Option<bool>,
131 /// Set on the single placeholder row a **leaf** directive (`::name{…}`)
132 /// renders to, carrying its name and attributes; `None` on every other row.
133 /// The container form isn't this — it wraps real blocks and marks each of
134 /// them [`directive`](Self::directive) instead. Like [`media`](Self::media)
135 /// it's plain display strings, so it rides row reuse untouched, and the map's
136 /// [`directives`](VisualMap::directives) side-table is derived from it once
137 /// the rows are final.
138 pub leaf_directive: Option<DirectiveMark>,
139 /// The heading level (1–6) of the block this row belongs to, on every row a
140 /// `heading` emits (a long one wraps to several) and `None` everywhere else.
141 ///
142 /// A frontend that sizes a whole line — a proportional renderer giving the
143 /// row a bigger line box — needs the level *per row*, and the glyphs can't
144 /// always supply it: an empty heading (`# ` with nothing typed after it,
145 /// which is what the toolbar's H1 leaves on a blank line) has no glyph to
146 /// carry a [`Role::Heading`] at all, so a glyph scan called it body text and
147 /// the line drew at body height until the first character landed. Riding the
148 /// row says it once, for the empty case and the wrapped case alike.
149 ///
150 /// Per-*glyph* styling still comes from [`Role::Heading`] on the glyphs; this
151 /// is the row-level fact, and the two agree wherever a heading has content —
152 /// same `u8` level, clamped the same way [`heading_style`] clamps it.
153 pub heading: Option<u8>,
154 /// How this row's block is aligned across the measure — the author's
155 /// `class="center"`, on every row the block emits and `None` for the
156 /// theme's default, which is left.
157 ///
158 /// A *row* fact and not a glyph one for [`heading`](Self::heading)'s reason,
159 /// and more sharply: alignment is a property of the *line*, not of the
160 /// letters on it, so an empty paragraph the author has just centred has to
161 /// carry it with no glyph to hang it on. It rides the row like a plain
162 /// `Copy` flag, so [`BlockCache`] reuse and [`build_spliced`] carry it
163 /// untouched.
164 ///
165 /// Read from the paragraph's or heading's own attributes and from those of
166 /// every `div` around it, the nearest winning — so `<div class="center">`
167 /// around three paragraphs centres all three, which is what the author of
168 /// that HTML meant.
169 pub align: Option<Align>,
170 /// How far apart this row's block sets its lines, as a multiple of the
171 /// theme's own line height — the author's `data-line-height`, on every row
172 /// the block emits and `None` for the theme's spacing.
173 ///
174 /// A frontend that lays rows out in pixels scales the row's height by
175 /// [`LineSpacing::ratio`]; one that draws a row per terminal line ignores it,
176 /// the way it ignores a heading's size. Read at the same two levels
177 /// [`align`](Self::align) is.
178 pub line_height: Option<LineSpacing>,
179 /// What this row divides, on the blank rows a block boundary is *drawn* with
180 /// and `None` on every other row — including the navigable blank lines of
181 /// preserve-soft flow, which are somewhere text can go rather than a gap
182 /// between blocks. So `boundary.is_some()` is exactly "this row is a drawn
183 /// block boundary", the [`decoration`](Self::decoration) rows that come from
184 /// [`Builder::emit_separators_before`].
185 ///
186 /// It exists because a boundary's *height* is a frontend decision but its
187 /// *kind* is not. Typography spaces a boundary by what it separates — the
188 /// margin above a heading is wider than the one between two paragraphs, so
189 /// the heading groups with the text it introduces — and a frontend that has
190 /// only rows to look at has to re-derive the structure by sniffing glyph
191 /// roles. Three frontends sniffing separately is three chances to disagree
192 /// about the same document. Core already knows, having just walked the AST
193 /// to emit this row, so it says so once here and each frontend multiplies by
194 /// its own spacing.
195 pub boundary: Option<Boundary>,
196 /// The offsets on this row where an inline mark's *content* ends under a
197 /// hidden closing delimiter — the end of the `d` in `**bold**`, one byte
198 /// before the `**` that draws nothing. Each is a caret stop with no glyph
199 /// of its own: the caret standing there is drawn where the next glyph is,
200 /// but typing there extends the mark, where typing past the delimiter
201 /// leaves it. See [`VisualMap::mark_ends`] for the rule.
202 ///
203 /// Source offsets, so [`shift_row`] moves them with the glyphs; empty on
204 /// decoration rows and on every row no mark closes on.
205 pub mark_ends: Vec<usize>,
206}
207
208/// What a drawn block boundary separates: the kinds of the blocks it falls
209/// between — the pair a frontend spaces by.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211pub struct Boundary {
212 pub above: BlockClass,
213 pub below: BlockClass,
214}
215
216/// The block kinds core tells apart when it walks a document — the vocabulary
217/// [`Boundary`] is spelled in. A statement about *structure*, not about how any
218/// of it should look: what a frontend does with "this gap sits above a heading"
219/// is entirely the frontend's.
220///
221/// `Class` rather than `Kind` because [`twig::BlockKind`] already means
222/// something else in this crate's public surface — the *command* vocabulary
223/// (`Paragraph | Heading(n)`) a toolbar passes to [`Doc::set_block`](crate::Doc::set_block).
224/// This is the reverse direction: what a block already *is*, read back off a
225/// rendered row.
226///
227/// [`BlockClass::Other`] is the honest answer for a node kind core doesn't
228/// separate out, so adding one here is additive for every frontend: nothing has
229/// to change until it wants to space that kind differently.
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
231pub enum BlockClass {
232 Paragraph,
233 Heading,
234 /// A whole list. Its *items* are [`BlockClass::ListItem`]; note that core
235 /// draws no boundary row between two items of one list, tight or loose, so
236 /// an item↔item pair never reaches a frontend.
237 List,
238 ListItem,
239 Quote,
240 Code,
241 Table,
242 /// A block-level image, video, or audio.
243 ///
244 /// Never reached through [`from_node_kind`](BlockClass::from_node_kind): a
245 /// block picture is not a node of its own — [`Builder::media_only`] promotes
246 /// the paragraph (or `<picture>`/`<video>` container) wrapping it — so the
247 /// walk only ever sees the wrapper's kind. [`label_media_boundaries`] reads
248 /// it back off the finished rows instead, after the fact.
249 Media,
250 /// A `:::name{.class}` directive container.
251 Directive,
252 Rule,
253 Footnote,
254 Other,
255}
256
257impl BlockClass {
258 /// Classify a twig node kind — the same vocabulary [`Builder::block`]
259 /// matches on, so the two can't drift about what a block is. Both the
260 /// whole-arena walk (which has [`FlatNode`]s) and the incremental top-level
261 /// walk (which has only a query match's kind) reach it by this one door.
262 pub fn from_node_kind(kind: &Kind) -> BlockClass {
263 match kind {
264 Kind::Para => BlockClass::Paragraph,
265 Kind::Heading => BlockClass::Heading,
266 Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
267 Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
268 Kind::BlockQuote => BlockClass::Quote,
269 Kind::CodeBlock => BlockClass::Code,
270 Kind::Table => BlockClass::Table,
271 Kind::Image => BlockClass::Media,
272 // twig 2.8 folded `div`/`span`/`directive`/`element` into one
273 // `container` kind, so a `:::note` panel and a promoted `<video>`
274 // arrive here indistinguishable — telling them apart needs the
275 // node's `origin`, and the incremental walk has only this kind.
276 // `Directive` is the right answer for the case that motivates the
277 // class (nothing else draws a tinted panel) and a harmless one for
278 // the rest: `BlockClass` is descriptive and core never branches on
279 // it. The one case where it was actively wrong — a promoted
280 // `<video>`, which would have been handed to a frontend as something
281 // to draw a fenced-div panel around — is corrected by
282 // [`label_media_boundaries`] once the rows are final, along the same
283 // door as a block image. Anything else that must be exact reads
284 // [`container_is_directive`] off a real node.
285 Kind::Container => BlockClass::Directive,
286 Kind::ThematicBreak => BlockClass::Rule,
287 Kind::Footnote => BlockClass::Footnote,
288 _ => BlockClass::Other,
289 }
290 }
291}
292
293/// The name and attributes a leaf directive's placeholder row carries, so a
294/// frontend that knows the host app's vocabulary can paint the real thing —
295/// an embedded page for diaryx's `::embed{src=…}`, a generated table of
296/// contents for a `::toc`, and the plain `⧉ name` label for one it doesn't
297/// know. The peer of [`MediaMark`], and plain strings for the same reason: they
298/// survive the row shuffling of [`BlockCache`] reuse and [`build_spliced`].
299#[derive(Clone, Debug, PartialEq, Eq)]
300pub struct DirectiveMark {
301 /// The directive's type — `embed`, `toc`, `vis` — with no leading colons.
302 /// Core is agnostic of what it means: the vocabulary is the host app's.
303 pub name: String,
304 /// Its `{…}` attributes as `(key, value)` pairs in source order. A bare
305 /// attribute (`{public}`) has a `None` value, the way twig reports it.
306 pub attrs: Vec<(String, Option<String>)>,
307 /// The directive's `[label]` text, flattened from its inline children, or
308 /// empty when it has none. Also what the placeholder label shows.
309 pub label: String,
310 /// How many visual rows this directive reserves — the label row plus blank
311 /// filler rows below it, so a frontend painting something real has the
312 /// vertical room. `1` is the bare placeholder, and the only value core
313 /// produces today: unlike an image (whose height a terminal frontend
314 /// measures and reports back), nothing has told core how tall an embed is.
315 /// A pixel-laid-out GUI sets its own height regardless.
316 pub rows: usize,
317}
318
319/// What a block-level media placeholder actually is, so a frontend knows which
320/// widget to build over the reserved rows: a raster, a movie player, or a
321/// transport with no picture at all. Core classifies and stops there — it opens
322/// nothing, so this is a statement about the *markup*, not about a file it has
323/// verified exists or can decode.
324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
325pub enum MediaKind {
326 /// A `` / `<img>` / `<picture>` — a still picture.
327 Image,
328 /// An HTML `<video>`. Markdown and Djot spell no video of their own, so this
329 /// only ever arrives through `html_elements` promotion (or a `::video{…}`
330 /// directive a host app maps itself, which core reports as a directive).
331 Video,
332 /// An HTML `<audio>` — a transport with no picture, so a frontend gives it a
333 /// fixed control height rather than measuring an aspect ratio.
334 Audio,
335}
336
337/// Which of the two caret homes a block media has — see
338/// [`VisualMap::block_media_stop`].
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340pub enum MediaStop {
341 /// The stop in front of the picture. What is typed here belongs above it.
342 Before,
343 /// The stop just past it. What is typed here belongs below it.
344 After,
345}
346
347impl MediaKind {
348 /// The emoji a plain surface prefixes the placeholder label with — the
349 /// `🖼`/`🎬`/`🔊` that makes the row read as *a thing* rather than as text.
350 fn sigil(self) -> char {
351 match self {
352 MediaKind::Image => '🖼',
353 MediaKind::Video => '🎬',
354 MediaKind::Audio => '🔊',
355 }
356 }
357}
358
359/// The destination and label a block-level media placeholder row carries, so a
360/// capable frontend can resolve and paint the real thing. Plain strings (no
361/// source offsets), so they survive the row shuffling of [`BlockCache`] reuse
362/// and [`build_spliced`] untouched — see [`VRow::media`].
363#[derive(Clone, Debug, PartialEq, Eq)]
364pub struct MediaMark {
365 /// Whether this is a picture, a movie, or a sound — which widget the
366 /// frontend builds over the reserved rows.
367 pub kind: MediaKind,
368 /// The media's link destination — a path, URL, or `data:` URI, verbatim from
369 /// the AST. A frontend resolves a relative path against the document's
370 /// directory itself; core holds no I/O.
371 ///
372 /// Empty is possible and legal for a `<video>`/`<audio>`, which may carry no
373 /// `src` of its own and name its candidates in child `<source>`s instead —
374 /// unlike an `<img>`, whose `src` *is* the picture. A frontend with an empty
375 /// destination takes its URL from [`sources`](MediaMark::sources).
376 pub destination: String,
377 /// A `<picture>`'s theme/media alternatives, in document order, when this
378 /// block image came from one; empty for a plain `` / bare `<img>`. Each
379 /// is a `<source>`'s media query + candidate URL(s); a frontend that knows its
380 /// theme picks the first whose media matches and falls back to [`destination`]
381 /// (the `<img>`). Core keeps them verbatim and picks nothing — it has no theme.
382 ///
383 /// [`destination`]: MediaMark::destination
384 pub sources: Vec<MediaSource>,
385 /// The media's alt text (its rendered inline children, flattened), or empty
386 /// when it has none. Also what the placeholder label shows. For a `<video>`/
387 /// `<audio>` this is the element's own text content — the "your browser does
388 /// not support…" fallback, which doubles as its accessible name.
389 pub alt: String,
390 /// A `<video poster="…">`'s still frame, verbatim, or empty when there is
391 /// none (and always empty for an image or audio). It is an *image*
392 /// destination, so a frontend already able to draw a picture can show it
393 /// before the movie loads — or in place of one it can't play at all.
394 pub poster: String,
395 /// How many visual rows this media reserves — the placeholder label row plus
396 /// the blank filler rows below it, so a frontend that paints a real raster has
397 /// the vertical room to draw it. `1` is the bare placeholder (a frontend that
398 /// can't draw pictures, or an image it couldn't resolve). A terminal frontend
399 /// asks for as many rows as the fitted picture is tall; the pixel-laid-out GUI
400 /// ignores this and sets its own row height, so it always leaves it `1`. The
401 /// count comes from the frontend (via [`crate::Doc::set_media_rows`]) because
402 /// core does no I/O and can't measure the image itself. See [`VRow::media`].
403 pub rows: usize,
404}
405
406/// One `<source>` under a `<picture>`, `<video>`, or `<audio>`: a candidate URL
407/// plus whichever of the two things HTML lets a `<source>` be chosen by — a
408/// media query (`<picture>`) or a MIME type (`<video>`/`<audio>`). Verbatim from
409/// the AST: core carries the alternatives and resolves none of them, having
410/// neither a theme nor a codec list to judge them by.
411///
412/// The two spellings are normalised onto one field. `<picture>` writes
413/// `srcset`, `<video>`/`<audio>` write `src`; both land in
414/// [`srcset`](MediaSource::srcset), since a frontend wants the URL either way
415/// and only `<picture>` ever uses the descriptor syntax.
416#[derive(Clone, Debug, PartialEq, Eq)]
417pub struct MediaSource {
418 /// The `<source media="…">` query, verbatim (`"(prefers-color-scheme: dark)"`),
419 /// or empty for a `<source>` with no `media` (an unconditional override, and
420 /// the norm for `<video>`/`<audio>`, which pick by codec rather than theme).
421 pub media: String,
422 /// The candidate URL(s): a `<picture>`'s `srcset` verbatim — one URL, or a
423 /// comma-separated candidate list with `1x`/`2x`/width descriptors — or a
424 /// `<video>`/`<audio>` `<source>`'s plain `src`. A frontend takes the first
425 /// URL token; the theme and codec cases both only ever need that.
426 pub srcset: String,
427 /// The `<source type="…">` MIME type (`"video/webm"`), verbatim, or empty
428 /// when the `<source>` declares none. How a `<video>`/`<audio>` frontend
429 /// picks a candidate it can actually decode; a `<picture>`'s sources
430 /// normally leave it empty and are chosen by [`media`](MediaSource::media).
431 pub mime: String,
432}
433
434/// The rendered document plus the offset⇄position mapping the caret rides on.
435#[derive(Clone, Default)]
436pub struct VisualMap {
437 /// The document's **default monospace rendering** — one [`VRow`] of glyphs
438 /// per visual line, tables spelled with box-drawing borders (`│ ─ ┌┬┐…`) and
439 /// cells padded to whole character-cell columns. Any monospace surface can
440 /// draw these verbatim, so a consumer gets a working view for free: the TUI
441 /// paints them as-is, and a five-line plain-text dump would too.
442 ///
443 /// It's a *default*, not the only truth. A frontend with its own geometry —
444 /// a proportional GUI — lays text out in its own units, and for a table
445 /// skips the box-drawn rows named by [`TableInfo::rows_span`] and draws from
446 /// the structural [`TableInfo`] instead. The box glyphs live here rather than
447 /// in a frontend precisely because they *are* a renderable default: unlike a
448 /// colour (a role each surface must map to its own palette — see
449 /// [`crate::style`]), `┌─┐` is finished text that needs no interpretation.
450 pub rows: Vec<VRow>,
451 /// The first source offset that is actually rendered — the caret floor for
452 /// the WYSIWYG view. Non-zero when a leading `metadata` block (YAML/TOML
453 /// frontmatter) is skipped: the frontmatter is preserved in the source and
454 /// editable in the source view, but hidden and unreachable here, so the
455 /// caret and selection can't wander into it (and copy won't grab it).
456 pub content_start: usize,
457 /// Every offset the caret may rest at, ascending and deduplicated: each
458 /// row's stop glyphs plus the row's own end (the "after the last character"
459 /// spot every line needs). Decoration contributes nothing.
460 ///
461 /// Left/Right read this instead of walking the grid, because the grid isn't
462 /// laid out in offset order: a table with wrapped cells puts column 1's
463 /// second line *below* column 2's first, so "the next stop rightward" and
464 /// "the next stop in the document" part ways. Following the document is what
465 /// a caret means — and on every row that *is* in order the two agree anyway,
466 /// so nothing else has to change.
467 stops: Vec<usize>,
468 /// The caret's second home at the end of every hidden inline mark: the
469 /// offset where the mark's content ends, one byte before its closing
470 /// delimiter — ascending and deduplicated, from every row's
471 /// [`VRow::mark_ends`].
472 ///
473 /// With delimiters hidden, `**bold** tail` draws one spot after the `d`
474 /// and the source has two offsets for it: the content end (inside the
475 /// mark, where typing extends the bold) and the byte past the `**` (where
476 /// typing leaves it). Only the second is a glyph's offset, so only it was
477 /// a stop, and a caret asked to rest at the first was snapped a whole
478 /// character back onto the `d` — a drag over `bold` came back one letter
479 /// short. The delete and backspace paths already settle the caret on the
480 /// content end as its natural home there
481 /// ([`crate::Doc::settle_inside_close_delims`]); this makes it one the
482 /// caret can be placed at and step onto too.
483 ///
484 /// Kept apart from [`stops`](Self::stops) rather than merged in, because
485 /// the two lists answer different questions. A stop with no glyph is
486 /// invisible to a walk that pairs stops with characters — a system text
487 /// input counting `position(from:offset:)` steps against the text it was
488 /// shown would drift a character at every mark — and to word motion, which
489 /// classifies a stop by the source byte under it (a `*`). So
490 /// [`stop_after`](Self::stop_after) and its kin walk the glyph stops alone,
491 /// and only the places a caret *rests* — snapping, resting checks, and
492 /// Left/Right — read both.
493 mark_ends: Vec<usize>,
494 /// Every table in the document, in order, described structurally rather than
495 /// drawn — see [`TableInfo`] for why both exist.
496 pub tables: Vec<TableInfo>,
497 /// Every fenced/indented code block, in order, as the range of [`rows`] it
498 /// occupies — a frontend draws one bordered, tinted box around each and
499 /// scrolls it horizontally rather than wrapping. Derived from the per-row
500 /// [`VRow::code`] flag once the rows are final (so it survives incremental
501 /// row reuse), the same way [`collect_stops`] derives the stop table.
502 ///
503 /// [`rows`]: VisualMap::rows
504 pub code_blocks: Vec<CodeBlockInfo>,
505 /// Every block-level image in the document, in order — one per placeholder
506 /// row a frontend replaces with a real picture. Derived from the per-row
507 /// [`VRow::media`] mark once the rows are final (so it survives incremental
508 /// row reuse), the same way [`code_blocks`](VisualMap::code_blocks) is
509 /// derived from [`VRow::code`].
510 pub media: Vec<MediaInfo>,
511 /// Every **leaf** directive in the document, in order — one per placeholder
512 /// row a frontend may replace with whatever the host app's vocabulary makes
513 /// of it. Derived from the per-row [`VRow::leaf_directive`] mark once the
514 /// rows are final, exactly as [`media`](VisualMap::media) is.
515 pub directives: Vec<DirectiveInfo>,
516}
517
518impl VisualMap {
519 pub fn num_rows(&self) -> usize {
520 self.rows.len()
521 }
522
523 /// The width of `row` in display columns — the rightmost column its caret
524 /// can occupy, and so what a goal column is clamped to on the way in.
525 pub fn row_width(&self, row: usize) -> usize {
526 self.rows.get(row).map_or(0, |r| r.width())
527 }
528
529 /// The screen `(row, col)` for a source offset — where to draw the caret:
530 /// the *nearest* stop at or past `off`. Snaps a hidden offset (inside a
531 /// delimiter) to the next visible glyph, and never resolves onto decoration
532 /// (a table border, a cell's padding), which is drawn but holds no caret.
533 ///
534 /// "Nearest" rather than "the first one found" because a table's wrapped
535 /// cells put rows slightly out of offset order: scanning top to bottom, the
536 /// second line of column 1 comes *after* the first line of column 2 but
537 /// holds smaller offsets. Where rows are in order the two rules agree.
538 ///
539 /// A soft wrap is the one place two rows want the same offset: the row above
540 /// ends where the row below opens, the space the wrap ate being drawn on the
541 /// row above and the offset past it being the row below's first character.
542 /// It resolves *downstream*, to the row that character is on — the row
543 /// above's last column is a phantom, a place the caret can be drawn but
544 /// never sent, and resolving upstream into it is what pinned Down at the
545 /// first wrap of a paragraph: it aimed at the row below's column 0, landed
546 /// on the offset it already had, and read that back as the row above's end.
547 pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
548 let mut best: Option<(usize, usize, usize)> = None; // (src, row, col)
549 for (r, row) in self.rows.iter().enumerate() {
550 if row.decoration {
551 continue;
552 }
553 // Offsets ascend *within* a row, so its first stop at or past `off`
554 // is the best this row has to offer.
555 let cand = row
556 .glyphs
557 .iter()
558 .enumerate()
559 .find(|(_, g)| g.stop && g.src >= off)
560 .map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
561 .or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
562 if let Some(c) = cand {
563 // `<=`, so a tie goes to the later row: the only offset two rows
564 // both hold is a wrap boundary, and it belongs to the row below.
565 if best.is_none_or(|b| c.0 <= b.0) {
566 best = Some(c);
567 }
568 }
569 // A row's *first* stop never decreases from one row to the next —
570 // true even across a table's wrapped cells, since a cell's lines run
571 // downward. So once a row opens past the best found so far, no later
572 // row can beat it and the scan stays proportional to `off`.
573 if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
574 && first.src > b.0
575 {
576 break;
577 }
578 }
579 match best {
580 Some((_, r, c)) => (r, c),
581 None => {
582 let r = self.last_stop_row();
583 (r, self.row_width(r))
584 }
585 }
586 }
587
588 /// The rows a source range occupies, inclusive: `(first, last)`.
589 ///
590 /// A *different question* from [`pos_of_offset`](Self::pos_of_offset), which
591 /// is why it can't be spelled with two calls to it. That one answers "where
592 /// does the caret go", and for a caret its forward snap is right — an offset
593 /// inside a hidden delimiter has no column of its own, so the caret belongs
594 /// at the next visible glyph, wherever that turns out to be. This one asks
595 /// "which rows does this block cover", and there the snap is a trap: a
596 /// footnote whose body *ends* in a link (`[^2]: [title](url)`) has a last
597 /// byte inside the hidden destination, so `pos_of_offset(end - 1)` walked
598 /// clean off the note's row and landed on the next note's — and a peek
599 /// slicing `first..=last` out of the frame drew two notes where the reader
600 /// asked for one. Every block ending in a link, an image, or any trailing
601 /// hidden markup had the same fault; only a block ending in visible text
602 /// (which is what the tests happened to use) did not.
603 ///
604 /// `row.end_src` is no help either: it is where the *rendered* text of a row
605 /// ends, not how far into the source the block reaches, and redefining it
606 /// would move every end-of-line caret.
607 ///
608 /// So the last row is found by asking which rows *open* before the range
609 /// does, rather than by mapping its last byte: a row belongs to the range
610 /// when its first caret stop lies before `range.end`. Decoration is skipped
611 /// (a drawn gap between blocks is not part of either), and the answer is
612 /// never shorter than one row — a range whose every byte is hidden still
613 /// covers the row it started on.
614 pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
615 if self.rows.is_empty() {
616 return (0, 0);
617 }
618 let first = self.pos_of_offset(range.start).0;
619 let mut last = first;
620 for (r, row) in self.rows.iter().enumerate().skip(first) {
621 if row.decoration {
622 continue;
623 }
624 let open = row
625 .glyphs
626 .iter()
627 .find(|g| g.stop)
628 .map_or(row.end_src, |g| g.src);
629 if open >= range.end {
630 // A row's first stop never decreases from one row to the next —
631 // the invariant `pos_of_offset` breaks on, true even across a
632 // table's wrapped cells — so nothing below can be in range.
633 break;
634 }
635 last = r;
636 }
637 (first, last)
638 }
639
640 /// The source offset of the task checkbox drawn at `(row, col)`, or `None`
641 /// when that cell holds no box — the hit-test a frontend runs on a click
642 /// before treating it as a tick rather than a caret placement.
643 ///
644 /// Only the box's own cells answer. Clicking an item's *text* places the
645 /// caret like any other click, so the box is a target aimed at rather than
646 /// something tripped over while editing — which is also why this is a
647 /// separate question from [`offset_of_pos`](Self::offset_of_pos) instead of
648 /// a flag on the offset it returns.
649 pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
650 let r = self.rows.get(row)?;
651 self.task_box_at_glyph(row, r.glyph_at_col(col)?)
652 }
653
654 /// [`task_box_at`](Self::task_box_at) keyed by glyph index rather than
655 /// display column — for a frontend that shapes its own rows (the GUI) and so
656 /// resolves a click to a glyph before it ever has a column.
657 pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
658 let r = self.rows.get(row)?;
659 r.task?;
660 let g = r.glyphs.get(glyph)?;
661 (g.style.role == Role::ListMarker).then_some(g.src)
662 }
663
664 /// The source offset for a screen `(row, col)` — where a click or a
665 /// visual-space move lands the caret. Clicking decoration maps through its
666 /// `src`, which points at the text it decorates, so a click on a border or
667 /// on a cell's padding lands in that cell.
668 ///
669 /// The inverse of [`pos_of_offset`](Self::pos_of_offset), which it has to
670 /// agree with: `col` is a display column, and the one it names may be the
671 /// far cell of a wide glyph — [`VRow::glyph_at_col`] is where that lands.
672 pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
673 let Some(r) = self.rows.get(row) else {
674 // A click or drag below the last row — a short document with empty
675 // space under it, dragged into to extend a selection. Land on the
676 // document's last caret stop (its end), not offset 0: jumping the
677 // caret to the top is the wrong direction, and 0 isn't even a stop
678 // when the document opens on hidden frontmatter or a `# ` marker, so
679 // returning it would leave the caret where it draws in one place and
680 // types in another (`move_to` would then clamp it onto the unhomeable
681 // frontmatter floor). `None` only for a document with no stops at all
682 // (empty), where the caret has nowhere to be but 0.
683 return self.stops.last().copied().unwrap_or(0);
684 };
685 match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
686 // A glyph that holds no caret is clickable, but where it points
687 // isn't always somewhere the caret can be: the blank gap between two
688 // paragraphs stands at an offset that belongs to neither of them,
689 // and the tail of a grapheme cluster stands inside a character.
690 // Land on the nearest real stop instead of handing back an offset
691 // that looks like the gap but types into the paragraph above.
692 Some(g) if !g.stop => self.nearest_stop(g.src),
693 Some(g) => g.src,
694 // A row's end is a stop by construction — unless the row is
695 // decoration, which contributes none.
696 None if r.decoration => self.nearest_stop(r.end_src),
697 None => r.end_src,
698 }
699 }
700
701 /// Which of a block media's two caret homes `off` is, or `None` for every
702 /// other offset in the document.
703 ///
704 /// [`block_media`](Builder::block_media) gives a block-level image, video, or
705 /// audio exactly two stops — one in front of it and one just past it — and
706 /// nothing inside the markup. Both are ordinary offsets to everything else in
707 /// core, but they are the two places where inserting text would *dissolve the
708 /// picture*: `` with anything typed against it is no longer a block
709 /// image but a paragraph with an inline one, and the frontend that was
710 /// painting a photo there paints a text run instead. A caller that is about to
711 /// insert asks this so it can open a paragraph first — see
712 /// [`Doc::insert`](crate::Doc::insert).
713 ///
714 /// An *inline* image reports `None`: it has no placeholder row and no stops of
715 /// its own, and typing beside one is ordinary editing.
716 ///
717 /// Answers with the media's own source span as well, since a caller that has
718 /// to keep the picture whole usually has to address it — [`Doc::backspace`]
719 /// takes the picture out in one piece rather than nibbling a byte off its
720 /// markup, which is the same dissolution from the other side.
721 ///
722 /// [`Doc::backspace`]: crate::Doc::backspace
723 pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
724 for m in &self.media {
725 let Some(row) = self.rows.get(m.rows_span.start) else {
726 continue;
727 };
728 // Every glyph of the `🖼 alt` label maps to the media's start offset;
729 // the row's end is past its markup. Read the start off the label
730 // rather than the first glyph, which on a quoted or listed picture is
731 // the block prefix and points at the gutter.
732 let Some(start) = row
733 .glyphs
734 .iter()
735 .find(|g| g.style.role == Role::Image)
736 .map(|g| g.src)
737 else {
738 continue;
739 };
740 if off == start {
741 return Some((MediaStop::Before, start..row.end_src));
742 }
743 if off == row.end_src {
744 return Some((MediaStop::After, start..row.end_src));
745 }
746 }
747 None
748 }
749
750 /// Whether `off` is a table's trailing caret stop — the one home past a
751 /// table's last cell, at the block's own end ([`TableInfo::end_src`]).
752 ///
753 /// The table's peer of [`block_media_stop`](Self::block_media_stop)'s
754 /// `After`: text inserted at that offset joins the table's last source
755 /// line, and a line glued under a table is a row of it (`| 1 | 2 |x`), so
756 /// a caller about to insert there opens a paragraph first — see
757 /// [`Doc::insert`](crate::Doc::insert). Nothing else about the offset is
758 /// special: it is where Down from the last row lands and where a click in
759 /// the blank space under a trailing table lands.
760 pub fn table_end_stop(&self, off: usize) -> bool {
761 self.tables.iter().any(|t| t.end_src == off)
762 }
763
764 /// Snap `off` to the nearest caret stop — the funnel a frontend that
765 /// hit-tests pixels straight to a source offset must run its result through.
766 /// A click or drag can land in the blank gap a paragraph break is drawn with,
767 /// or inside a hidden delimiter; both are offsets the caret can't rest at, so
768 /// resting there would draw the caret in one place and type in another. This
769 /// settles it on a real caret home instead. Idempotent on an offset that is
770 /// already a stop — the `(row, col)` click path already snaps this way inside
771 /// [`offset_of_pos`](Self::offset_of_pos), and this gives the pixel path the
772 /// same guarantee. Returns `off` unchanged only for an empty document (no
773 /// stops at all).
774 pub fn snap_to_stop(&self, off: usize) -> usize {
775 self.nearest_stop(off)
776 }
777
778 /// The caret stop nearest `off`, preferring the one before it when `off`
779 /// falls exactly between two. Returns `off` unchanged if there are no stops
780 /// at all (an empty document). A mark's content end counts: it is a place
781 /// the caret rests, and the one a drag ending on a marked word means.
782 fn nearest_stop(&self, off: usize) -> usize {
783 let before = Self::last_at_or_before(&self.stops, off)
784 .max(Self::last_at_or_before(&self.mark_ends, off));
785 let after = match (
786 Self::first_at_or_after(&self.stops, off),
787 Self::first_at_or_after(&self.mark_ends, off),
788 ) {
789 (Some(a), Some(b)) => Some(a.min(b)),
790 (a, b) => a.or(b),
791 };
792 match (before, after) {
793 (Some(b), Some(a)) if off - b <= a - off => b,
794 (_, Some(a)) => a,
795 (Some(b), None) => b,
796 (None, None) => off,
797 }
798 }
799
800 /// The glyph stop nearest `off` — [`nearest_stop`](Self::nearest_stop)
801 /// for a walk that pairs stops with characters, which a mark's content
802 /// end has none of. A caret resting on one resolves to the glyph stop
803 /// drawn at the same spot, the one just past the hidden delimiter, so the
804 /// text a system input is shown from there and the steps it counts agree.
805 pub fn snap_to_glyph_stop(&self, off: usize) -> usize {
806 if self.mark_ends.binary_search(&off).is_ok()
807 && let Some(next) = Self::first_at_or_after(&self.stops, off)
808 {
809 return next;
810 }
811 let before = Self::last_at_or_before(&self.stops, off);
812 let after = Self::first_at_or_after(&self.stops, off);
813 match (before, after) {
814 (Some(b), Some(a)) if off - b <= a - off => b,
815 (_, Some(a)) => a,
816 (Some(b), None) => b,
817 (None, None) => off,
818 }
819 }
820
821 /// The last of `sorted` at or before `off`, if any.
822 fn last_at_or_before(sorted: &[usize], off: usize) -> Option<usize> {
823 let i = sorted.partition_point(|&s| s <= off);
824 i.checked_sub(1).map(|i| sorted[i])
825 }
826
827 /// The first of `sorted` at or after `off`, if any.
828 fn first_at_or_after(sorted: &[usize], off: usize) -> Option<usize> {
829 let i = sorted.partition_point(|&s| s < off);
830 sorted.get(i).copied()
831 }
832
833 /// The next place the caret rests past `off` — the next glyph stop or the
834 /// next mark's content end, whichever comes first. What Right walks:
835 /// leaving `**bold**` from the `d` is two presses, one onto the end of the
836 /// bold (still bold, the toolbar lit) and one past its delimiter, at the
837 /// same spot on screen. [`stop_after`](Self::stop_after) is the walk that
838 /// skips the first, for every caller that pairs stops with characters.
839 pub fn caret_stop_after(&self, off: usize) -> Option<usize> {
840 match (
841 self.stop_after(off),
842 Self::first_at_or_after(&self.mark_ends, off + 1),
843 ) {
844 (Some(a), Some(b)) => Some(a.min(b)),
845 (a, b) => a.or(b),
846 }
847 }
848
849 /// The previous place the caret rests before `off` — the mirror of
850 /// [`caret_stop_after`](Self::caret_stop_after), what Left walks.
851 pub fn caret_stop_before(&self, off: usize) -> Option<usize> {
852 self.stop_before(off).max(
853 off.checked_sub(1)
854 .and_then(|o| Self::last_at_or_before(&self.mark_ends, o)),
855 )
856 }
857
858 /// Whether the caret can occupy `row` at all: decoration rows (a table's
859 /// border rules) are stepped over by vertical motion.
860 pub fn row_is_navigable(&self, row: usize) -> bool {
861 self.rows.get(row).is_some_and(|r| !r.decoration)
862 }
863
864 /// The first offset the caret can rest at on `row` — its first stop, or the
865 /// row's own end when it holds no text (an empty paragraph). `None` for a
866 /// decoration row, which holds no caret at all.
867 ///
868 /// Not `offset_of_pos(row, 0)`: column 0 of a quoted or listed row is the
869 /// gutter, and a gutter's `src` points at the *block* it opens, so the stop
870 /// nearest it is the one on the block's first row rather than on this one.
871 /// Which is right for a click — the gutter decorates the whole block — and
872 /// wrong for Home, whose whole question is where *this* row starts.
873 pub fn row_start(&self, row: usize) -> Option<usize> {
874 let r = self.rows.get(row).filter(|r| !r.decoration)?;
875 Some(
876 r.glyphs
877 .iter()
878 .find(|g| g.stop)
879 .map_or(r.end_src, |g| g.src),
880 )
881 }
882
883 /// The last row the caret can rest on — the fallback when an offset is past
884 /// everything rendered (a table's bottom border must not swallow the caret).
885 fn last_stop_row(&self) -> usize {
886 (0..self.rows.len())
887 .rev()
888 .find(|&r| self.row_is_navigable(r))
889 .unwrap_or(0)
890 }
891
892 /// The nearest row above `row` the caret can occupy, skipping decoration.
893 pub fn navigable_above(&self, row: usize) -> Option<usize> {
894 (0..row.min(self.rows.len()))
895 .rev()
896 .find(|&r| self.row_is_navigable(r))
897 }
898
899 /// The nearest row below `row` the caret can occupy, skipping decoration.
900 pub fn navigable_below(&self, row: usize) -> Option<usize> {
901 ((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
902 }
903
904 /// The caret stop just before `off` — one press of Left. `None` at the
905 /// first stop in the document.
906 ///
907 /// Runs of decoration (a table border, a cell's alignment padding) are
908 /// stepped over in a single press: they hold no stop, so they aren't in the
909 /// table to land on.
910 pub fn stop_before(&self, off: usize) -> Option<usize> {
911 let i = self.stops.partition_point(|&s| s < off);
912 i.checked_sub(1).map(|i| self.stops[i])
913 }
914
915 /// The caret stop just after `off` — one press of Right. `None` at the last
916 /// stop in the document.
917 pub fn stop_after(&self, off: usize) -> Option<usize> {
918 let i = self.stops.partition_point(|&s| s <= off);
919 self.stops.get(i).copied()
920 }
921
922 /// The first caret stop at or past `off` — where the caret at a hidden
923 /// offset is *drawn*, and so where a rightward walk over the rendered text
924 /// starts from.
925 pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
926 let i = self.stops.partition_point(|&s| s < off);
927 self.stops.get(i).copied()
928 }
929
930 /// The last caret stop at or before `off` — where a leftward walk starts
931 /// from. Snapping the way the walk is headed, rather than always forward,
932 /// is what keeps a leftward motion from ever moving the caret right.
933 pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
934 let i = self.stops.partition_point(|&s| s <= off);
935 i.checked_sub(1).map(|i| self.stops[i])
936 }
937
938 /// Whether the caret may rest at `off` — the invariant every motion in this
939 /// view has to leave standing. A glyph stop, a row's end, or a hidden
940 /// mark's content end ([`mark_ends`](Self::mark_ends)).
941 pub fn is_stop(&self, off: usize) -> bool {
942 self.stops.binary_search(&off).is_ok() || self.mark_ends.binary_search(&off).is_ok()
943 }
944
945 /// The visible text a caret crosses walking rightward from `from` up to
946 /// (but not including) `to` — `UITextInput.text(in:)`'s `[from, to)` in
947 /// *this* view. A hidden inline-mark delimiter (`**`, `` ` ``, `_`, an
948 /// escape backslash) never got a glyph in the first place — see
949 /// [`push_text`]/[`synth`] — so it contributes nothing; what's left is
950 /// what's drawn on screen for that span, one character per caret stop.
951 ///
952 /// **Exactly one character per stop** is the contract, and it is the
953 /// system text input's, not a nicety: `UITextInput`'s tokenizer reads a
954 /// window of this text around a tap, indexes into it by the integer
955 /// `offset(from:to:)` reports (`distance_offset` in `leaf-ffi`, a count of
956 /// [`stop_after`](Self::stop_after) hops), finds a word boundary at some
957 /// character index, and hands the delta back through
958 /// `position(from:offset:)`, which hops stops again. If the text ever
959 /// spends a character on something that is not a stop, or a stop on
960 /// nothing, every index past that point is off by one and the word the
961 /// reader double-tapped comes back shifted — into the header row of a
962 /// table, or one letter short. So a stop that draws a glyph is spelled
963 /// as that glyph, and a stop that draws none is spelled `'\n'`:
964 ///
965 /// - a row's own end stop ([`VRow::end_src`]) — the caret home past a
966 /// paragraph's, heading's, list item's, or code line's last glyph. This
967 /// is also what keeps two blocks' words apart: without it the last word
968 /// of one paragraph and the first of the next read as one run of
969 /// letters (`"…edb\n\nhello\n"` came back as `"edbhello"`), and the
970 /// tokenizer selected across the boundary. A list item's end is a
971 /// row end like any other, though no blank gap row follows it.
972 /// - a table cell's end, which [`push_table_row`] draws as the gutter
973 /// space before the next `│` so the caret has somewhere to stand past
974 /// the cell's last character. To a reader of *this* text a cell ends a
975 /// line: spelled as a space, a touch surface that lands a tap at a
976 /// word's end past the space that follows it stepped into the next
977 /// cell — or the next row, from the last column.
978 ///
979 /// A hidden mark's content end ([`mark_ends`](Self::mark_ends)) is a place
980 /// the caret rests but not a stop the walks above count, so it has no
981 /// character here either; `from` is snapped to the glyph stop drawn at
982 /// the same spot first, exactly as [`snap_to_glyph_stop`] does for those
983 /// walks. `to` is left as given, so a stop landing exactly on it is still
984 /// excluded — the same half-open range `distance_offset`'s loop counts.
985 ///
986 /// [`push_table_row`]: Builder::push_table_row
987 /// [`snap_to_glyph_stop`]: Self::snap_to_glyph_stop
988 pub fn visible_text(&self, from: usize, to: usize) -> String {
989 self.visible_items(from, to)
990 .into_iter()
991 .map(|(_, ch)| ch.unwrap_or('\n'))
992 .collect()
993 }
994
995 /// The UTF-16 length of `visible_text(from, to)` — what an `NSRange`
996 /// location into that text is, without building the string.
997 ///
998 /// AppKit's `NSTextInputClient` and `NSAccessibility` speak in UTF-16
999 /// units of *the text as the system sees it*, which for leaf is the visible
1000 /// text — delimiters hidden. A frontend reporting its selection to the
1001 /// system converts each end with this and gets back an index into the
1002 /// string `visible_text(0, end)` returns, which is exactly what the system
1003 /// will index into.
1004 pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
1005 self.visible_items(from, to)
1006 .into_iter()
1007 .map(|(_, ch)| ch.map_or(1, char::len_utf16))
1008 .sum()
1009 }
1010
1011 /// The inverse of `visible_utf16_len(0, ·)`: the source offset of the
1012 /// visible character a UTF-16 index into `visible_text(0, to)` lands on.
1013 ///
1014 /// An index inside a surrogate pair resolves to the character that owns
1015 /// it; one at or past the end of the text returns `None`, so a caller can
1016 /// substitute the document's end stop. The `\n` a row's or a cell's end
1017 /// is spelled with resolves to that end stop — a caret home, so a caller
1018 /// placing a caret there needs no snap.
1019 pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
1020 let mut seen = 0usize;
1021 for (src, ch) in self.visible_items(0, to) {
1022 let len = ch.map_or(1, char::len_utf16);
1023 if index < seen + len {
1024 return Some(src);
1025 }
1026 seen += len;
1027 }
1028 None
1029 }
1030
1031 /// The items `visible_text` spells, in order — one per caret stop in
1032 /// `[from, to)`, keyed by the stop's source offset: the glyph it draws
1033 /// (`Some`), or `None` for a stop with no character of its own, which the
1034 /// text spells `'\n'`. See [`visible_text`](Self::visible_text) for which
1035 /// stops those are and why.
1036 fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
1037 let from = self.snap_to_glyph_stop(from);
1038 let lo = self.stops.partition_point(|&s| s < from);
1039 // The document's last stop is the end of the text, not a character in
1040 // it: `distance_offset` has no hop past it to pair one with.
1041 let last = self.stops.len().saturating_sub(1);
1042 let hi = self.stops.partition_point(|&s| s < to).min(last).max(lo);
1043 let stops = &self.stops[lo..hi];
1044
1045 // The glyph each stop draws — the first at its offset in row order,
1046 // since a media row's label glyphs all share the media's offset and a
1047 // wrapped line's end is the next line's first glyph. Sorted because
1048 // row order only follows source order outside a table's wrapped
1049 // cells (see `pos_of_offset`); the sort is stable, so "first" holds.
1050 let mut glyphs: Vec<(usize, char)> = self
1051 .rows
1052 .iter()
1053 .filter(|r| !r.decoration)
1054 .flat_map(|r| r.glyphs.iter())
1055 .filter(|g| g.stop && g.src >= from && g.src < to)
1056 .map(|g| (g.src, g.ch))
1057 .collect();
1058 glyphs.sort_by_key(|&(src, _)| src);
1059 glyphs.dedup_by_key(|&mut (src, _)| src);
1060
1061 // A cell's end stop has a glyph (the gutter space) but is spelled as
1062 // a line end; the structural grid is where the cells' offsets live.
1063 let mut cell_ends: Vec<usize> = self
1064 .tables
1065 .iter()
1066 .flat_map(|t| t.grid.iter())
1067 .flat_map(|r| r.cells.iter())
1068 .map(|c| c.end)
1069 .filter(|&e| e >= from && e < to)
1070 .collect();
1071 cell_ends.sort_unstable();
1072 cell_ends.dedup();
1073
1074 let mut gi = 0;
1075 stops
1076 .iter()
1077 .map(|&s| {
1078 while gi < glyphs.len() && glyphs[gi].0 < s {
1079 gi += 1;
1080 }
1081 let ch = match glyphs.get(gi) {
1082 Some(&(src, ch)) if src == s && cell_ends.binary_search(&s).is_err() => {
1083 Some(ch)
1084 }
1085 _ => None,
1086 };
1087 (s, ch)
1088 })
1089 .collect()
1090 }
1091}
1092
1093/// Collect the caret stops of a laid-out grid: every stop glyph's offset plus
1094/// every row's end, ascending and deduplicated. Duplicates are the norm rather
1095/// than the exception — a wrapped line's end is the same offset as the next
1096/// line's first glyph — and collapsing them is what makes one press of Left or
1097/// Right cross exactly one stop.
1098fn collect_stops(rows: &[VRow]) -> Vec<usize> {
1099 let mut stops: Vec<usize> = rows
1100 .iter()
1101 .filter(|r| !r.decoration)
1102 .flat_map(|r| {
1103 r.glyphs
1104 .iter()
1105 .filter(|g| g.stop)
1106 .map(|g| g.src)
1107 .chain(std::iter::once(r.end_src))
1108 })
1109 .collect();
1110 stops.sort_unstable();
1111 stops.dedup();
1112 stops
1113}
1114
1115/// Collect every row's [`VRow::mark_ends`] into one ascending, deduplicated
1116/// table — the peer of [`collect_stops`] for the caret's second home at the
1117/// end of a hidden mark. A mark that closes at a row's end coincides with the
1118/// row's own end stop; that offset is in both tables, and harmlessly so.
1119fn collect_mark_ends(rows: &[VRow]) -> Vec<usize> {
1120 let mut ends: Vec<usize> = rows
1121 .iter()
1122 .filter(|r| !r.decoration)
1123 .flat_map(|r| r.mark_ends.iter().copied())
1124 .collect();
1125 ends.sort_unstable();
1126 ends.dedup();
1127 ends
1128}
1129
1130/// Group the rows tagged [`VRow::code`] into one [`CodeBlockInfo`] per maximal
1131/// run — the block-level view a frontend needs to box and scroll each code
1132/// block. Two code blocks are always parted by the blank separator row a block
1133/// boundary is spelled with (never itself a code row), so a contiguous run is
1134/// exactly one block. Derived from the final rows rather than tracked through
1135/// the builder so it comes out right no matter how [`build_cached`] and
1136/// [`build_spliced`] shuffle rows around.
1137fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
1138 let mut blocks = Vec::new();
1139 let mut start: Option<usize> = None;
1140 for (i, row) in rows.iter().enumerate() {
1141 match (row.code, start) {
1142 (true, None) => start = Some(i),
1143 (false, Some(s)) => {
1144 blocks.push(CodeBlockInfo {
1145 rows_span: s..i,
1146 lang: rows[s].code_lang.clone(),
1147 });
1148 start = None;
1149 }
1150 _ => {}
1151 }
1152 }
1153 if let Some(s) = start {
1154 blocks.push(CodeBlockInfo {
1155 rows_span: s..rows.len(),
1156 lang: rows[s].code_lang.clone(),
1157 });
1158 }
1159 blocks
1160}
1161
1162/// The value of `node`'s `key` attribute, if it carries one *with* a value. A
1163/// bare attribute (`controls`, `muted`) has a `None` value and so reads as
1164/// absent here — a caller wanting presence-not-value tests the list directly.
1165/// Shared by the media element and `<source>` readers.
1166fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
1167 node.attrs
1168 .iter()
1169 .find(|(k, _)| k == key)
1170 .and_then(|(_, v)| v.clone())
1171}
1172
1173/// Collect one [`MediaInfo`] per row carrying a [`VRow::media`] mark — the
1174/// block-level view a frontend needs to replace each placeholder row with a real
1175/// picture. The mark rides the block's *first* row and names how many rows the
1176/// media reserves ([`MediaMark::rows`]); the rows below it are blank
1177/// [`decoration`](VRow::decoration) fillers that hold the vertical space and no
1178/// caret. So the span runs from the marked row across those fillers. Derived from
1179/// the final rows rather than tracked through the builder so it survives however
1180/// [`build_cached`] and [`build_spliced`] shuffle rows around.
1181fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
1182 rows.iter()
1183 .enumerate()
1184 .filter_map(|(i, row)| {
1185 row.media.as_ref().map(|m| MediaInfo {
1186 rows_span: i..i + m.rows.max(1),
1187 kind: m.kind,
1188 destination: m.destination.clone(),
1189 sources: m.sources.clone(),
1190 alt: m.alt.clone(),
1191 poster: m.poster.clone(),
1192 })
1193 })
1194 .collect()
1195}
1196
1197/// Re-label the drawn block boundaries either side of a block-level media
1198/// placeholder, so the pair a frontend spaces by names the picture.
1199///
1200/// [`BlockClass::from_node_kind`] classifies the node the walk is standing on,
1201/// and a block image is never a node of its own: [`Builder::media_only`] promotes
1202/// its *wrapper* — a `paragraph`, or the `container` a `<video>`/`<audio>`
1203/// arrives as — so the gap above a picture reported [`BlockClass::Paragraph`] and
1204/// the gap above a movie reported [`BlockClass::Directive`], the class a frontend
1205/// paints a tinted panel for. [`BlockClass::Media`] was unreachable in
1206/// consequence: the vocabulary named a kind no frontend could ever be told about.
1207///
1208/// Done as a pass over the finished rows rather than inside the walk because
1209/// only the rows know. The incremental top-level walk carries no node arena at
1210/// all (`nodes: &[]`) and can classify by kind alone, so teaching the wrapper
1211/// promotion to the whole-arena walk would label the full and incremental builds
1212/// differently — the exact drift that walk's own comment forbids. Both builds
1213/// emit the same [`VRow::media`] marks, so both reach the same answer here. The
1214/// [`media_spans`] / [`code_block_spans`] pattern.
1215///
1216/// One gap can be spelled with several rows — [`Builder::emit_separators_before`]
1217/// draws the row that closes the block above and the row that opens the block
1218/// below, with any extra blank source lines navigable between them — and gives
1219/// every one of them the same [`Boundary`]. So the walk crosses those navigable
1220/// blanks and relabels the whole run, stopping at the first row that is neither.
1221fn label_media_boundaries(rows: &mut [VRow]) {
1222 let spans: Vec<Range<usize>> = rows
1223 .iter()
1224 .enumerate()
1225 .filter_map(|(i, row)| row.media.as_ref().map(|m| i..i + m.rows.max(1)))
1226 .collect();
1227 // A row inside one gap: a drawn boundary to relabel, or one of the navigable
1228 // blank lines sitting between two drawn ones. Anything else ends the run.
1229 fn in_gap(row: &VRow) -> bool {
1230 row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
1231 }
1232 for span in spans {
1233 for i in (0..span.start).rev() {
1234 if !in_gap(&rows[i]) {
1235 break;
1236 }
1237 if let Some(b) = rows[i].boundary.as_mut() {
1238 b.below = BlockClass::Media;
1239 }
1240 }
1241 for row in rows.iter_mut().skip(span.end) {
1242 if !in_gap(row) {
1243 break;
1244 }
1245 if let Some(b) = row.boundary.as_mut() {
1246 b.above = BlockClass::Media;
1247 }
1248 }
1249 }
1250}
1251
1252/// Collect one [`DirectiveInfo`] per row carrying a [`VRow::leaf_directive`]
1253/// mark — the block-level view a frontend needs to replace each placeholder row
1254/// with whatever the directive means to it. The peer of [`media_spans`], derived
1255/// from the final rows for the same reason: it survives however [`build_cached`]
1256/// and [`build_spliced`] shuffle rows around.
1257fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
1258 rows.iter()
1259 .enumerate()
1260 .filter_map(|(i, row)| {
1261 row.leaf_directive.as_ref().map(|m| DirectiveInfo {
1262 rows_span: i..i + m.rows.max(1),
1263 name: m.name.clone(),
1264 attrs: m.attrs.clone(),
1265 label: m.label.clone(),
1266 })
1267 })
1268 .collect()
1269}
1270
1271/// The source range of a fenced code block's info string — everything on the
1272/// opening line past the fence (`` ```rust `` → the `rust`). `block_start` is the
1273/// code block node's `span.start`. `None` for an indented code block, which
1274/// opens with no fence to carry one. The range is empty for a fence written
1275/// bare (`` ``` `` alone), which is exactly where a language would be inserted.
1276///
1277/// Shared by the WYSIWYG builder (to label the box) and [`crate::Doc`] (to edit
1278/// the label through a prompt), so the two agree on where the language lives.
1279pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
1280 let rest = source.get(block_start..)?;
1281 let line_len = rest.find('\n').unwrap_or(rest.len());
1282 let line = &rest[..line_len];
1283 // A fence may be indented up to three spaces; past that it opens with a run
1284 // of the same fence character.
1285 let indent = line.len() - line.trim_start().len();
1286 if indent > 3 {
1287 return None;
1288 }
1289 let fence = line[indent..].chars().next()?;
1290 if fence != '`' && fence != '~' {
1291 return None; // an indented block, not a fenced one
1292 }
1293 let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
1294 let info_start = block_start + indent + fence_len;
1295 Some(info_start..block_start + line_len)
1296}
1297
1298/// A fenced code block's language for display: its info string, trimmed, or
1299/// `None` when there's no fence or the fence carries no language. The trimmed
1300/// text is what a frontend labels the box with; [`code_info_span`] is what an
1301/// edit replaces.
1302pub fn code_language(source: &str, block_start: usize) -> Option<String> {
1303 let span = code_info_span(source, block_start)?;
1304 let text = source.get(span)?.trim();
1305 (!text.is_empty()).then(|| text.to_string())
1306}
1307
1308/// A horizontal rule's dash count when the map isn't wrapping to a column grid
1309/// (the GUI, which wraps at pixel width): a fixed, sane width the frontend can
1310/// paint or re-wrap, instead of a runaway count from an unbounded wrap width.
1311const UNWRAPPED_RULE_WIDTH: usize = 40;
1312
1313/// Render the document to a [`VisualMap`]. `wrap` is the column budget for
1314/// word-wrapping (`Some` for the monospace TUI), or `None` to emit one row per
1315/// block — the GUI does its own proportional pixel wrapping over these rows.
1316/// Text and offsets come from the AST (`str` nodes carry the verbatim source
1317/// slice and an exact span), so the original source string isn't needed here.
1318pub fn build(
1319 nodes: &[FlatNode],
1320 source: &str,
1321 wrap: Option<usize>,
1322 preserve_soft: bool,
1323 media_rows: &HashMap<String, usize>,
1324 reveal: Option<Range<usize>>,
1325) -> VisualMap {
1326 let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
1327 return VisualMap::default();
1328 };
1329 let top = top_level(nodes, doc);
1330 let mut b = Builder {
1331 nodes,
1332 source,
1333 wrap: wrap.map(|w| w.max(8)),
1334 rows: Vec::new(),
1335 tables: Vec::new(),
1336 last_off: 0,
1337 stepped_over: 0,
1338 media_rows,
1339 break_glyph: Cell::new(' '),
1340 preserve_soft,
1341 reveal: reveal.clone(),
1342 pending_mark_ends: RefCell::new(Vec::new()),
1343 presentation: Presentation::default(),
1344 };
1345 let last_drawn = b.top_blocks(&top);
1346 // The hidden frontmatter's end is the baseline for both the trailing blank
1347 // rows and the caret floor — see [`hidden_prefix_end`]. `top_level` has
1348 // already dropped every `metadata` child, so read it off the arena.
1349 let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
1350 b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
1351 let content_start = top.first().map_or(hidden_end, |&i| nodes[i].span.start);
1352 let stops = collect_stops(&b.rows);
1353 let mark_ends = collect_mark_ends(&b.rows);
1354 label_media_boundaries(&mut b.rows);
1355 let code_blocks = code_block_spans(&b.rows);
1356 let media = media_spans(&b.rows);
1357 let directives = directive_spans(&b.rows);
1358 VisualMap {
1359 rows: b.rows,
1360 content_start,
1361 stops,
1362 mark_ends,
1363 tables: b.tables,
1364 code_blocks,
1365 media,
1366 directives,
1367 }
1368}
1369
1370/// Like [`build`], but reuses a persistent [`BlockCache`] so an edit re-renders
1371/// only the top-level blocks whose source bytes changed *and* marshals only
1372/// those blocks from twig instead of the whole arena.
1373///
1374/// `top` is the document's top-level blocks — twig's `child_spans` of the doc
1375/// root: `(node_id, kind, span)` for each, in order. `fetch_subtree(node_id)`
1376/// marshals one block's subtree (local-indexed, root at 0) and is called *only*
1377/// for a block that missed the cache, i.e. one that actually changed. So a
1378/// keystroke marshals one small subtree, not ~20k nodes. The result is
1379/// byte-for-byte identical to [`build`] on the same document (the
1380/// `build_cached_matches_build` test pins this); [`build`] stays the cache-free,
1381/// whole-arena reference. This is the entry point [`crate::Doc`] uses.
1382// One builder, and every one of these is a distinct input to the same layout
1383// pass — a struct of them would be built at the one call site and unpacked
1384// here, which is the same arguments with an extra name in the way.
1385#[allow(clippy::too_many_arguments)]
1386pub fn build_cached(
1387 top: &[QueryMatch],
1388 source: &str,
1389 wrap: Option<usize>,
1390 preserve_soft: bool,
1391 media_rows: &HashMap<String, usize>,
1392 reveal: Option<Range<usize>>,
1393 cache: &mut BlockCache,
1394 mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1395) -> VisualMap {
1396 let wrap = wrap.map(|w| w.max(8));
1397
1398 // Wrapping is a function of the width, so a width change makes every cached
1399 // row's wrap wrong: start the cache over.
1400 if cache.wrap != Some(wrap) {
1401 cache.entries.clear();
1402 cache.wrap = Some(wrap);
1403 }
1404 cache.generation = cache.generation.wrapping_add(1);
1405
1406 // Frontmatter (a leading `metadata` block) is document metadata, not prose:
1407 // hidden in the rich view exactly as [`Builder::blocks`] skips it.
1408 let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1409
1410 // The outer builder only accumulates rows/tables and spells block boundaries
1411 // — both a function of the source and `last_off`, never of a node array — so
1412 // it carries an empty `nodes`. Each changed block is rendered by a *fresh*
1413 // builder over that block's subtree.
1414 let mut b = Builder {
1415 nodes: &[],
1416 source,
1417 wrap,
1418 rows: Vec::new(),
1419 tables: Vec::new(),
1420 last_off: 0,
1421 stepped_over: 0,
1422 media_rows,
1423 break_glyph: Cell::new(' '),
1424 preserve_soft,
1425 reveal: reveal.clone(),
1426 pending_mark_ends: RefCell::new(Vec::new()),
1427 presentation: Presentation::default(),
1428 };
1429
1430 // Record the per-block row decomposition as we go, so a later
1431 // [`build_spliced`] can patch one block without rebuilding the map.
1432 let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
1433 let mut all_shift_safe = true;
1434 // The class of the last block that drew anything: what the next separator
1435 // closes, and what the trailing blank lines close at the end. A hidden block
1436 // (a comment) never becomes it — see [`Builder::block_or_hidden`], whose
1437 // step-over this loop repeats for the incremental walk.
1438 let mut above: Option<BlockClass> = None;
1439 for block in &blocks {
1440 let start = block.span.start;
1441 let before_sep = b.rows.len();
1442 if let Some(above) = above {
1443 // This walker has no node arena at all (see the `nodes: &[]` above),
1444 // but a top-level query match carries its kind — the same string
1445 // `BlockClass::from_node_kind` classifies for the whole-arena walk, so
1446 // the incremental and full builds label a boundary identically.
1447 b.emit_separators_before(
1448 start,
1449 &[],
1450 true,
1451 Boundary {
1452 above,
1453 below: BlockClass::from_node_kind(&block.kind),
1454 },
1455 );
1456 }
1457 let after_sep = b.rows.len();
1458 let bytes = block_bytes(source, &block.span);
1459 let hash = block_hash(bytes);
1460 // How this block meets the reveal line, if at all — part of its cache
1461 // key, since the same bytes render differently on the caret's line.
1462 let rkey = reveal_key(&reveal, &block.span);
1463
1464 // Hit: clone the block's rows shifted to its current offset and restore
1465 // the (shifted) `last_off` so the next separator lands right — no marshal.
1466 // Only shift-safe blocks are ever cached, so a hit is safe by construction.
1467 if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
1468 let delta = start as isize - hit.built_start as isize;
1469 for row in &hit.rows {
1470 b.rows.push(shift_row(row, delta));
1471 }
1472 b.last_off = (hit.last_off as isize + delta) as usize;
1473 // `0` is a block that stepped over nothing, and no answer to shift.
1474 if hit.stepped_over > 0 {
1475 let stepped = (hit.stepped_over as isize + delta) as usize;
1476 b.stepped_over = b.stepped_over.max(stepped);
1477 }
1478 } else {
1479 // Miss: marshal just this block's subtree and render it. A subtree is
1480 // self-contained with local ids (root at 0) and absolute spans, so a
1481 // fresh builder over it produces the same rows the whole-arena path
1482 // would. An empty subtree (twig couldn't hand it back) renders nothing.
1483 let subtree = fetch_subtree(block.node_id);
1484 if !subtree.is_empty() {
1485 let mut sub = Builder {
1486 nodes: &subtree,
1487 source,
1488 wrap,
1489 rows: Vec::new(),
1490 tables: Vec::new(),
1491 last_off: 0,
1492 stepped_over: 0,
1493 media_rows,
1494 break_glyph: Cell::new(' '),
1495 preserve_soft,
1496 reveal: reveal.clone(),
1497 pending_mark_ends: RefCell::new(Vec::new()),
1498 presentation: Presentation::default(),
1499 };
1500 sub.block(0, &[], &[]);
1501 // A block that drew nothing is stepped over, not stood on: its
1502 // `last_off` is its own end, so the separator after it counts
1503 // from there. The sub-builder started at 0 and never moved, and
1504 // 0 is where the next separator would otherwise count from —
1505 // every line of the document, as a blank row each.
1506 let last_off = if sub.rows.is_empty() {
1507 block.span.end
1508 } else {
1509 sub.last_off
1510 };
1511 let stepped_over = sub.stepped_over;
1512 b.stepped_over = b.stepped_over.max(stepped_over);
1513 // Cache only a block that is table-free AND renders inside its own
1514 // span: those two are the conditions for reuse-by-shift to be
1515 // correct. A block failing either is re-rendered every build (a
1516 // fresh render always matches a fresh whole-document build).
1517 if sub.tables.is_empty() {
1518 if rows_within(&sub.rows, &block.span) {
1519 cache.store(
1520 hash,
1521 bytes,
1522 start,
1523 sub.rows.clone(),
1524 last_off,
1525 stepped_over,
1526 rkey,
1527 );
1528 }
1529 b.rows.extend(sub.rows);
1530 } else {
1531 // A table block is never cached; rebase its row-index
1532 // bookkeeping onto the combined row vector and append.
1533 let base = b.rows.len();
1534 for t in &mut sub.tables {
1535 t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
1536 }
1537 b.rows.extend(sub.rows);
1538 b.tables.extend(sub.tables);
1539 }
1540 b.last_off = last_off;
1541 }
1542 }
1543 let content_rows = b.rows.len() - after_sep;
1544 let sep_rows = if content_rows == 0 {
1545 // Hidden: take back the separator drawn for it, so what stands
1546 // either side meets across one boundary. Its layout entry stays, at
1547 // no rows, so the splice arithmetic still counts one entry per block.
1548 b.rows.truncate(before_sep);
1549 // A cache hit restored the stored `last_off` above; an empty subtree
1550 // (twig couldn't hand it back) restored nothing. Either way the walk
1551 // stands past the block.
1552 b.last_off = b.last_off.max(block.span.end);
1553 b.stepped_over = b.stepped_over.max(block.span.end);
1554 0
1555 } else {
1556 above = Some(BlockClass::from_node_kind(&block.kind));
1557 all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
1558 after_sep - before_sep
1559 };
1560 layout_blocks.push(BlockLayout {
1561 span: block.span.clone(),
1562 kind: block.kind.clone(),
1563 sep_rows,
1564 content_rows,
1565 });
1566 }
1567
1568 let before_trailing = b.rows.len();
1569 let hidden_end = hidden_prefix_end(
1570 source,
1571 top.iter()
1572 .filter(|m| m.kind == Kind::Metadata)
1573 .map(|m| m.span.end)
1574 .next_back(),
1575 );
1576 b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
1577 let trailing_rows = b.rows.len() - before_trailing;
1578
1579 // Evict every entry no block reused this build, so the cache tracks the
1580 // current document instead of growing without bound over a session.
1581 let g = cache.generation;
1582 cache.entries.retain(|_, bucket| {
1583 bucket.retain(|e| e.generation == g);
1584 !bucket.is_empty()
1585 });
1586
1587 cache.layout = Layout {
1588 blocks: layout_blocks,
1589 trailing_rows,
1590 built_len: source.len(),
1591 has_tables: !b.tables.is_empty(),
1592 all_shift_safe,
1593 reveal: reveal.clone(),
1594 };
1595
1596 // The first rendered offset is the first non-metadata block's start — the
1597 // analogue of [`first_content_offset`] for the top-level list. With nothing
1598 // but frontmatter it's the end of that frontmatter, and 0 for an empty
1599 // document ([`hidden_prefix_end`]).
1600 let content_start = blocks.first().map_or(hidden_end, |m| m.span.start);
1601 let stops = collect_stops(&b.rows);
1602 let mark_ends = collect_mark_ends(&b.rows);
1603 label_media_boundaries(&mut b.rows);
1604 let code_blocks = code_block_spans(&b.rows);
1605 let media = media_spans(&b.rows);
1606 let directives = directive_spans(&b.rows);
1607 VisualMap {
1608 rows: b.rows,
1609 content_start,
1610 stops,
1611 mark_ends,
1612 tables: b.tables,
1613 code_blocks,
1614 media,
1615 directives,
1616 }
1617}
1618
1619/// The fast path for a single-block edit: patch the previous [`VisualMap`] in
1620/// place rather than reassembling it. Returns `Some(new_map)` when it applies,
1621/// or `None` to tell the caller to fall back to [`build_cached`] (always
1622/// correct). Consumes `prev` either way — on `None` the caller rebuilds from
1623/// scratch and doesn't need it.
1624///
1625/// It applies only when `dirty` (twig's dirty byte range) falls inside exactly
1626/// one top-level block AND the block structure around it is unchanged — verified
1627/// by matching the new `top` list against the previous [`Layout`] block for
1628/// block: kinds unchanged, spans before the edit identical, spans after it
1629/// shifted by the byte delta, count unchanged. Any deviation — a block split or
1630/// merged, a fence opened to swallow later blocks, a table anywhere, a
1631/// multi-block edit — fails the match and returns `None`. That check is what
1632/// makes the byte-range trustworthy: twig's dirty range is exact about *bytes*
1633/// but silent about *reparse*, and the structural match catches the reparse
1634/// effects it can't see.
1635///
1636/// When it applies, the unchanged prefix rows move verbatim, the suffix rows
1637/// shift by the delta *in place* (integer adds, no glyph copy), and only the one
1638/// dirty block is re-marshalled and re-rendered; stops splice the same way by
1639/// offset. So the cost is O(rows after the edit), and nothing before the edit is
1640/// touched. The hash-keyed entry cache is left alone — a later [`build_cached`]
1641/// will miss on the changed block, re-render it, and evict the stale entry, so
1642/// chained splices neither corrupt nor grow it.
1643// One builder, and every one of these is a distinct input to the same layout
1644// pass — a struct of them would be built at the one call site and unpacked
1645// here, which is the same arguments with an extra name in the way.
1646#[allow(clippy::too_many_arguments)]
1647pub fn build_spliced(
1648 prev: VisualMap,
1649 source: &str,
1650 wrap: Option<usize>,
1651 preserve_soft: bool,
1652 top: &[QueryMatch],
1653 dirty: Range<usize>,
1654 media_rows: &HashMap<String, usize>,
1655 reveal: Option<Range<usize>>,
1656 cache: &mut BlockCache,
1657 mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1658) -> Option<VisualMap> {
1659 let wrap = wrap.map(|w| w.max(8));
1660 // A width change invalidates every cached row — a full rebuild's job.
1661 if cache.wrap != Some(wrap) {
1662 return None;
1663 }
1664 // So does a moved reveal line, and for the same reason: this path reuses
1665 // every row outside the dirty block, and those rows encode which line was
1666 // showing its raw markup when they were built. Typing almost always moves
1667 // the caret, so under `MarkupMode::Full` this bails to `build_cached` on
1668 // most keystrokes — still block-cached, so only the edited block and the
1669 // revealed one actually re-render.
1670 if cache.layout.reveal != reveal {
1671 return None;
1672 }
1673 // Take the previous layout; on any bail below the caller rebuilds it (and the
1674 // map) via `build_cached`, so leaving it empty is fine. A table or a block
1675 // that renders outside its span (a degenerate inline span) makes shifting
1676 // unsound, so those force the full-rebuild path.
1677 let prev_layout = std::mem::take(&mut cache.layout);
1678 if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
1679 return None;
1680 }
1681 // The layout addresses `prev` by row index, so it is only usable against the
1682 // map it was built from. A frontend is free to hold the map it was handed and
1683 // present it differently — leaf-ratatui splices blank filler rows under an
1684 // oversized heading so the raster has somewhere to stand — and if one of those
1685 // comes back here the row arithmetic below lands on the wrong rows: the
1686 // re-rendered block is laid over a filler and the rows it really occupied
1687 // survive into the suffix, stranding a stale copy of the edited line and
1688 // pushing everything after it one row down, once per keystroke. A row count
1689 // that doesn't match what this layout describes is the tell, and the honest
1690 // answer is the full rebuild.
1691 let described_rows = prev_layout
1692 .blocks
1693 .iter()
1694 .map(|pl| pl.sep_rows + pl.content_rows)
1695 .sum::<usize>()
1696 + prev_layout.trailing_rows;
1697 if described_rows != prev.rows.len() {
1698 return None;
1699 }
1700
1701 let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1702 if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
1703 return None;
1704 }
1705 let delta = source.len() as isize - prev_layout.built_len as isize;
1706
1707 // The single block whose NEW span contains the whole dirty range. A dirty
1708 // range straddling a block boundary (or a separator) finds none → bail.
1709 let k = blocks
1710 .iter()
1711 .position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
1712
1713 // Structural match: every OTHER block is unchanged — same kind throughout,
1714 // span identical before the edit and shifted by `delta` after it. A mismatch
1715 // means the reparse reshaped the block structure, which only a full rebuild
1716 // renders correctly.
1717 for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
1718 if m.kind != pl.kind {
1719 return None;
1720 }
1721 if i == k {
1722 continue;
1723 }
1724 let want = if i < k {
1725 pl.span.clone()
1726 } else {
1727 (pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
1728 };
1729 if m.span != want {
1730 return None;
1731 }
1732 }
1733 // The dirty block itself: start unchanged (the edit is inside it, past its
1734 // start), end moved by exactly the delta.
1735 let pk_start = prev_layout.blocks[k].span.start;
1736 let pk_end = prev_layout.blocks[k].span.end;
1737 let pk_sep = prev_layout.blocks[k].sep_rows;
1738 let pk_content = prev_layout.blocks[k].content_rows;
1739 if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
1740 {
1741 return None;
1742 }
1743
1744 // Re-render the dirty block from its subtree. A table makes the splice
1745 // bookkeeping unsafe, so bail if one appears.
1746 let subtree = fetch_subtree(blocks[k].node_id);
1747 if subtree.is_empty() {
1748 return None;
1749 }
1750 let mut sub = Builder {
1751 nodes: &subtree,
1752 source,
1753 wrap,
1754 rows: Vec::new(),
1755 tables: Vec::new(),
1756 last_off: 0,
1757 stepped_over: 0,
1758 media_rows,
1759 break_glyph: Cell::new(' '),
1760 preserve_soft,
1761 reveal: reveal.clone(),
1762 pending_mark_ends: RefCell::new(Vec::new()),
1763 presentation: Presentation::default(),
1764 };
1765 sub.block(0, &[], &[]);
1766 // A table, or content that renders outside the block's span (a degenerate
1767 // inline span), makes the shift bookkeeping unsound — fall back.
1768 if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
1769 return None;
1770 }
1771 let new_content = sub.rows;
1772 let new_content_len = new_content.len();
1773 let new_stops = collect_stops(&new_content);
1774 let new_mark_ends = collect_mark_ends(&new_content);
1775
1776 // Row span of the dirty block's CONTENT. Its leading separator stays in the
1777 // prefix: the gap before block k is unchanged, since k's start didn't move.
1778 let content_start_row: usize = prev_layout.blocks[..k]
1779 .iter()
1780 .map(|pl| pl.sep_rows + pl.content_rows)
1781 .sum::<usize>()
1782 + pk_sep;
1783 let content_end_row = content_start_row + pk_content;
1784
1785 // Splice rows: [prefix | new content | suffix + delta]. The prefix moves
1786 // untouched; the suffix shifts in place — integer adds, no glyph copy.
1787 let mut rows = prev.rows;
1788 let mut suffix = rows.split_off(content_end_row);
1789 rows.truncate(content_start_row);
1790 for row in &mut suffix {
1791 shift_row_in_place(row, delta);
1792 }
1793 rows.reserve(new_content_len + suffix.len());
1794 rows.extend(new_content);
1795 rows.extend(suffix);
1796
1797 // Splice stops by offset. The old dirty block covered `[pk_start, pk_end]`:
1798 // prefix stops fall below it, suffix stops above it (shift by delta), the new
1799 // content supplies the middle. The three ranges stay disjoint and ascending,
1800 // so the result needs no re-sort.
1801 let p1 = prev.stops.partition_point(|&s| s < pk_start);
1802 let p2 = prev.stops.partition_point(|&s| s <= pk_end);
1803 let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
1804 stops.extend_from_slice(&prev.stops[..p1]);
1805 stops.extend(new_stops);
1806 for &s in &prev.stops[p2..] {
1807 stops.push((s as isize + delta) as usize);
1808 }
1809 // The mark ends splice the same way: they are offsets in the same
1810 // coordinates, cut at the same block.
1811 let m1 = prev.mark_ends.partition_point(|&s| s < pk_start);
1812 let m2 = prev.mark_ends.partition_point(|&s| s <= pk_end);
1813 let mut mark_ends = Vec::with_capacity(m1 + new_mark_ends.len() + (prev.mark_ends.len() - m2));
1814 mark_ends.extend_from_slice(&prev.mark_ends[..m1]);
1815 mark_ends.extend(new_mark_ends);
1816 for &s in &prev.mark_ends[m2..] {
1817 mark_ends.push((s as isize + delta) as usize);
1818 }
1819
1820 // Record the patched layout for the next splice: spans move to the new
1821 // coordinates, and the dirty block takes its new content-row count.
1822 let mut new_blocks = prev_layout.blocks;
1823 for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
1824 pl.span = m.span.clone();
1825 }
1826 new_blocks[k].content_rows = new_content_len;
1827 cache.layout = Layout {
1828 blocks: new_blocks,
1829 trailing_rows: prev_layout.trailing_rows,
1830 built_len: source.len(),
1831 has_tables: false,
1832 // Every prefix/suffix block was shift-safe last build (we bailed
1833 // otherwise) and the re-rendered block was just checked, so the patched
1834 // document is still entirely shift-safe.
1835 all_shift_safe: true,
1836 reveal,
1837 };
1838
1839 label_media_boundaries(&mut rows);
1840 let code_blocks = code_block_spans(&rows);
1841 let media = media_spans(&rows);
1842 let directives = directive_spans(&rows);
1843 Some(VisualMap {
1844 rows,
1845 content_start: blocks[0].span.start,
1846 stops,
1847 mark_ends,
1848 tables: Vec::new(),
1849 code_blocks,
1850 media,
1851 directives,
1852 })
1853}
1854
1855/// A persistent, content-keyed cache of the rows each top-level block renders
1856/// to — the [`VisualMap`] analogue of the GUI's ShapedLine cache, one level
1857/// down. Held by a [`crate::Doc`] and threaded into [`build_cached`], it is what
1858/// makes a rebuild after a keystroke cost "re-render the edited block + shift
1859/// the rest" instead of re-rendering the whole document.
1860///
1861/// A top-level block's rows are a pure function of its source bytes and the wrap
1862/// width, so an unchanged block's rows are cloned and their source offsets
1863/// shifted by the edit's byte delta rather than rebuilt glyph by glyph. Two
1864/// things make that purity hold: at the top level the render prefix is always
1865/// empty (nesting prefixes — a quote gutter, a list indent — exist only *inside*
1866/// a top-level block, within its cached unit), and a block's output never reads
1867/// the incoming `last_off` (it writes `last_off` from its own content before any
1868/// nested separator reads it). So the only thing that differs between two
1869/// positions of an unchanged block is a uniform offset shift. Keyed by a fast
1870/// hash of the block's bytes with the bytes kept for a verify-on-hit — exactly
1871/// the shape cache's weak-hash-then-compare, so a collision costs a re-render,
1872/// never a wrong row.
1873///
1874/// Tables are never cached (a block that emits any table row is always rebuilt):
1875/// their rows are cross-referenced from the map's `tables` side-table by row
1876/// index, which a blind offset-shift wouldn't fix up, and they are rare enough
1877/// that the simplicity beats the reuse.
1878#[derive(Default)]
1879pub struct BlockCache {
1880 /// The wrap width every entry was built at; a change invalidates all of
1881 /// them. `None` before the first build (distinct from `Some(None)`, the
1882 /// unwrapped GUI width).
1883 wrap: Option<Option<usize>>,
1884 /// Bumped once per [`build_cached`]. An entry reused or inserted this build
1885 /// carries the current value; stale entries are dropped at the end of it.
1886 generation: u64,
1887 /// `hash(bytes)` → the block(s) sharing that hash — a bucket because
1888 /// distinct blocks can collide, while two *identical* blocks share one entry
1889 /// (free dedup).
1890 entries: HashMap<u64, Vec<CachedBlock>>,
1891 /// The row/stop decomposition of the last build, which [`build_spliced`]
1892 /// patches in place for a single-block edit. Kept in step with whatever
1893 /// [`VisualMap`] was last produced; empty before the first build.
1894 layout: Layout,
1895}
1896
1897/// How the last build's [`VisualMap`] decomposes into top-level blocks — the
1898/// bookkeeping [`build_spliced`] needs to splice one block's rows and stops
1899/// without rebuilding the whole map. Every field describes the *previous* build,
1900/// in that build's coordinates.
1901#[derive(Default)]
1902struct Layout {
1903 /// One entry per rendered (metadata-filtered) top-level block, in order.
1904 blocks: Vec<BlockLayout>,
1905 /// Trailing blank rows past the last block (from `emit_trailing_blank_lines`).
1906 trailing_rows: usize,
1907 /// The source length this layout was built at — the reference for the edit's
1908 /// byte delta.
1909 built_len: usize,
1910 /// Whether the last build drew any table. A table's cross-referenced row
1911 /// indices don't survive a blind splice, so their presence makes
1912 /// [`build_spliced`] bail to a full rebuild.
1913 has_tables: bool,
1914 /// Whether every block rendered strictly inside its own span (see
1915 /// [`rows_within`]). A block that doesn't — a malformed Markdown inline node
1916 /// that twig leaves with a degenerate `0..0` span renders at a fixed offset
1917 /// outside its block — can't be shifted correctly, so its presence makes
1918 /// [`build_spliced`] bail to a full rebuild.
1919 all_shift_safe: bool,
1920 /// The reveal line this layout was built under (see [`Builder::reveal`]).
1921 /// A splice reuses every row it isn't re-rendering, so a reveal line that
1922 /// has moved would leave the old line still showing its delimiters and the
1923 /// new one still hiding them — [`build_spliced`] bails when this changes.
1924 reveal: Option<Range<usize>>,
1925}
1926
1927/// One top-level block's contribution to the last build: its span and kind (for
1928/// the structural match that proves only one block changed) and how many
1929/// separator and content rows it emitted (to locate its slice of the row
1930/// vector).
1931struct BlockLayout {
1932 span: Range<usize>,
1933 kind: Kind,
1934 sep_rows: usize,
1935 content_rows: usize,
1936}
1937
1938/// One cached block: the rows it rendered to, plus what a reuse at a new
1939/// position needs to shift them. Offsets are stored absolute (as built) and
1940/// shifted by `new_start - built_start` on reuse.
1941struct CachedBlock {
1942 /// The block's exact source bytes, compared on a hash hit so a collision
1943 /// can never hand back another block's rows.
1944 bytes: Box<[u8]>,
1945 /// The offset the rows were built at (the block's `span.start`).
1946 built_start: usize,
1947 /// The block's rows, offsets absolute as built.
1948 rows: Vec<VRow>,
1949 /// `last_off` after this block was emitted, absolute as built — restored
1950 /// (shifted) on reuse so the following separator lands correctly.
1951 last_off: usize,
1952 /// `stepped_over` after this block was emitted, absolute as built — the
1953 /// hidden tail a block ends with (a `</div>`), restored with `last_off`
1954 /// so the trailing blank lines are counted from past it on a hit too.
1955 stepped_over: usize,
1956 /// Where the reveal line fell *within this block* when the rows were built,
1957 /// as a block-relative byte range — see [`reveal_key`]. Compared alongside
1958 /// `bytes` on a hit, because identical source renders to different rows
1959 /// depending on whether the caret's line is inside it: the same `*em*`
1960 /// shows its asterisks on the revealed line and hides them everywhere else.
1961 ///
1962 /// Block-relative rather than absolute so an unaffected block still hits
1963 /// after an edit shifts it, and `None` for the overwhelmingly common
1964 /// no-reveal case — which is why an entry stored under `MarkupMode::None`
1965 /// keeps hitting for every block that isn't the caret's.
1966 reveal: Option<Range<usize>>,
1967 /// The build that last reused or inserted this entry (see `generation`).
1968 generation: u64,
1969}
1970
1971/// Where `reveal` falls inside a block, in block-relative bytes — the extra key
1972/// a cached block is stored and matched under.
1973///
1974/// `None` when the block doesn't meet the reveal line at all, which is every
1975/// block on every build in the two hidden modes, and all but one of them under
1976/// [`crate::MarkupMode::Full`]. So the cache keeps its hit rate as the caret
1977/// moves: only the line the caret leaves and the line it arrives at re-render.
1978fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
1979 let r = reveal.as_ref()?;
1980 // The same generous intersection test `Builder::revealed` uses, so a block
1981 // is keyed as revealed exactly when its glyphs will be built that way.
1982 (span.start <= r.end && r.start <= span.end).then(|| {
1983 let start = r.start.max(span.start) - span.start;
1984 let end = r.end.min(span.end) - span.start;
1985 start..end
1986 })
1987}
1988
1989impl BlockCache {
1990 /// Look up a block by hash, verify its bytes and reveal key, and on a hit
1991 /// stamp it used this build and hand back a borrow to shift-and-clone from.
1992 /// `None` on a miss (unknown hash, a collision whose bytes differ, or the
1993 /// same bytes built under a different reveal).
1994 fn reuse(
1995 &mut self,
1996 hash: u64,
1997 bytes: &[u8],
1998 reveal: &Option<Range<usize>>,
1999 ) -> Option<&CachedBlock> {
2000 let g = self.generation;
2001 let bucket = self.entries.get_mut(&hash)?;
2002 let e = bucket
2003 .iter_mut()
2004 .find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
2005 e.generation = g;
2006 Some(&*e)
2007 }
2008
2009 /// Cache the rows a freshly-rendered block produced (or refresh an existing
2010 /// entry for the same bytes and reveal — an identical block elsewhere, or a
2011 /// re-render).
2012 #[allow(clippy::too_many_arguments)]
2013 fn store(
2014 &mut self,
2015 hash: u64,
2016 bytes: &[u8],
2017 built_start: usize,
2018 rows: Vec<VRow>,
2019 last_off: usize,
2020 stepped_over: usize,
2021 reveal: Option<Range<usize>>,
2022 ) {
2023 let g = self.generation;
2024 let bucket = self.entries.entry(hash).or_default();
2025 if let Some(e) = bucket
2026 .iter_mut()
2027 .find(|e| &*e.bytes == bytes && e.reveal == reveal)
2028 {
2029 e.built_start = built_start;
2030 e.rows = rows;
2031 e.last_off = last_off;
2032 e.stepped_over = stepped_over;
2033 e.generation = g;
2034 } else {
2035 bucket.push(CachedBlock {
2036 bytes: bytes.into(),
2037 built_start,
2038 rows,
2039 last_off,
2040 stepped_over,
2041 reveal,
2042 generation: g,
2043 });
2044 }
2045 }
2046}
2047
2048/// The source bytes a top-level block covers — the block cache's key material.
2049///
2050/// Clamped to the source rather than sliced by the span as twig gives it,
2051/// because that span can end *past* the last byte: the final block of a document
2052/// with no trailing newline is closed on the virtual newline the parser supplies
2053/// at EOF, so its `span.end` is `source.len() + 1`. Slicing by such a range
2054/// yields `None`, and the obvious `unwrap_or(&[])` reads that as *this block has
2055/// no bytes* — the wrong answer twice over.
2056///
2057/// Two blocks whose spans both overrun then key alike, and the second is served
2058/// the first one's rows. That is not hypothetical: a footnote definition is a
2059/// root beside `doc` merged back into the top level by [`top_blocks`], while the
2060/// `section` above it spans the definition's bytes too, so both end at EOF —
2061/// and a document ending in `[^note]: …` renders that definition as a second
2062/// copy of the heading. Even alone, a block that keeps hashing empty as the user
2063/// types in it is served the stale rows built before the edit.
2064///
2065/// Clamping hands back the bytes the block really covers, which tells both cases
2066/// apart, and costs nothing for a span that was in range to begin with.
2067fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
2068 let bytes = source.as_bytes();
2069 let start = span.start.min(bytes.len());
2070 &bytes[start..span.end.clamp(start, bytes.len())]
2071}
2072
2073/// A fast, allocation-free content hash (FNV-1a) for a block's bytes. Weak by
2074/// design — the bytes are compared on a hit — so its only job is to spread
2075/// blocks across buckets cheaply. SipHash over every block's bytes on every
2076/// keystroke would cost more than it saves, the same lesson the shape cache
2077/// learned when it stopped hashing through the standard hasher.
2078fn block_hash(bytes: &[u8]) -> u64 {
2079 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2080 for &x in bytes {
2081 h ^= x as u64;
2082 h = h.wrapping_mul(0x0000_0100_0000_01b3);
2083 }
2084 h
2085}
2086
2087/// Clone a cached row with every source offset advanced by `delta` — the whole
2088/// cost of reusing an unchanged block: integer adds where a rebuild would
2089/// re-shape every glyph.
2090fn shift_row(row: &VRow, delta: isize) -> VRow {
2091 let shift = |off: usize| (off as isize + delta) as usize;
2092 VRow {
2093 glyphs: row
2094 .glyphs
2095 .iter()
2096 .map(|g| Glyph {
2097 ch: g.ch,
2098 style: g.style,
2099 src: shift(g.src),
2100 stop: g.stop,
2101 })
2102 .collect(),
2103 end_src: shift(row.end_src),
2104 decoration: row.decoration,
2105 code: row.code,
2106 code_lang: row.code_lang.clone(),
2107 directive: row.directive,
2108 directive_label: row.directive_label.clone(),
2109 media: row.media.clone(),
2110 // A tick, not an offset — reuse carries it as-is, like `code_lang`.
2111 task: row.task,
2112 leaf_directive: row.leaf_directive.clone(),
2113 heading: row.heading,
2114 // Presentation, not offsets: names the author wrote, which a shifted
2115 // block still wears — like `code_lang`.
2116 align: row.align,
2117 line_height: row.line_height,
2118 // Structure, not offsets: a reused block's rows divide the same blocks
2119 // wherever the edit above moved them to.
2120 boundary: row.boundary,
2121 mark_ends: row.mark_ends.iter().map(|&o| shift(o)).collect(),
2122 }
2123}
2124
2125/// Advance a row's source offsets by `delta` in place — the suffix half of
2126/// [`build_spliced`], where the rows are already owned and only need shifting,
2127/// not copying.
2128fn shift_row_in_place(row: &mut VRow, delta: isize) {
2129 for g in &mut row.glyphs {
2130 g.src = (g.src as isize + delta) as usize;
2131 }
2132 row.end_src = (row.end_src as isize + delta) as usize;
2133 for o in &mut row.mark_ends {
2134 *o = (*o as isize + delta) as usize;
2135 }
2136}
2137
2138/// Whether every source offset a block's rows carry falls inside the block's own
2139/// span — the precondition for reusing the block by a uniform offset shift. It
2140/// holds for well-formed blocks (their glyphs and row ends address bytes within
2141/// the block, synthetic glyphs point at the block start). It fails when a node
2142/// renders *outside* its block, which today means a malformed Markdown inline
2143/// node twig leaves with a degenerate `0..0` span: that content lands at a fixed
2144/// offset that doesn't move with the block. Such a block is re-rendered every
2145/// build instead of shifted, so the incremental map still matches a fresh one —
2146/// see [`build_cached`] and [`build_spliced`].
2147fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
2148 rows.iter().all(|r| {
2149 r.end_src >= span.start
2150 && r.end_src <= span.end
2151 && r.glyphs
2152 .iter()
2153 .all(|g| g.src >= span.start && g.src <= span.end)
2154 })
2155}
2156
2157/// Where the rendered document begins when a leading `metadata` block is all
2158/// there is — the end of that hidden frontmatter, past the newline that closes
2159/// its last line so the floor sits at the start of the (empty) body rather than
2160/// on the closing `---`.
2161///
2162/// With a real block after it the frontmatter's end is never needed: the floor
2163/// is that block's start, and the rows begin there. With nothing after it, both
2164/// the caret floor and the trailing-blank-line count would otherwise fall back
2165/// to offset 0 — inside the hidden frontmatter — which put the caret *before*
2166/// the metadata and made typing land ahead of the opening `---`.
2167fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
2168 let Some(end) = meta_end else { return 0 };
2169 let end = end.min(source.len());
2170 let rest = &source[end..];
2171 if rest.starts_with("\r\n") {
2172 end + 2
2173 } else if rest.starts_with('\n') {
2174 end + 1
2175 } else {
2176 end
2177 }
2178}
2179
2180/// The end of the document's hidden frontmatter: the last `metadata` child of
2181/// `doc`, which is what [`top_level`] and [`Builder::blocks`] drop. `None` when
2182/// there is none.
2183fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
2184 let mut end = None;
2185 let mut child = nodes[doc].first_child;
2186 while let Some(cid) = child {
2187 let n = &nodes[cid.0 as usize];
2188 if n.kind == Kind::Metadata {
2189 end = Some(n.span.end);
2190 }
2191 child = n.next_sibling;
2192 }
2193 end
2194}
2195
2196/// The document's rendered top-level blocks, as node indices in source order.
2197///
2198/// Not simply `doc`'s children, for two reasons. Frontmatter (a leading
2199/// `metadata` block) is document metadata rather than prose and is dropped, the
2200/// way [`Builder::blocks`] drops it. And a **footnote definition** (`[^1]: …`)
2201/// is not a child of `doc` at all: twig parses it as a root of its own, a
2202/// *sibling* of the document node with `parent == None`. A walk that starts at
2203/// `doc` therefore never reaches one, which is why a definition — and every
2204/// byte of its body — used to render as nothing at all. Merging the roots back
2205/// in by `span.start` puts each definition on screen exactly where it was
2206/// written, which is what keeps rows, stops, and offsets monotonic.
2207///
2208/// A **link reference definition** (`[foo]: /url`) is a root of the same kind,
2209/// and is merged for the opposite reason: it draws *nothing*, and the walk has
2210/// to know where it stands to step over it. A definition closing a README —
2211/// the `[links]: …` block under the prose — left no block over its lines, so
2212/// the separator logic read them as blank lines and drew an empty paragraph
2213/// per definition. Merged in, it is a hidden block like a comment, and
2214/// [`Builder::block_or_hidden`] moves the walk past it. One with no span
2215/// (`0..0`, what twig before 3.3.3 reported for every one) has nowhere to be
2216/// merged, and is left out as before.
2217///
2218/// Only those roots are merged. twig also leaves stray orphan `str` nodes
2219/// parented to nothing (the `*` of an emphasis run, for one); those are already
2220/// rendered as part of the subtree that owns their bytes, and re-emitting them
2221/// here would double them.
2222fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
2223 let mut out = Vec::new();
2224 let mut child = nodes[doc].first_child;
2225 while let Some(cid) = child {
2226 let n = &nodes[cid.0 as usize];
2227 if n.kind != Kind::Metadata {
2228 out.push(cid.0 as usize);
2229 }
2230 child = n.next_sibling;
2231 }
2232 out.extend(
2233 nodes
2234 .iter()
2235 .enumerate()
2236 .filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
2237 .map(|(i, _)| i),
2238 );
2239 out.sort_by_key(|&i| nodes[i].span.start);
2240 out
2241}
2242
2243/// Is a parentless node of `kind` at `span` a definition the top-level walk
2244/// merges in — a footnote definition, or a link reference definition that
2245/// knows where it stands? Shared by [`top_level`] and [`top_blocks`] so the
2246/// two walks cannot disagree about what the top-level blocks are.
2247fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
2248 match *kind {
2249 Kind::Footnote => true,
2250 Kind::Reference => span.end > span.start,
2251 _ => false,
2252 }
2253}
2254
2255/// The top-level blocks to hand [`build_cached`] / [`build_spliced`] — the
2256/// incremental path's twin of [`top_level`], which the two must agree with block
2257/// for block or the render paths diverge.
2258///
2259/// `child_spans(None)` gives `doc`'s children, which is all of them for an
2260/// ordinary document. A **footnote definition** is not one: twig parses `[^1]: …`
2261/// as a root beside `doc` with no parent, and indexes it at no offset either —
2262/// `node_at` inside its bytes answers `doc`, and a `query("footnote")` selector
2263/// finds nothing. Leaf used to discover them by marshalling the whole arena with
2264/// `nodes()` — the very cost the incremental path exists to avoid — behind a
2265/// byte-scan gate that gave documents with no `[^…]:` line a substring search
2266/// instead. twig 3.0's `definitions()` asks the library the question directly,
2267/// so both the marshal and the gate are gone.
2268///
2269/// The link reference definitions `definitions()` also reports are merged on
2270/// the same terms as [`top_level`] merges them — see [`is_placed_definition`].
2271///
2272/// This is the one part of the render that needs an [`Editor`] rather than a
2273/// marshalled node array. The builders themselves stay editor-free; this only
2274/// prepares their input.
2275pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
2276 let mut top = editor.child_spans(None).unwrap_or_default();
2277 let defs: Vec<QueryMatch> = definitions(editor)
2278 .into_iter()
2279 .filter(|m| is_placed_definition(&m.kind, &m.span))
2280 .collect();
2281 if defs.is_empty() {
2282 return top;
2283 }
2284 top.extend(defs);
2285 // Source order — what every offset-keyed thing downstream (rows, stops, the
2286 // splice path's block-for-block match) is built to assume.
2287 top.sort_by_key(|m| m.span.start);
2288 top
2289}
2290
2291/// Every `[^label]: …` definition in the document, in whatever order twig
2292/// reports them.
2293///
2294/// Filtered to [`Kind::Footnote`]: `definitions()` also reports the *link*
2295/// reference definitions (`[foo]: /url`), which are [`top_blocks`]'s business
2296/// and not [`crate::Doc::footnote_at_caret`]'s.
2297///
2298/// Empty when the document can't be walked, which leaves [`top_blocks`] with
2299/// the ordinary top-level children and [`crate::Doc::footnote_at_caret`] with an
2300/// undefined reference — in both cases the same answer as a document that has
2301/// no definitions, which is the right way to degrade.
2302pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2303 definitions(editor)
2304 .into_iter()
2305 .filter(|m| m.kind == Kind::Footnote)
2306 .collect()
2307}
2308
2309/// Every definition twig resolves by label rather than by position — footnote
2310/// and link reference definitions both — or nothing when the document can't be
2311/// walked.
2312fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2313 let Ok(mut doc) = editor.document() else {
2314 return Vec::new();
2315 };
2316 doc.definitions().unwrap_or_default()
2317}
2318
2319/// The label of the footnote definition starting at `start` — the `1` in
2320/// `[^1]: …`. twig gives the `footnote` node no label of its own (no `text`, no
2321/// `name`), and the bytes that spell it belong to no child node either — the
2322/// body `para` starts its *content* past them — so the source is the only place
2323/// to read it from. `None` when what's there isn't a definition after all.
2324pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
2325 let rest = source.get(start..)?.strip_prefix("[^")?;
2326 let end = rest.find("]:")?;
2327 Some(&rest[..end])
2328}
2329
2330/// Where the body of the footnote definition spanning `span` sits in `source` —
2331/// everything past the `[^1]:` marker, which is the part a reader actually wants
2332/// when they follow a reference.
2333///
2334/// Source bytes, verbatim but for the whitespace trimmed off each end: a note
2335/// that says `see *later*` answers with the asterisks in. Rendering that body is
2336/// a frontend's business the same way painting a [`Role`] is, and a caller that
2337/// wants it laid out already has the definition on screen where it was written.
2338///
2339/// The trim is what makes the common case read right — `[^1]: text` has a space
2340/// after the colon that belongs to the marker, not the note, and a definition's
2341/// span runs to the newline ending it.
2342///
2343/// The span is taken at its word, which it has only been safe to do since twig
2344/// 3.1: a djot definition's span used to run *past* its own last line, through
2345/// the blank line separating it from the next block and into that block's first
2346/// byte, so `[^2a]: a note.` came back as `"a note.\n\n["` and the offsets named
2347/// the following note's rows as well as this one's — a reader asking about one
2348/// footnote was shown two. leaf measured the body itself to get around that, and
2349/// paid for it: the scan stopped at the first blank line, so a note with a second
2350/// indented paragraph lost it. Both halves go away with the fix, since a blank
2351/// line *inside* a definition was always interior to the span and still is.
2352///
2353/// A range rather than a slice because "go to note" needs the *position* as much
2354/// as the text, and it needs the position of the body specifically: a
2355/// definition's `[^1]:` marker is decoration the caret can't occupy (the rich
2356/// view draws it as `[1] ` and gives it no stop), so aiming a caret at the
2357/// definition's first byte lands it on the nearest real stop instead — which is
2358/// up in the paragraph *above* the note. The body's first byte is a stop, and is
2359/// where a reader following a reference wants to arrive anyway.
2360pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
2361 let rest = source.get(span.clone())?.strip_prefix("[^")?;
2362 let marker = rest.find("]:")?;
2363 // `span.start` + `[^` + the label + `]:`.
2364 let after_marker = span.start + 2 + marker + 2;
2365 let raw = source.get(after_marker..span.end)?;
2366 // Written as a start plus a length so an all-whitespace body lands on an
2367 // empty range at the end rather than an inverted one.
2368 let start = after_marker + (raw.len() - raw.trim_start().len());
2369 Some(start..start + raw.trim().len())
2370}
2371
2372/// The label of the footnote *reference* spanning `span` — the `1` in `[^1]`.
2373///
2374/// The peer of [`footnote_label`] for the other half of the pair, and needed for
2375/// the same reason: a reference whose node carries neither a `content_span` nor
2376/// a `text` still spells its label plainly in the source. `None` when the bytes
2377/// aren't a reference after all.
2378pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
2379 let rest = source.get(span)?.strip_prefix("[^")?;
2380 let end = rest.find(']')?;
2381 Some(&rest[..end])
2382}
2383
2384/// Where a heading's *content* starts — past the `#`s and the space the rich
2385/// view hides, for an ATX heading; the block's own start for a setext one (which
2386/// has no leading marker) and for a format that spells headings some other way.
2387///
2388/// Only an empty heading needs asking: with any content at all, the row ends on
2389/// its last glyph. Bounded to the heading's own first line so a marker-less
2390/// heading can't scan into the text under it.
2391fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
2392 let end = span.end.min(source.len());
2393 let Some(line) = source.get(span.start..end) else {
2394 return span.start;
2395 };
2396 let line = line.split('\n').next().unwrap_or("");
2397 let hashes = line.len() - line.trim_start_matches('#').len();
2398 if hashes == 0 {
2399 return span.start;
2400 }
2401 let after = &line[hashes..];
2402 span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
2403}
2404
2405struct Builder<'a> {
2406 nodes: &'a [FlatNode],
2407 /// The document source, consulted to place blank-line rows at the source
2408 /// offsets the caret should occupy on them (the AST drops blank lines).
2409 source: &'a str,
2410 /// The word-wrap column budget, or `None` to emit each block as a single
2411 /// unwrapped row (the frontend wraps).
2412 wrap: Option<usize>,
2413 rows: Vec<VRow>,
2414 /// Built alongside `rows`, never instead of them — see [`TableInfo`].
2415 tables: Vec<TableInfo>,
2416 /// The end offset of the last content emitted — the anchor for blank
2417 /// separator rows so the caret never snaps onto one.
2418 last_off: usize,
2419 /// The end of the last block the walk stepped over without drawing — a
2420 /// comment, which the rich view hides. `last_off` moves past it too, for the
2421 /// separators; this is kept apart so the trailing blank lines can be counted
2422 /// from it without also being counted from a code block's closing fence,
2423 /// which `last_off` likewise ends after. `0` until a hidden block is met.
2424 stepped_over: usize,
2425 /// How many rows each block image reserves, keyed by its destination — the
2426 /// frontend's per-image height, threaded in from [`crate::Doc::set_media_rows`]
2427 /// so [`Builder::block_media`] can size the placeholder without core doing any
2428 /// I/O. A destination absent from the map (or a `0`/`1` entry) reserves the
2429 /// bare one-row placeholder, which is the whole-document default and what
2430 /// every existing test — passing an empty map — still gets.
2431 media_rows: &'a HashMap<String, usize>,
2432 /// The glyph a hard break renders as while the current inline run is built:
2433 /// a space in prose (a break folds into the flow the frontend wraps), but a
2434 /// newline (`\n`) inside a table cell, where a row is one source line and the
2435 /// only break it can carry is an explicit one that must show as a line of its
2436 /// own. Set around [`Builder::row_cells`] and otherwise left at `' '`.
2437 break_glyph: Cell<char>,
2438 /// Render a soft break (a bare newline inside a paragraph) as a line break
2439 /// where it was written, rather than folding it into the reflowed paragraph
2440 /// — the `LineFlow::Preserve` behaviour. A soft break emits a `'\n'` glyph
2441 /// (like a hard break in a cell), which [`Builder::emit_wrapped`] turns into
2442 /// a fresh visual row. `false` is the flowing-prose default. Inside a table
2443 /// cell (where `break_glyph` is already `'\n'`) it has no effect: a cell is
2444 /// one line and folds its own soft breaks regardless.
2445 preserve_soft: bool,
2446 /// The source byte range of the one line that should render its markup
2447 /// *raw* — the caret's line under `MarkupMode::Full` (see
2448 /// [`crate::Doc::reveal_line`]). `None` in every other mode and view, which
2449 /// is the delimiters-always-hidden behaviour every build had before the
2450 /// preference existed.
2451 ///
2452 /// Read only by [`Builder::revealed`], which every delimiter-bearing arm of
2453 /// [`Builder::inline`] consults. A range rather than a bare caret offset
2454 /// because the decision is per-*node*, not per-caret: a node is revealed
2455 /// when its span meets this line, so `*em*` shows both its asterisks even
2456 /// with the caret at one end of it.
2457 reveal: Option<Range<usize>>,
2458 /// The content ends of the hidden marks rendered since the last row was
2459 /// pushed — recorded as the inline walk meets each mark, and drained onto
2460 /// the rows as they are emitted (see [`Builder::take_mark_ends`]). A cell
2461 /// rather than a `&mut`, for the reason `break_glyph` is: the inline walk
2462 /// borrows the builder shared.
2463 pending_mark_ends: RefCell<Vec<usize>>,
2464 /// The presentation vocabulary in force at the block being walked — the
2465 /// keys the `div`s around it carry, folded together with the nearest
2466 /// winning, and [`Presentation::default`] at the top level.
2467 ///
2468 /// Saved and restored around each `div` in [`Builder::block`], so a block
2469 /// reads its own attributes over whatever its containers said and nothing
2470 /// leaks sideways to the block after it. It is per-*build* state rather
2471 /// than a parameter because every one of the dozen call sites of `block`
2472 /// would otherwise thread a value none of them care about.
2473 presentation: Presentation,
2474}
2475
2476/// The six presentation keys as the walker carries them down a block tree —
2477/// the two that are the block's ([`Align`], [`LineSpacing`]) and the three that
2478/// are a run's but may be written on the block ([`SizeStep`], [`FontFamily`],
2479/// [`MarkColor`]).
2480///
2481/// `Copy` and five `Option`s, because folding is the whole of what it does:
2482/// [`under`](Presentation::under) reads a container's attributes over an
2483/// existing set and a key the container does not name keeps the value it had.
2484/// That is the "nearest wins" rule stated once, rather than at each of the
2485/// three levels a key can be written at.
2486#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2487struct Presentation {
2488 align: Option<Align>,
2489 line_height: Option<LineSpacing>,
2490 size: Option<SizeStep>,
2491 font: Option<FontFamily>,
2492 color: Option<MarkColor>,
2493}
2494
2495impl Presentation {
2496 /// This set with whatever `attrs` names written over it — the nearer node's
2497 /// answer where it has one, the outer node's where it hasn't.
2498 fn under(self, attrs: &[(String, Option<String>)]) -> Self {
2499 Self {
2500 align: Align::from_attrs(attrs).or(self.align),
2501 line_height: LineSpacing::from_attrs(attrs).or(self.line_height),
2502 size: SizeStep::from_attrs(attrs).or(self.size),
2503 font: FontFamily::from_attrs(attrs).or(self.font),
2504 color: MarkColor::from_attrs(attrs).or(self.color),
2505 }
2506 }
2507
2508 /// `base` carrying the three run-level keys — the style a block's glyphs
2509 /// start from, which an attributed span inside it then writes over.
2510 fn over(self, base: Style) -> Style {
2511 base.size(self.size).font(self.font).color(self.color)
2512 }
2513}
2514
2515impl Builder<'_> {
2516 /// Note that the mark `id` closes with a hidden delimiter, so its content
2517 /// end is a caret home — unless the mark is empty, where the end is the
2518 /// start and there is nothing to extend.
2519 fn note_mark_end(&self, id: usize) {
2520 let node = &self.nodes[id];
2521 if let Some(content) = &node.content_span
2522 && content.end < node.span.end
2523 && !content.is_empty()
2524 {
2525 self.pending_mark_ends.borrow_mut().push(content.end);
2526 }
2527 }
2528
2529 /// The pending mark ends at or before `end_src`, for the row ending there
2530 /// — every mark rendered so far that closes on it. A mark's end never
2531 /// exceeds the end of the row its last glyph is on, so the leftovers are
2532 /// those of rows still to come.
2533 fn take_mark_ends(&self, end_src: usize) -> Vec<usize> {
2534 let mut pending = self.pending_mark_ends.borrow_mut();
2535 let (taken, kept): (Vec<usize>, Vec<usize>) =
2536 pending.drain(..).partition(|&o| o <= end_src);
2537 *pending = kept;
2538 taken
2539 }
2540 /// Whether `span` belongs to the line that is showing its raw markup. True
2541 /// only when a reveal line is set (`MarkupMode::Full`) and the two ranges
2542 /// actually meet.
2543 ///
2544 /// Touching at an endpoint counts: an emphasis ending exactly where the line
2545 /// does is on that line, and a zero-length reveal range (the caret alone on
2546 /// a blank line) still meets a node that starts there. The test is
2547 /// deliberately generous — the failure it avoids is revealing one delimiter
2548 /// of a pair while hiding the other, which looks like corruption rather than
2549 /// like markup.
2550 fn revealed(&self, span: &Range<usize>) -> bool {
2551 self.reveal
2552 .as_ref()
2553 .is_some_and(|r| span.start <= r.end && r.start <= span.end)
2554 }
2555
2556 /// The `(opening, closing)` source byte ranges of a node's delimiters — the
2557 /// bytes its `span` holds that its `content_span` doesn't.
2558 ///
2559 /// This is how *every* inline delimiter is recovered, rather than a table of
2560 /// spellings per kind: twig gives `*em*` a span of `13..17` and a content
2561 /// span of `14..16`, so the gaps at each end are the delimiters, whatever
2562 /// they happen to be. That matters because one kind has many spellings —
2563 /// `*em*` and `_em_` are both emphasis, `` `x` `` and ``` ``x`` ``` both
2564 /// verbatim — and re-deriving the text from the source is the only way to
2565 /// show back what the author actually typed. It also gets a link's
2566 /// asymmetric `[` / `](dest)` right for free.
2567 ///
2568 /// `None` when the node has no content span, or when content and span
2569 /// coincide (nothing was elided, so there is nothing to reveal).
2570 fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
2571 let node = &self.nodes[id];
2572 let content = node.content_span.clone()?;
2573 let span = node.span.clone();
2574 // A content span that escapes its own node's span means the two are
2575 // describing different things; reveal nothing rather than slice wildly.
2576 if content.start < span.start || content.end > span.end {
2577 return None;
2578 }
2579 let (open, close) = (span.start..content.start, content.end..span.end);
2580 // A delimiter that spans a newline isn't this line's to reveal — a setext
2581 // heading's `\n=====` underline is the case that arises in practice. It
2582 // would also inject a `'\n'` glyph, which `emit_wrapped` reads as a hard
2583 // row break, so the row would split where the author wrote no break.
2584 let multiline =
2585 |r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
2586 if multiline(&open) || multiline(&close) {
2587 return None;
2588 }
2589 (!open.is_empty() || !close.is_empty()).then_some((open, close))
2590 }
2591
2592 /// Emit the source bytes of `range` as revealed markup — real glyphs, each
2593 /// mapped to its own source byte and each a caret stop, so a delimiter shown
2594 /// is a delimiter that can be selected, edited and deleted like any other
2595 /// text. Styled [`Role::Delimiter`] on top of the run's own style, which is
2596 /// how a frontend tells scaffolding from prose and dims it.
2597 ///
2598 /// Deliberately *not* [`push_escaped_text`]: this is raw source, not parsed
2599 /// text, so there is no escape-driven drift between the two to correct.
2600 fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
2601 let Some(text) = self.source.get(range.clone()) else {
2602 return;
2603 };
2604 push_text(out, text, range.start, base.role(Role::Delimiter));
2605 }
2606
2607 /// Render an inline node's children wrapped in its raw delimiters when the
2608 /// node is on the revealed line, and bare (delimiters resolved away) when it
2609 /// isn't — the shared body of every delimiter-bearing arm of
2610 /// [`inline`](Self::inline).
2611 ///
2612 /// `style` is the resolved styling the content still gets in *both* modes:
2613 /// revealing `*em*` shows the asterisks *and* keeps the text italic, the
2614 /// live-preview behaviour. Showing the markup is not the same as turning the
2615 /// rendering off — that is what [`crate::View::Source`] is for.
2616 fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
2617 let show = self
2618 .revealed(&self.nodes[id].span)
2619 .then(|| self.delims(id))
2620 .flatten();
2621 if let Some((open, _)) = &show {
2622 self.push_delim(out, open, style);
2623 }
2624 self.recurse(id, style, out);
2625 match &show {
2626 Some((_, close)) => self.push_delim(out, close, style),
2627 // Hidden, so the content's end has no glyph after it: give the
2628 // caret its home there.
2629 None => self.note_mark_end(id),
2630 }
2631 }
2632
2633 fn children(&self, id: usize) -> Vec<usize> {
2634 let mut out = Vec::new();
2635 let mut c = self.nodes[id].first_child;
2636 while let Some(cid) = c {
2637 out.push(cid.0 as usize);
2638 c = self.nodes[cid.0 as usize].next_sibling;
2639 }
2640 out
2641 }
2642
2643 /// Render a node's block children, a blank separator between each. `tight`
2644 /// suppresses the *fabricated* separator between adjacent children that share
2645 /// a source line boundary — a tight list item and the sub-list nested in it —
2646 /// while a real blank source line between them still opens a gap.
2647 fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
2648 // Frontmatter (a leading `metadata` block) is document metadata, not
2649 // prose: hide it entirely in the rich-text view. Skipping it here means
2650 // no phantom blank rows for its lines and no separator before the first
2651 // real block — the document opens straight into its content.
2652 let kids: Vec<usize> = self
2653 .children(id)
2654 .into_iter()
2655 .filter(|&c| self.nodes[c].kind != Kind::Metadata)
2656 .collect();
2657 let mut above: Option<BlockClass> = None;
2658 for child in kids {
2659 let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2660 let before_sep = self.rows.len();
2661 if let Some(above) = above {
2662 self.emit_separators_before(
2663 self.nodes[child].span.start,
2664 pc,
2665 !tight,
2666 Boundary { above, below },
2667 );
2668 }
2669 // The first *drawn* child wears the first-row prefix (a bullet, a
2670 // footnote label), not the first child: a comment opening a list
2671 // item draws nothing, and the bullet belongs to what follows it.
2672 let first = if above.is_none() { pf } else { pc };
2673 if self.block_or_hidden(child, before_sep, first, pc) {
2674 above = Some(below);
2675 }
2676 }
2677 }
2678
2679 /// Render `child` after the separator [`Builder::emit_separators_before`]
2680 /// spelled for it from row `before_sep` on, and say whether it drew
2681 /// anything.
2682 ///
2683 /// A block that draws no rows — an HTML comment, which the rich view hides
2684 /// the way it hides frontmatter — is still *there* in the source, and the
2685 /// walk has to step over it: `last_off` moves past it so the next separator
2686 /// counts the blank lines from its end, not from wherever the last drawn
2687 /// block stopped. Left where it was, the separator counted every line of the
2688 /// comment as a blank row; and the cached path, whose per-block builder
2689 /// starts at offset 0, handed back a `last_off` of 0 and counted every line
2690 /// of the *document* — one phantom blank row per source line, once per
2691 /// comment. The separator drawn for it is taken back too, so a hidden block
2692 /// leaves no gap of its own: what stands either side of it meets across one
2693 /// boundary, as if the comment were not there.
2694 fn block_or_hidden(
2695 &mut self,
2696 child: usize,
2697 before_sep: usize,
2698 pf: &[Glyph],
2699 pc: &[Glyph],
2700 ) -> bool {
2701 let after_sep = self.rows.len();
2702 self.block(child, pf, pc);
2703 if self.rows.len() > after_sep {
2704 return true;
2705 }
2706 self.rows.truncate(before_sep);
2707 let end = self.nodes[child].span.end;
2708 self.last_off = self.last_off.max(end);
2709 self.stepped_over = self.stepped_over.max(end);
2710 false
2711 }
2712
2713 /// Render an explicit, ordered list of top-level blocks — [`Builder::blocks`]
2714 /// for a walk that isn't "the children of one node". The document's top level
2715 /// no longer is: a footnote definition is a root beside `doc`, not under it,
2716 /// and [`top_level`] merges it into this list by source position.
2717 ///
2718 /// The separator between blocks is spelled by the same
2719 /// [`Builder::emit_separators_before`] the incremental top-level walk in
2720 /// [`build_cached`] uses, so the two paths can't drift on how a boundary
2721 /// looks.
2722 ///
2723 /// Returns the class of the last block that drew anything — what the
2724 /// trailing blank lines close — or `None` when nothing did.
2725 fn top_blocks(&mut self, ids: &[usize]) -> Option<BlockClass> {
2726 let mut above: Option<BlockClass> = None;
2727 for &child in ids {
2728 let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2729 let before_sep = self.rows.len();
2730 if let Some(above) = above {
2731 self.emit_separators_before(
2732 self.nodes[child].span.start,
2733 &[],
2734 true,
2735 Boundary { above, below },
2736 );
2737 }
2738 if self.block_or_hidden(child, before_sep, &[], &[]) {
2739 above = Some(below);
2740 }
2741 }
2742 above
2743 }
2744
2745 /// Emit the blank separator row(s) that sit between a block ending at the
2746 /// current `last_off` and the next block starting at `next_start`, wearing
2747 /// the continuation prefix `pc`. Shared by [`Builder::blocks`] and the
2748 /// incremental top-level walk so the two can't drift on how a boundary is
2749 /// spelled.
2750 ///
2751 /// The blank line(s) between two blocks are real caret stops, each needing
2752 /// its *own* source offset — one strictly past the previous block's content,
2753 /// else it collides with that block's last row and `pos_of_offset`
2754 /// (first-match-wins) would resolve the caret onto the wrong row, pinning
2755 /// downward motion there.
2756 ///
2757 /// One row *per* blank source line, not a single collapsed separator: an
2758 /// empty paragraph opened between two blocks (Enter in the gap,
2759 /// `…\n\n\n\n…`) must be a navigable empty row, not vanish — else the caret
2760 /// in it snaps onto the *next* block's start and Enter looks like it did
2761 /// nothing.
2762 fn emit_separators_before(
2763 &mut self,
2764 next_start: usize,
2765 pc: &[Glyph],
2766 synthetic: bool,
2767 boundary: Boundary,
2768 ) {
2769 let mut offs = self.blank_rows_between(self.last_off, next_start);
2770 if offs.is_empty() {
2771 if !synthetic {
2772 // A tight list item's own text sits directly above the sub-list
2773 // nested in it — no fabricated gap. The "breathe" row belongs
2774 // between free-standing blocks, not between an item and its
2775 // child list, which the source writes on the very next line. A
2776 // real blank source line (a loose list) still lands a gap below,
2777 // because `blank_rows_between` found it and we never reach here.
2778 return;
2779 }
2780 // A tight gap with no blank line (e.g. a heading directly above its
2781 // text): keep the one conventional separator row so blocks still
2782 // breathe, as they always have.
2783 offs.push(self.blank_line_offset(self.last_off, next_start));
2784 }
2785 let last = offs.len() - 1;
2786 for (k, end_src) in offs.into_iter().enumerate() {
2787 // Only the drawn-only rows carry the boundary: the navigable blank
2788 // lines between them (and every blank line under preserve-soft flow)
2789 // are somewhere text can go, not a gap between blocks, and a frontend
2790 // that shrank one would be shrinking a line the author is typing on.
2791 let drawn = !self.preserve_soft && (k == 0 || k == last);
2792 // The blank line a boundary is *drawn* with isn't a place text can
2793 // go. The first one closes the block above and the last one opens the
2794 // block below — with a single blank line, the usual case, doing both
2795 // at once. Typing on either just continues the paragraph it abuts,
2796 // since the blank line it would need to be a paragraph of its own is
2797 // the very line being typed on. So they're a gap, like a table's
2798 // border: drawn, clickable, never a caret's home.
2799 //
2800 // The lines *between* them are the real ones. That's what Enter
2801 // opens: it inserts a paragraph break (`\n\n`), which leaves a blank
2802 // line spare on each side and the caret on the navigable line
2803 // between them.
2804 //
2805 // Preserve flow is the exception: there a bare `\n` is a visible line
2806 // break the author edits directly, so a lone blank line *is* a caret
2807 // home — typing on it makes the soft break the mode exists to show,
2808 // and Enter at a line's end lands the caret on exactly this row. So no
2809 // separator is drawn-only; every blank line is navigable.
2810 self.rows.push(VRow {
2811 glyphs: pc.to_vec(),
2812 end_src,
2813 decoration: drawn,
2814 code: false,
2815 code_lang: None,
2816 directive: false,
2817 directive_label: None,
2818 media: None,
2819 task: None,
2820 leaf_directive: None,
2821 heading: None,
2822 align: None,
2823 line_height: None,
2824 boundary: drawn.then_some(boundary),
2825 mark_ends: Vec::new(),
2826 });
2827 }
2828 }
2829
2830 /// One block, drawn under whatever presentation the containers around it
2831 /// impose.
2832 ///
2833 /// A container named `div` with `Element` origin is transparent already —
2834 /// its children draw as themselves — and it now also *contributes* its
2835 /// vocabulary keys to every block it holds. That is the reading side of
2836 /// twig's own rule for where a Markdown block's attributes live: there is
2837 /// no attribute syntax to put on the paragraph, so `set_block_attrs` writes
2838 /// a `<div …>` around it, and reading one back has to look through the div.
2839 /// `<div class="center">` around three paragraphs centres all three, which
2840 /// is what the author of that HTML meant, and around one is the sole-child
2841 /// shape twig writes.
2842 ///
2843 /// Saved and restored rather than pushed onto a stack, so a nested div
2844 /// reads its own keys over its parent's and the block *after* the div is
2845 /// unaffected.
2846 fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2847 if element_tag(&self.nodes[id]) == Some("div") {
2848 let saved = self.presentation;
2849 self.presentation = saved.under(&self.nodes[id].attrs);
2850 self.block_kind(id, pf, pc);
2851 self.presentation = saved;
2852 // Step the walk past the closing `</div>`, as the fenced-div arm
2853 // below anchors past its `:::`. The tag sits on a line of its own
2854 // after the last child and the blank line under it, and the rich
2855 // view draws nothing for it — so left where the last child ended,
2856 // the separator logic read the tag's line as a blank line between
2857 // the div and the block below, and drew a navigable empty row there
2858 // that the author never opened and Backspace could not close; and
2859 // at the end of the document the trailing count read it as an empty
2860 // paragraph the author had left. It is hidden markup the walk steps
2861 // over, which is what `stepped_over` records, so both counts start
2862 // past it.
2863 let end = self.nodes[id].span.end;
2864 self.last_off = self.last_off.max(end);
2865 self.stepped_over = self.stepped_over.max(end);
2866 return;
2867 }
2868 self.block_kind(id, pf, pc);
2869 }
2870
2871 fn block_kind(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2872 let node = &self.nodes[id];
2873 match node.kind.as_str() {
2874 "doc" | "section" => self.blocks(id, pf, pc, false),
2875 "heading" => {
2876 // A heading whose only visible content is a single image — a
2877 // banner set in an `<h1>` (`<h1><picture><img></picture></h1>`),
2878 // or `# ` — is a block picture, not text. Render
2879 // it as one; anything with real heading text falls through.
2880 if let Some((m, kind)) = self.media_only(id) {
2881 self.block_media(m, kind, id, pf);
2882 return;
2883 }
2884 let level = node.level.unwrap_or(1);
2885 // A `data-size` on a heading scales the *heading's* ramp, not
2886 // the body's — the role and the step compose rather than
2887 // compete, which is the same thing a colour does to a link.
2888 let pres = self.presentation.under(&node.attrs);
2889 let style = pres.over(heading_style(level));
2890 let mut glyphs = Vec::new();
2891 // On the revealed line the `# ` comes back as real, editable
2892 // text in front of the heading. Only the opening marker: a
2893 // closing `#`-run (`## title ##`) is covered by the same
2894 // `delims` pair, and a setext underline is excluded there for
2895 // being on another line entirely.
2896 if let Some((open, close)) =
2897 self.revealed(&node.span).then(|| self.delims(id)).flatten()
2898 {
2899 self.push_delim(&mut glyphs, &open, style);
2900 glyphs.extend(self.inline_children_with_trailing(id, style));
2901 self.push_delim(&mut glyphs, &close, style);
2902 } else {
2903 glyphs = self.inline_children_with_trailing(id, style);
2904 }
2905 // An *empty* heading — `# ` with nothing typed after it, which is
2906 // what the toolbar's H1 leaves on a blank line — has no glyph for
2907 // its row to end on, so the fallback below is the row's whole
2908 // extent: its only caret stop, and the offset every row after it
2909 // is measured from. The block's start is the wrong answer for
2910 // both, because it sits *in front of* the `# ` the rich view
2911 // hides: the caret drew (and typed) before the hashes, and the
2912 // rows below inherited an offset short by the marker's length,
2913 // which put the caret on one of them the moment the heading grew
2914 // text. Its content's start is where the caret belongs.
2915 let home = heading_content_start(self.source, &node.span);
2916 let first = self.rows.len();
2917 self.emit_wrapped(glyphs, home, pf, pc);
2918 // Stamp the level on every row the heading just emitted — a
2919 // wrapped heading's continuation rows as much as its first, and
2920 // an empty one's single glyphless row, which is the whole point
2921 // (see [`VRow::heading`]).
2922 for row in &mut self.rows[first..] {
2923 row.heading = Some(level.min(255) as u8);
2924 row.align = pres.align;
2925 row.line_height = pres.line_height;
2926 }
2927 }
2928 "block_quote" => {
2929 let (start, end) = (node.span.start, node.span.end);
2930 let gutter = synth("│ ", Role::QuoteGutter, start);
2931 let f = concat(pf, &gutter);
2932 let c = concat(pc, &gutter);
2933 // A childless quote — a bare `> ` on an otherwise blank line,
2934 // which is what the toolbar's Quote button leaves there — has no
2935 // inner block to carry the gutter or a caret home, so `blocks`
2936 // emitted *nothing at all*: the quote didn't merely draw
2937 // unstyled, it disappeared, and a document that was only `> `
2938 // rendered zero rows with the caret nowhere to stand. Emit the
2939 // gutter row itself, ending just past the marker, exactly as an
2940 // empty `list_item` emits its bare bullet.
2941 if self.children(id).is_empty() {
2942 self.push_row_at(f, end.min(self.source.len()));
2943 } else {
2944 self.blocks(id, &f, &c, false);
2945 self.emit_quote_trailing_lines(&c, end);
2946 }
2947 }
2948 // A generic `:::name{.class}` fenced-div container (twig's
2949 // `directive`, container form). Core is agnostic of `name` — it's
2950 // the host app's vocabulary (diaryx's `vis` for audience
2951 // visibility, say) and isn't available here regardless: twig only
2952 // threads an `element`'s tag name through `FlatNode::name`, not a
2953 // directive's own identifier. Every row gets marked `directive` (a
2954 // frontend draws a tinted panel around each maximal run, the
2955 // `code`/`code_block` recipe) and the first row carries a label —
2956 // the way a code fence's language rides only its first row.
2957 //
2958 // The label reads BOTH attribute conventions diaryx content
2959 // actually uses: twig's own dot-prefixed classes (`{.public
2960 // .family}`, one combined `class` attr) and bare pandoc-style
2961 // words with no leading dot (`{public family}` — the syntax
2962 // `diaryx_core::visibility`'s hand-rolled publish-time filter and
2963 // apps/web's directive serializer both write; twig parses each
2964 // bare word as its own attribute with an empty value, per
2965 // `languages/markdown/attributes.zig`). Reading only `.class`
2966 // would leave every *existing* diaryx `:::vis{...}` block
2967 // unlabeled.
2968 // Only the *container* form is the panel below. A `text` directive
2969 // is inline and never reaches the block walker (see `is_inline`); a
2970 // `leaf` one is a standalone block with no body, drawn as a
2971 // placeholder the way an image is.
2972 "container"
2973 if container_is_directive(node)
2974 && node.directive_form == Some(DirectiveForm::Leaf) =>
2975 {
2976 self.block_directive(id, pf);
2977 }
2978 // djot has no *leaf* directive form. `insert_directive` spells the
2979 // same document as an empty `::: page-break` fence — a container
2980 // with nothing in it — and the name comes back as the fence's one
2981 // class rather than as the node's name, because djot's div is
2982 // anonymous. Draw it as the placeholder Markdown's `::page-break`
2983 // gets, so a frontend that paginates on a `page-break`
2984 // [`DirectiveMark`] cannot tell which format the file is in.
2985 //
2986 // Narrow on purpose: only an *anonymous* empty fence. A Markdown
2987 // `:::note` with nothing in it keeps the reading it has, because
2988 // its name is its own and nothing about it says "a block with no
2989 // body" the way djot's spelling of a leaf directive does.
2990 "container"
2991 if container_is_directive(node)
2992 && node.directive_form == Some(DirectiveForm::Container)
2993 && node.name.as_deref().unwrap_or_default().is_empty()
2994 && self.children(id).is_empty()
2995 && !leaf_directive_identity(node).0.is_empty() =>
2996 {
2997 self.block_directive(id, pf);
2998 }
2999 "container" if container_is_directive(node) => {
3000 let label = directive_attr_label(&node.attrs);
3001 let start_row = self.rows.len();
3002 self.blocks(id, pf, pc, false);
3003 for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
3004 row.directive = true;
3005 if i == 0 {
3006 row.directive_label = label.clone();
3007 }
3008 }
3009 // Anchor the block's end past its closing `:::` fence, exactly as
3010 // the code-block arm anchors past its ```` ``` ````. A container's
3011 // last content row ends at its last *child*, before the fence and
3012 // the blank line under it, so the separator logic counted the
3013 // fence line as a blank row of its own and drew a second boundary
3014 // — one gap's worth of margin twice, under every fenced div.
3015 self.last_off = node.span.end;
3016 }
3017 "bullet_list" | "ordered_list" | "task_list" => {
3018 let ordered = node.kind == Kind::OrderedList;
3019 let mut item_no = 0usize;
3020 let kids = self.children(id);
3021 for (i, child) in kids.iter().copied().enumerate() {
3022 let kind = &self.nodes[child].kind;
3023 if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
3024 let start = self.nodes[child].span.start;
3025 item_no += 1;
3026 // A task item's box replaces the bullet rather than
3027 // joining it. The `[ ] ` that spells it is markup twig
3028 // has already consumed — the item's paragraph *content*
3029 // starts past it — so without a drawn box a task item
3030 // was indistinguishable from a plain bullet, ticked or
3031 // not. `☐`/`☑` is the marker for the same reason `•` is:
3032 // it stands where the source's own marker stands. Which
3033 // way it faces is `checked`, straight off the node.
3034 let checked = self.nodes[child].checked;
3035 let marker = match (checked, ordered) {
3036 (Some(true), _) => "☑ ".to_string(),
3037 (Some(false), _) => "☐ ".to_string(),
3038 (None, true) => format!("{item_no}. "),
3039 (None, false) => "• ".to_string(),
3040 };
3041 let bullet = synth(&marker, Role::ListMarker, start);
3042 let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
3043 let first_row = self.rows.len();
3044 self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
3045 // On the item's first row, the way `code_lang` rides the
3046 // first row of its block.
3047 if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
3048 row.task = Some(c);
3049 }
3050 } else {
3051 // twig can nest a *following* top-level block as a direct
3052 // child of the list rather than a sibling of it — e.g.
3053 // `- item\n\n> quote` parses the block quote under the
3054 // `bullet_list`. It isn't a list item, so render it de-nested:
3055 // no bullet, at the list's own prefix, with the usual block
3056 // separator — never `• │ quote`.
3057 if i > 0 {
3058 self.emit_separators_before(
3059 self.nodes[child].span.start,
3060 pc,
3061 true,
3062 Boundary {
3063 above: BlockClass::from_node_kind(
3064 &self.nodes[kids[i - 1]].kind,
3065 ),
3066 below: BlockClass::from_node_kind(&self.nodes[child].kind),
3067 },
3068 );
3069 }
3070 self.block(child, pc, pc);
3071 }
3072 }
3073 }
3074 "list_item" | "task_list_item" => {
3075 // A childless item — the empty bullet you get the instant you
3076 // press Enter to open a new one — has no inner block to carry the
3077 // marker prefix or a caret home, so `blocks` would emit nothing
3078 // and the new bullet simply wouldn't appear until something was
3079 // typed into it. Emit the prefixed row itself, ending at a caret
3080 // stop just past the marker (the item's `span.end`), the way an
3081 // empty paragraph emits its one prefixed row via `emit_wrapped`.
3082 if self.children(id).is_empty() {
3083 let home = self.nodes[id].span.end.min(self.source.len());
3084 self.push_row_at(pf.to_vec(), home);
3085 } else {
3086 // Tight: an item's text and the list nested under it butt
3087 // together (`• a` / ` • b`), no fabricated blank row between —
3088 // a loose item's real blank line still parts them.
3089 self.blocks(id, pf, pc, true);
3090 }
3091 }
3092 // A footnote *definition* (`[^1]: the note`). It reaches this walker
3093 // only because [`top_level`] merges it back in — twig hangs it off no
3094 // parent at all, so a walk from `doc` never sees one and every byte
3095 // of its body used to render as nothing.
3096 //
3097 // Drawn as a hanging-indent item, the way a list item is: the marker
3098 // reads `[1] `, matching the `[1]` its references render as, so the
3099 // two can be paired by eye, and the body wraps under it. The marker
3100 // is synthetic decoration (one shared offset, never a caret stop) —
3101 // the `[^1]: ` that spells it in the source is markup, hidden like a
3102 // heading's `# `.
3103 "footnote" => {
3104 let (start, end) = (node.span.start, node.span.end);
3105 let source = self.source;
3106 let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
3107 let indent = " ".repeat(text_width(&marker));
3108 let f = concat(pf, &synth(&marker, Role::ListMarker, start));
3109 let c = concat(pc, &synth(&indent, Role::Body, start));
3110 if self.children(id).is_empty() {
3111 // A definition with no body yet — the instant `[^1]: ` has
3112 // been typed and nothing after it. `blocks` would emit
3113 // nothing and the definition simply wouldn't appear, so emit
3114 // the marker row itself with a caret home just past it,
3115 // exactly as an empty list item does.
3116 self.push_row_at(f, end.min(source.len()));
3117 } else {
3118 self.blocks(id, &f, &c, false);
3119 }
3120 }
3121 // A link reference definition (`[foo]: /url`): resolved by label
3122 // into the links that use it, and drawn nowhere — the rich view has
3123 // no more use for its line than for a comment's. It is walked at all
3124 // (see [`top_level`]) so [`Builder::block_or_hidden`] can step the
3125 // walk past its bytes rather than count them as blank lines.
3126 "reference" => {}
3127 "table" => self.table(id, pf, pc),
3128 "code_block" => {
3129 let style = Style::default().role(Role::Code);
3130 let text = node.text.clone().unwrap_or_default();
3131 // Cut the block's *terminator*, not every trailing newline. A
3132 // block whose last line is empty spells that as a second `\n`,
3133 // and `trim_end_matches` ate it along with the terminator: the
3134 // Return that made the line got no row, so the caret placed on
3135 // it fell through to the paragraph below and typing landed
3136 // outside the block. twig's `content_span` is `text` less
3137 // exactly this one newline, so cutting one and no more is also
3138 // what keeps `code_line_offsets` lined up.
3139 let lines: Vec<&str> = text
3140 .strip_suffix('\n')
3141 .unwrap_or(text.as_str())
3142 .split('\n')
3143 .collect();
3144 // Each line at its own source offset, so the caret can walk the
3145 // code a character at a time like any other text. Where the
3146 // lines can't be lined up with the source there's no honest
3147 // offset to give, so the block maps coarsely to its start (and
3148 // stays a source-view job, as all of it once was).
3149 let offs = node
3150 .content_span
3151 .as_ref()
3152 .and_then(|c| self.code_line_offsets(c, &lines));
3153 // The fence's info string, carried on the block's first row as
3154 // its language label (`None` for an indented block or a bare
3155 // fence). Kept on the row so it rides the block cache.
3156 let lang = code_language(self.source, node.span.start);
3157 // The block's syntax highlighting, a token per byte range of
3158 // each line — `None` unless the fence names a language the
3159 // grammars know (and unless the `syntax` feature is on). Done
3160 // here, once per build of the block, because the rows it
3161 // colours ride the block cache: an edit elsewhere in the
3162 // document reuses them, tokens and all.
3163 let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
3164 for (i, raw) in lines.iter().enumerate() {
3165 let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
3166 // No gutter glyph: the block is set apart by the border and
3167 // tint a frontend draws around the whole run of `code` rows,
3168 // not by a per-line mark. Just the block prefix (a list
3169 // indent, a quote gutter) and the code text.
3170 let mut glyphs: Vec<Glyph> = pf.to_vec();
3171 match tokens.as_ref().and_then(|t| t.get(i)) {
3172 Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
3173 None => push_text(&mut glyphs, raw, at, style),
3174 }
3175 // Explicitly past the line's *text*: a blank code line has no
3176 // glyph, and any prefix's offset would put the row's end
3177 // inside the next line.
3178 self.push_row_at(glyphs, at + raw.len());
3179 if let Some(row) = self.rows.last_mut() {
3180 row.code = true;
3181 if i == 0 {
3182 row.code_lang = lang.clone();
3183 }
3184 }
3185 }
3186 // Anchor the block's end past its closing fence. Its last content
3187 // row ends at the last code line, before the ``` and the blank
3188 // line under it; without this the separator logic would count the
3189 // closing-fence line as its own blank row and open a phantom
3190 // second gap below the block.
3191 self.last_off = node.span.end;
3192 }
3193 "thematic_break" => {
3194 let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
3195 let w = full.saturating_sub(prefix_width(pf)).max(4);
3196 let mut glyphs = pf.to_vec();
3197 for _ in 0..w {
3198 glyphs.push(Glyph {
3199 ch: '─',
3200 style: Style::default().role(Role::Rule),
3201 src: node.span.start,
3202 // A rule is a block the caret can sit on, as it always
3203 // has; it maps coarsely to the block's start.
3204 stop: true,
3205 });
3206 }
3207 // The dashes share one caret home in front of the atomic block,
3208 // while the row's end is the second home just past its source.
3209 // Without that trailing stop a final rule made the document end
3210 // unreachable: Right could not cross it and a click in the
3211 // empty space below it snapped back before the rule.
3212 let after_line = node.span.end
3213 + self.source[node.span.end..]
3214 .strip_prefix("\r\n")
3215 .map_or_else(
3216 || usize::from(self.source[node.span.end..].starts_with('\n')),
3217 |_| 2,
3218 );
3219 self.push_row_at(glyphs, after_line);
3220 }
3221 // A block-level image node with no wrapping paragraph — a promoted
3222 // top-level HTML `<img>` lands as a direct `doc` child like this
3223 // (a Markdown `` comes wrapped in a `para`, handled below).
3224 "image" => self.block_media(id, MediaKind::Image, id, pf),
3225 // The same case for a promoted top-level `<video>`/`<audio>`, which
3226 // arrives as a generic `container` rather than a node kind of its
3227 // own. It can't be found by the `media_only` scan below the way a
3228 // wrapped one is: that scan looks at a wrapper's *children*, and here
3229 // the media element is itself the block.
3230 "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3231 let kind = match element_tag(node) {
3232 Some("audio") => MediaKind::Audio,
3233 _ => MediaKind::Video,
3234 };
3235 self.block_media(id, kind, id, pf);
3236 }
3237 _ => {
3238 // A container of blocks, or an inline-bearing paragraph.
3239 let kids = self.children(id);
3240 // A block-level image: a paragraph (or other wrapper — a
3241 // `<picture>`, an `<h1>` banner) whose only visible content is a
3242 // single `image` node. Render it as a placeholder row + record an
3243 // [`MediaInfo`] a capable frontend replaces. An image mixed with
3244 // real text or other images on the line isn't block-level and
3245 // falls through to the inline path below, still as its alt text.
3246 if let Some((m, kind)) = self.media_only(id) {
3247 self.block_media(m, kind, id, pf);
3248 return;
3249 }
3250 let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
3251 if inline || kids.is_empty() {
3252 // The block's own attributes over its containers' — the
3253 // three run-level keys become the style its glyphs start
3254 // from, and the two line-level ones ride every row it
3255 // emits, a wrapped paragraph's continuations included.
3256 let pres = self.presentation.under(&node.attrs);
3257 let glyphs =
3258 self.inline_children_with_trailing(id, pres.over(Style::default()));
3259 if !glyphs.is_empty() {
3260 let first = self.rows.len();
3261 self.emit_wrapped(glyphs, node.span.start, pf, pc);
3262 for row in &mut self.rows[first..] {
3263 row.align = pres.align;
3264 row.line_height = pres.line_height;
3265 }
3266 }
3267 } else {
3268 self.blocks(id, pf, pc, false);
3269 }
3270 }
3271 }
3272 }
3273
3274 /// Render a table as a box-drawn grid: every column as wide as its widest
3275 /// cell, the header bold and ruled off, each cell padded to its column's
3276 /// alignment. This is the *default* monospace rendering (see
3277 /// [`VisualMap::rows`]); the same cells are also published structurally as
3278 /// [`TableInfo`], so a frontend that lays the grid out in its own units draws
3279 /// from there and skips the picture built here.
3280 ///
3281 /// The alignment comes from twig's `cell.alignment` — the delimiter row
3282 /// (`|:--|--:|`) that spells it out is consumed by the parser and leaves no
3283 /// node, so the snapshot is the only source for it.
3284 ///
3285 /// Borders and padding are *decoration*: they carry the source offset of the
3286 /// text they surround, so a click lands in that cell, but they're never
3287 /// caret stops — the caret steps cell-to-cell instead of into the box art.
3288 fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
3289 let node_end = self.nodes[id].span.end;
3290 // twig's shape is `[caption, row, row, …]`: the caption is always
3291 // present (usually empty in Markdown) and is not part of the grid.
3292 let row_ids: Vec<usize> = self
3293 .children(id)
3294 .into_iter()
3295 .filter(|&c| self.nodes[c].kind == Kind::Row)
3296 .collect();
3297 if row_ids.is_empty() {
3298 return;
3299 }
3300 // Lay every cell out first — the column widths depend on all of them.
3301 let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
3302 let heads: Vec<bool> = row_ids
3303 .iter()
3304 .map(|&r| self.nodes[r].head.unwrap_or(false))
3305 .collect();
3306 let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
3307 if cols == 0 {
3308 return;
3309 }
3310 let mut widths = vec![0usize; cols];
3311 for row in &grid {
3312 for (c, cell) in row.iter().enumerate() {
3313 widths[c] = widths[c].max(cell_width(&cell.glyphs));
3314 }
3315 }
3316 // Every column at its widest cell is only the *wish*; a grid wider than
3317 // the surface has its far side hanging off the edge where no amount of
3318 // caret motion can reach it. Cut it down to what's actually there, and
3319 // let the cells wrap into the space they're given.
3320 if let Some(w) = self.wrap {
3321 fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
3322 }
3323
3324 // Where the picture starts, so a frontend drawing its own grid knows
3325 // which rows to skip. Recorded before the first border goes down.
3326 let rows_start = self.rows.len();
3327
3328 let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
3329 self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
3330 for (ri, row) in grid.iter().enumerate() {
3331 self.push_table_row(row, &widths, pc);
3332 // The rule under the header: only where the head actually ends.
3333 let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
3334 if ends_head {
3335 let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
3336 self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
3337 }
3338 }
3339 // The bottom border is the one rule the caret can rest on: its end is
3340 // the table's trailing stop, the caret home just past the block — the
3341 // peer of a block picture's second stop, and of a rule's row end. Without
3342 // it a document ending in a table ended *inside* it: nothing after the
3343 // last cell was a stop, so Right could not leave the table, and a click
3344 // in the blank space under it snapped back into the last cell — or, on a
3345 // surface that resolved the click onto the border row, to the table's
3346 // first cell, since a decoration row's only stop is the nearest one.
3347 // Typing at the stop opens a paragraph first, as at a picture's — see
3348 // `Doc::open_paragraph_at_block_edge`. The glyphs stay non-stops at
3349 // `node_end`, so a click anywhere on the border lands past the table.
3350 self.push_rule_with_home(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
3351
3352 // The same cells the picture above was drawn from, published unwrapped
3353 // and unpadded for a frontend that lays them out in pixels.
3354 self.tables.push(TableInfo {
3355 rows_span: rows_start..self.rows.len(),
3356 end_src: node_end,
3357 // The *continuation* prefix: `pf` opens the block and only its first
3358 // row wears it, but every row of a grid is a continuation of the
3359 // block the table sits in.
3360 prefix: pc.to_vec(),
3361 grid: grid
3362 .into_iter()
3363 .zip(heads)
3364 .map(|(cells, head)| TableRow { head, cells })
3365 .collect(),
3366 });
3367 // The table's own end anchors whatever separator follows it; the border
3368 // rows deliberately don't move `last_off` (they hold no content).
3369 self.last_off = node_end;
3370 }
3371
3372 /// One row of laid-out cells, in column order.
3373 fn row_cells(&self, row: usize) -> Vec<TableCell> {
3374 // A cell is one source line, so a break within it is an explicit line
3375 // break (an inline `<br>`) that must render as a line of its own — not the
3376 // flow-folding space a break is in prose.
3377 self.break_glyph.set('\n');
3378 let cells = self
3379 .children(row)
3380 .into_iter()
3381 .filter(|&c| self.nodes[c].kind == Kind::Cell)
3382 .enumerate()
3383 .map(|(col, c)| {
3384 let n = &self.nodes[c];
3385 let style = if n.head.unwrap_or(false) {
3386 Style::default().bold()
3387 } else {
3388 Style::default()
3389 };
3390 // Only `content_span` bounds a cell's text, and an EMPTY cell
3391 // has none at all — twig records no interior for it — so both
3392 // offsets would fall back to the cell's `span.start`: on the
3393 // pipe that opens it, or (under a twig that gave every cell
3394 // the whole row's span) the row's start, where every empty
3395 // cell collapses onto one spot before the first `│` and a
3396 // caret there types *before* the table. Derive the interior
3397 // from the span's own pipes and this cell's column instead,
3398 // so each empty cell has a distinct, editable caret home.
3399 let span = n.content_span.clone().unwrap_or_else(|| {
3400 let off = empty_cell_offset(
3401 &self.source[n.span.start.min(self.source.len())
3402 ..n.span.end.min(self.source.len())],
3403 n.span.start,
3404 col,
3405 );
3406 off..off
3407 });
3408 TableCell {
3409 glyphs: self.inline_children(c, style),
3410 start: span.start,
3411 end: span.end,
3412 align: n.alignment.unwrap_or(Alignment::Default),
3413 }
3414 })
3415 .collect();
3416 self.break_glyph.set(' ');
3417 cells
3418 }
3419
3420 /// A horizontal rule between/around rows — entirely decoration.
3421 fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3422 self.push_rule_row(text, src, prefix, true);
3423 }
3424
3425 /// A table's bottom border: drawn like the other rules, but a row the caret
3426 /// can rest on, its end (`src`) being the table's trailing stop.
3427 fn push_rule_with_home(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3428 self.push_rule_row(text, src, prefix, false);
3429 }
3430
3431 fn push_rule_row(&mut self, text: &str, src: usize, prefix: &[Glyph], decoration: bool) {
3432 let glyphs = concat(prefix, &synth(text, Role::Rule, src));
3433 self.rows.push(VRow {
3434 glyphs,
3435 end_src: src,
3436 decoration,
3437 code: false,
3438 code_lang: None,
3439 directive: false,
3440 directive_label: None,
3441 media: None,
3442 task: None,
3443 leaf_directive: None,
3444 heading: None,
3445 align: None,
3446 line_height: None,
3447 boundary: None,
3448 mark_ends: Vec::new(),
3449 });
3450 }
3451
3452 /// One `│ a │ b │` row of the grid: real cell text between decoration.
3453 ///
3454 /// A row of cells is not a row of the screen — a cell wrapped to its column
3455 /// spans several, each one `│`-divided across the full width so the grid
3456 /// stays square. Cells in the same row are laid out independently and run
3457 /// out at their own heights; a column that has run dry pads out as
3458 /// decoration while its neighbours keep going.
3459 fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
3460 let fallback = cells.last().map(|c| c.end).unwrap_or(0);
3461 let laid: Vec<Vec<Vec<Glyph>>> = cells
3462 .iter()
3463 .enumerate()
3464 .map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
3465 .collect();
3466 let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
3467
3468 for j in 0..height {
3469 let mut glyphs = prefix.to_vec();
3470 for (ci, &w) in widths.iter().enumerate() {
3471 let cell = cells.get(ci);
3472 let line = laid.get(ci).and_then(|l| l.get(j));
3473 // The divider before this column belongs to the cell it
3474 // introduces, so clicking it lands in that cell — on this line
3475 // of it, which is what's next to the divider being clicked.
3476 let at = line
3477 .and_then(|l| l.first().map(|g| g.src))
3478 .or_else(|| cell.map(|c| c.start))
3479 .unwrap_or(fallback);
3480 glyphs.extend(synth("│", Role::Rule, at));
3481 match (cell, line) {
3482 (Some(cell), Some(line)) => {
3483 let pad = w.saturating_sub(glyphs_width(line));
3484 let (lead, trail) = match cell.align {
3485 Alignment::Right => (pad, 0),
3486 Alignment::Center => (pad / 2, pad - pad / 2),
3487 Alignment::Left | Alignment::Default => (0, pad),
3488 };
3489 // Every line renders at least one space after its text
3490 // (the gutter before `│`), so there is always somewhere
3491 // to put the "after the last character" caret a line
3492 // needs. It's the one padding glyph that is a stop: on
3493 // the cell's last line that's the cell's end, and on any
3494 // other it's the space the wrap consumed.
3495 let last = laid[ci].len() == j + 1;
3496 let end = match last {
3497 true => cell.end,
3498 false => line
3499 .last()
3500 .map(|g| g.src + g.ch.len_utf8())
3501 .unwrap_or(cell.end),
3502 };
3503 glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
3504 glyphs.extend(line.iter().cloned());
3505 glyphs.push(Glyph {
3506 ch: ' ',
3507 style: Style::default(),
3508 src: end,
3509 stop: true,
3510 });
3511 glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
3512 }
3513 // A ragged row, or a column whose cell ended higher up: pad
3514 // it out so the grid stays square.
3515 _ => {
3516 let at = cell.map(|c| c.end).unwrap_or(fallback);
3517 glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
3518 }
3519 }
3520 }
3521 glyphs.extend(synth("│", Role::Rule, fallback));
3522 // The row ends where its last stop does. A table row has no gap
3523 // between its final cell and the border, so inventing an end past
3524 // that would be a stop with nothing under it.
3525 let end_src = glyphs
3526 .iter()
3527 .rev()
3528 .find(|g| g.stop)
3529 .map_or(fallback, |g| g.src);
3530 let mark_ends = self.take_mark_ends(end_src);
3531 self.rows.push(VRow {
3532 glyphs,
3533 end_src,
3534 decoration: false,
3535 code: false,
3536 code_lang: None,
3537 directive: false,
3538 directive_label: None,
3539 media: None,
3540 task: None,
3541 leaf_directive: None,
3542 heading: None,
3543 align: None,
3544 line_height: None,
3545 boundary: None,
3546 mark_ends,
3547 });
3548 }
3549 }
3550
3551 /// Render a block-level image, video, or audio as one placeholder row: the
3552 /// `🖼 alt` / `🎬 alt` / `🔊 alt` label styled [`Role::Image`], every glyph
3553 /// mapped to the media's start offset and a caret stop there (they share the
3554 /// offset, so the stop table dedups them to a single home in front of it, as
3555 /// a rule's dashes do), and the row's end stop set past it so the caret can
3556 /// also rest after it. The row carries a [`MediaMark`] so [`media_spans`]
3557 /// publishes it as a [`MediaInfo`] a capable frontend replaces with the real
3558 /// picture or player; a plain surface paints the label as-is. `pf` is the
3559 /// block prefix (a list indent, a quote gutter) the row opens with, exactly
3560 /// as every other block honours it.
3561 fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
3562 let node = &self.nodes[img];
3563 let start = node.span.start;
3564 let end = node.span.end;
3565 // An `image`'s URL is twig's `destination`; a `<video>`/`<audio>` is a
3566 // generic element, so its URL is the `src` attribute — and may be absent
3567 // entirely, the element naming its candidates in child `<source>`s.
3568 let destination = match kind {
3569 MediaKind::Image => node.destination.clone().unwrap_or_default(),
3570 MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
3571 };
3572 let poster = match kind {
3573 MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
3574 MediaKind::Image | MediaKind::Audio => String::new(),
3575 };
3576 // The `<source>`s under the media element itself, not under `wrapper`: a
3577 // `<video>` is its own container, unlike an `<img>`, whose `<picture>`
3578 // alternatives are its *siblings* and so only reachable from the wrapper.
3579 let sources = match kind {
3580 MediaKind::Image => self.media_sources(wrapper),
3581 MediaKind::Video | MediaKind::Audio => self.media_sources(img),
3582 };
3583 let alt = self.image_alt(img);
3584 let sigil = kind.sigil();
3585 let label = if alt.is_empty() {
3586 // With no alt, name the file — but a `<video>` with neither `src` nor
3587 // alt has only its `<source>`s to be named by, so fall back to the
3588 // first candidate rather than labelling the row a bare sigil.
3589 let named = if destination.is_empty() {
3590 sources
3591 .first()
3592 .map(|s| s.srcset.as_str())
3593 .unwrap_or_default()
3594 } else {
3595 &destination
3596 };
3597 format!("{sigil} {}", media_label(named))
3598 } else {
3599 format!("{sigil} {alt}")
3600 };
3601 let style = Style::default().role(Role::Image);
3602 let mut glyphs = pf.to_vec();
3603 for ch in label.chars() {
3604 glyphs.push(Glyph {
3605 ch,
3606 style,
3607 src: start,
3608 stop: true,
3609 });
3610 }
3611 // How many rows the frontend wants for this picture: the label row plus
3612 // the blank fillers below it. Absent (a GUI that lays images out in
3613 // pixels, an image that didn't resolve, or a plain surface) means the
3614 // bare one-row placeholder.
3615 let rows = self
3616 .media_rows
3617 .get(&destination)
3618 .copied()
3619 .unwrap_or(1)
3620 .max(1);
3621 // End past the image so the caret has a stop after it: the last glyph's
3622 // offset is the image *start*, not its extent, so `push_row`'s
3623 // last-glyph rule would strand the end stop inside the markup.
3624 self.push_row_at(glyphs, end);
3625 if let Some(row) = self.rows.last_mut() {
3626 row.media = Some(MediaMark {
3627 kind,
3628 destination,
3629 sources,
3630 alt,
3631 poster,
3632 rows,
3633 });
3634 }
3635 // Reserve the picture's remaining height as blank `decoration` rows: drawn
3636 // (so the frontend has the vertical room to paint the raster over them),
3637 // but holding no caret and contributing no stops — vertical motion steps
3638 // over them and the caret's only homes stay the stop in front of the image
3639 // and the one just past it, both on the label row above. They anchor at the
3640 // image's end offset so a click on the picture's lower half lands after it,
3641 // the nearest caret home. Mirrors how a table's box-rule rows reserve space
3642 // without ever holding the caret.
3643 for _ in 1..rows {
3644 self.rows.push(VRow {
3645 glyphs: Vec::new(),
3646 end_src: end,
3647 decoration: true,
3648 code: false,
3649 code_lang: None,
3650 directive: false,
3651 directive_label: None,
3652 media: None,
3653 task: None,
3654 leaf_directive: None,
3655 heading: None,
3656 align: None,
3657 line_height: None,
3658 boundary: None,
3659 mark_ends: Vec::new(),
3660 });
3661 }
3662 self.last_off = end;
3663 }
3664
3665 /// The `<picture>` alternatives inside block-image `wrapper`, in document
3666 /// order — every `<source>` element in its subtree. Empty when there's no
3667 /// `<picture>`. Each is a `<source>`'s `media` + `srcset`; core keeps them
3668 /// verbatim and picks none (see [`MediaSource`]). A `<source>` with no
3669 /// `srcset` is dropped (nothing to load); its `media` may be empty (an
3670 /// unconditional override), which a frontend treats as always-matching.
3671 ///
3672 /// It scans the wrapper's whole subtree (via the forward `first_child` /
3673 /// `next_sibling` links, the reliable ones) rather than the `<img>`'s parent,
3674 /// for two reasons. A `<picture>` reaches core in two shapes: twig promotes a
3675 /// block `<picture>` to an `element(picture)` wrapping `[source, img]`, but
3676 /// leaves an inline one's tags as raw siblings — `[raw "<picture>", source,
3677 /// img, raw "</picture>"]` — so the `<source>`s sit at different depths in
3678 /// the two. And the editor's flat arena leaves a promoted inline node's
3679 /// `parent` back-pointer dangling on a phantom root, so only the wrapper
3680 /// (known at the call site) is a trustworthy anchor. A block image is the
3681 /// sole visible content of its wrapper, so every `<source>` under it is its
3682 /// picture's.
3683 fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
3684 let mut out = Vec::new();
3685 self.collect_sources(wrapper, &mut out);
3686 out
3687 }
3688
3689 fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
3690 for c in self.children(id) {
3691 let node = &self.nodes[c];
3692 if node.name.as_deref() == Some("source") {
3693 // `<picture>` spells its candidate `srcset`, `<video>`/`<audio>`
3694 // spell it `src`. Both mean "the URL to load", so they normalise
3695 // onto one field; `srcset` wins where (illegally) both appear.
3696 let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
3697 if let Some(srcset) = url {
3698 out.push(MediaSource {
3699 media: attr_of(node, "media").unwrap_or_default(),
3700 srcset,
3701 mime: attr_of(node, "type").unwrap_or_default(),
3702 });
3703 }
3704 }
3705 self.collect_sources(c, out);
3706 }
3707 }
3708
3709 /// The single block-level media `id`'s subtree resolves to, or `None`.
3710 ///
3711 /// A wrapper is a block picture when the only *visible* thing under it is one
3712 /// image: whitespace-only text and structure-only elements (a `<picture>`'s
3713 /// `<source>`, which declares an alternate but paints nothing) don't count,
3714 /// and the search descends through wrapping elements (`<picture>`, a linking
3715 /// `<a>`). This is what makes `<p><img></p>`, a bare `<img>`, and
3716 /// `<h1><picture>…<img></picture></h1>` all render as one framed picture.
3717 /// Any real text, or a second image, means it isn't image-only — it falls
3718 /// back to inline rendering, where the image still shows as its alt text.
3719 ///
3720 /// [`FlatNode`]'s snapshot doesn't carry an element's tag name, so a
3721 /// `<source>` can't be skipped by name — but it needs no special case:
3722 /// contributing no image and no text, it's simply invisible to the scan.
3723 fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
3724 let mut found = None;
3725 let mut count = 0usize;
3726 let mut has_text = false;
3727 self.scan_visual(id, &mut found, &mut count, &mut has_text);
3728 (count == 1 && !has_text).then(|| found.unwrap())
3729 }
3730
3731 /// Walk `id`'s subtree tallying visible leaves for [`media_only`]: each
3732 /// image, `<video>`, or `<audio>` (remembering the last, counting the total)
3733 /// and whether any non-whitespace text appears. Media isn't descended into —
3734 /// an image's inline children are alt text, and a `<video>`'s are its
3735 /// no-support fallback and its `<source>` declarations, none of which is
3736 /// document content.
3737 ///
3738 /// [`media_only`]: Self::media_only
3739 fn scan_visual(
3740 &self,
3741 id: usize,
3742 found: &mut Option<(usize, MediaKind)>,
3743 count: &mut usize,
3744 has_text: &mut bool,
3745 ) {
3746 for c in self.children(id) {
3747 let node = &self.nodes[c];
3748 match node.kind.as_str() {
3749 "image" => {
3750 *found = Some((c, MediaKind::Image));
3751 *count += 1;
3752 }
3753 // A `<video>`/`<audio>` reaches core as a generic `container`
3754 // (twig gives neither a semantic node, so `html_elements`
3755 // promotion leaves the tag name on `name`). Counted as media and
3756 // *not* descended into, so its `<source>` children and its
3757 // "your browser does not support…" fallback text neither add a
3758 // second count nor make the block look like text.
3759 "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3760 let kind = match element_tag(node) {
3761 Some("audio") => MediaKind::Audio,
3762 _ => MediaKind::Video,
3763 };
3764 *found = Some((c, kind));
3765 *count += 1;
3766 }
3767 // Text leaves: only non-whitespace counts as visible content.
3768 // (Twig keeps the whitespace `str`s between HTML tags — the
3769 // newlines and indentation inside a `<picture>` — as real nodes.)
3770 "str" | "smart_punctuation" | "verbatim" | "inline_math" => {
3771 if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
3772 *has_text = true;
3773 }
3774 }
3775 // Structural breaks carry no visible glyph of their own.
3776 "soft_break" | "hard_break" | "non_breaking_space" => {}
3777 // Any other wrapper (emphasis, a link, a `<picture>`) is
3778 // transparent to the scan — descend into it.
3779 _ => self.scan_visual(c, found, count, has_text),
3780 }
3781 }
3782 }
3783
3784 /// A leaf directive (`::name{…}`) as one placeholder row — the
3785 /// [`block_media`](Self::block_media) recipe, for the same reason: it is a
3786 /// block that renders as *a thing*, not as text, and the frontend paints
3787 /// whatever the host app's vocabulary makes of it.
3788 ///
3789 /// The row's glyphs are a `⧉ label` (or `⧉ name`) stand-in a plain surface
3790 /// paints as-is, every glyph anchored at the directive's start with a caret
3791 /// stop there, and the row ending past it so the caret can also rest after
3792 /// it. It carries a [`DirectiveMark`] for [`directive_spans`], and is marked
3793 /// [`directive`](VRow::directive) so a frontend already drawing the
3794 /// container form's panel frames this one identically for free.
3795 ///
3796 /// Before this, a leaf directive emitted no rows at all: it was invisible,
3797 /// held no caret, and vertical motion crossed a void where it stood.
3798 fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
3799 let node = &self.nodes[id];
3800 let (start, end) = (node.span.start, node.span.end);
3801 let (name, attrs) = leaf_directive_identity(node);
3802 let label = self.image_alt(id); // its `[label]` children, flattened
3803 let shown = if label.is_empty() { &name } else { &label };
3804 let style = Style::default().role(Role::Image);
3805 let mut glyphs = pf.to_vec();
3806 for ch in format!("⧉ {shown}").chars() {
3807 glyphs.push(Glyph {
3808 ch,
3809 style,
3810 src: start,
3811 stop: true,
3812 });
3813 }
3814 // End past the directive so the caret has a stop after it — the same
3815 // reason `block_media` anchors its row at the image's end.
3816 self.push_row_at(glyphs, end);
3817 if let Some(row) = self.rows.last_mut() {
3818 row.directive = true;
3819 row.leaf_directive = Some(DirectiveMark {
3820 name,
3821 attrs,
3822 label,
3823 rows: 1,
3824 });
3825 }
3826 self.last_off = end;
3827 }
3828
3829 /// An image's alt text: the flattened text of its inline descendants (an
3830 /// image's children *are* its alt content), empty when it has none. Also a
3831 /// leaf directive's `[label]`, which is the same shape — inline children
3832 /// standing for the block.
3833 fn image_alt(&self, id: usize) -> String {
3834 let mut out = String::new();
3835 self.collect_text(id, &mut out);
3836 out
3837 }
3838
3839 /// Append every descendant's `text` to `out`, in document order. Inline text
3840 /// (`str`) nodes are leaves, so a node never contributes both its own text and
3841 /// a child's — no double counting.
3842 fn collect_text(&self, id: usize, out: &mut String) {
3843 for c in self.children(id) {
3844 if let Some(t) = &self.nodes[c].text {
3845 out.push_str(t);
3846 }
3847 self.collect_text(c, out);
3848 }
3849 }
3850
3851 fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
3852 let mut out = Vec::new();
3853 for c in self.children(id) {
3854 self.inline(c, base, &mut out);
3855 }
3856 out
3857 }
3858
3859 /// [`inline_children`](Self::inline_children) plus any trailing whitespace the
3860 /// block carries past its inline content (see [`trailing_ws_glyphs`]). Used
3861 /// for the leaf inline blocks — paragraphs and headings — whose own `span`
3862 /// bounds exactly one line of text, so the trailing gap is theirs. *Not* for
3863 /// a table cell, whose `span` is the whole row and would swallow the
3864 /// delimiters and neighbours between it and the row's end.
3865 ///
3866 /// [`trailing_ws_glyphs`]: Self::trailing_ws_glyphs
3867 fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
3868 let mut out = self.inline_children(id, base);
3869 out.extend(self.trailing_ws_glyphs(id, base));
3870 out
3871 }
3872
3873 /// Glyphs for whatever trailing whitespace a block's source carries past its
3874 /// last inline node — the space(s) at the end of `hello ` that Markdown and
3875 /// Djot drop from the `str` node as insignificant. twig still records them:
3876 /// a block's `content_span` ends at its last meaningful character while its
3877 /// `span` runs to the end of the line's text (before the terminating
3878 /// newline), so the gap between the two *is* that trailing whitespace.
3879 ///
3880 /// Emitting it as real caret-stop glyphs is what lets the caret be drawn
3881 /// past the last visible character. Without it, typing a space at the end of
3882 /// a paragraph moved the caret in the source but not on screen — the caret
3883 /// stuck on the last glyph until the next visible character reparsed the
3884 /// space into an interior `str` node that finally carried it.
3885 ///
3886 /// Restricted to spaces: only they are safe to synthesize one-cell-per-byte,
3887 /// and only they are what the parser silently strips. Anything else in the
3888 /// gap means the span accounting isn't what this assumes, so it's left alone.
3889 fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
3890 let node = &self.nodes[id];
3891 let Some(content) = &node.content_span else {
3892 return Vec::new();
3893 };
3894 let (from, to) = (content.end, node.span.end);
3895 let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
3896 return Vec::new();
3897 };
3898 if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
3899 return Vec::new();
3900 }
3901 slice
3902 .bytes()
3903 .enumerate()
3904 .map(|(i, _)| Glyph {
3905 ch: ' ',
3906 style,
3907 src: from + i,
3908 stop: true,
3909 })
3910 .collect()
3911 }
3912
3913 fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
3914 let node = &self.nodes[id];
3915 match node.kind.as_str() {
3916 "str" | "smart_punctuation" => push_escaped_text(
3917 out,
3918 node.text.as_deref().unwrap_or(""),
3919 node.span.clone(),
3920 self.source,
3921 base,
3922 ),
3923 "soft_break" | "hard_break" | "non_breaking_space" => {
3924 // A break renders as a real, caret-navigable glyph — but twig
3925 // gives it no span of its own (`0..0`), so the offset comes from
3926 // the text in front of it: one *past* the last glyph, which is
3927 // the newline the break stands for. Past, not on: sharing the
3928 // previous glyph's offset would put two stops on one byte, and a
3929 // caret that can't change offset can't move.
3930 let src = if node.span.start != 0 {
3931 node.span.start
3932 } else {
3933 out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
3934 };
3935 // A *hard* break renders as this run's break glyph — a newline
3936 // inside a table cell (its own line), the same space in prose the
3937 // frontend re-wraps. A soft break normally folds into a space;
3938 // under `LineFlow::Preserve` it renders as a `'\n'` too, so the
3939 // author's line break shows where it was written. Never inside a
3940 // cell (`break_glyph` is `'\n'` there): a cell is one line and
3941 // folds its own soft breaks regardless.
3942 let ch = if node.kind == Kind::HardBreak {
3943 self.break_glyph.get()
3944 } else if node.kind == Kind::SoftBreak
3945 && self.preserve_soft
3946 && self.break_glyph.get() == ' '
3947 {
3948 '\n'
3949 } else {
3950 ' '
3951 };
3952 out.push(Glyph {
3953 ch,
3954 style: base,
3955 src,
3956 stop: true,
3957 });
3958 }
3959 // A cell's only spelling for an in-line break is a raw `<br>`; read it
3960 // back as one (outside a cell it stays the literal text it falls to
3961 // below). The tag's bytes carry no stop of their own — the line it
3962 // ends stops just before it, the next just after.
3963 "raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
3964 out.push(Glyph {
3965 ch: '\n',
3966 style: base,
3967 src: node.span.start,
3968 stop: true,
3969 });
3970 }
3971 "emph" => self.inline_delimited(id, base.italic(), out),
3972 "strong" => self.inline_delimited(id, base.bold(), out),
3973 // A coloured highlight's emoji is spelling, not content: twig strips
3974 // it and records the colour on the node, so the glyphs are the
3975 // author's words and the colour rides the role. Revealed markup
3976 // still shows the emoji, because `delims` reads the source bytes
3977 // between the span and the content span — which is exactly the
3978 // `==🔴 ` the author typed.
3979 "mark" => {
3980 let color = MarkColor::from_attrs(&node.attrs);
3981 self.inline_delimited(id, base.role(Role::Mark(color)), out)
3982 }
3983 "insert" => self.inline_delimited(id, base.underline(), out),
3984 "delete" => self.inline_delimited(id, base.strikethrough(), out),
3985 // The one pair whose whole meaning is *where the glyphs sit*. Drawn
3986 // in the surrounding style otherwise, so `^**2**^` stays bold and a
3987 // superscript inside a heading keeps the heading's role — which is
3988 // exactly why this is a `Baseline` and not a `Role`.
3989 "superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
3990 "subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
3991 "verbatim" | "inline_math" => {
3992 // The interior begins at `content_span.start` — past however many
3993 // backticks the fence used, which `span.start + 1` only guessed
3994 // right for a single one. Fall back to that guess if it's absent.
3995 let at = node
3996 .content_span
3997 .as_ref()
3998 .map_or(node.span.start + 1, |c| c.start);
3999 let style = base.role(Role::Code);
4000 // Not `inline_delimited`: verbatim has no child nodes to recurse
4001 // into — its content is its own `text` — so the fences bracket a
4002 // `push_text` instead. The fences themselves keep `Role::Code`'s
4003 // sibling treatment via `push_delim`'s role override.
4004 let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
4005 if let Some((open, _)) = &show {
4006 self.push_delim(out, open, style);
4007 }
4008 push_text(out, node.text.as_deref().unwrap_or(""), at, style);
4009 match &show {
4010 Some((_, close)) => self.push_delim(out, close, style),
4011 None => self.note_mark_end(id),
4012 }
4013 }
4014 // An attributed span — the run-level half of the presentation
4015 // vocabulary. djot's `[text]{…}`, AsciiDoc's `[.a]#text#`, HTML's
4016 // and Markdown's `<span …>`: one node with a name twig hands back
4017 // for two of the four (see [`is_run_span`]), all four carrying the
4018 // author's `data-size`, `data-font` and `data-color` on the run
4019 // they cover.
4020 //
4021 // The keys are written over the surrounding style rather than
4022 // replacing it, so a span inside a block that names its own size
4023 // wins on size and keeps the block's face — the nearest-wins rule
4024 // the block walker applies through a `div`. A key the span does not
4025 // name is one the block still says.
4026 //
4027 // A `data-color` here is the text's *foreground*, where the same key
4028 // on a `mark` is a highlight's background: same vocabulary, same
4029 // enum, and no collision, because a `mark` is a `mark` and a span is
4030 // a span.
4031 //
4032 // Otherwise this is the plain `recurse` an anonymous container has
4033 // always had — no delimiters, because the `{…}` is markup and the
4034 // span's text is the author's words.
4035 "container" if is_run_span(node) && !self.children(id).is_empty() => {
4036 self.recurse(id, run_style(node, base), out)
4037 }
4038 // A text directive (`:name[label]{…}`) — the inline form of a generic
4039 // directive. Its `[label]` children are the visible text; the name and
4040 // the `{…}` attributes are the host app's vocabulary (diaryx's
4041 // `:vis[…]`) and stay hidden markup, exactly as a link's `](dest)` is.
4042 // Drawn in the surrounding style: a role of its own would need one
4043 // every frontend maps, and the bug this fixes is that the text was
4044 // invisible, not that it was unstyled.
4045 "container" if container_is_directive(node) && !self.children(id).is_empty() => {
4046 self.recurse(id, base, out)
4047 }
4048 // No `[label]`, so there are no children to render and recursing
4049 // emitted *nothing*: the directive's bytes vanished from the document
4050 // and left no caret stop behind. What to draw instead turns on
4051 // whether the syntax looks deliberate.
4052 //
4053 // Bare `:word` almost never is. twig matches a colon followed by any
4054 // letter-led word (`scanTextDirective`, deliberately matching remark),
4055 // so ordinary prose is full of them — `:see below`, a `:smile:`
4056 // shortcode, a stray colon before a word. Those are prose, and prose
4057 // renders as itself: every byte visible, every byte a caret stop, so a
4058 // colon typed by accident can be seen and deleted. Hiding them behind
4059 // a placeholder would be the invisible-and-unreachable failure this
4060 // arm exists to fix, just wearing a nicer glyph.
4061 "container" if container_is_directive(node) && node.attrs.is_empty() => {
4062 let span = node.span.clone();
4063 push_text(
4064 out,
4065 self.source.get(span.clone()).unwrap_or(""),
4066 span.start,
4067 base,
4068 );
4069 }
4070 // `{…}` attributes, though, are unmistakably deliberate — nobody
4071 // types `:vis{.family}` by accident, and diaryx writes exactly that
4072 // inline. So an attribute-bearing directive with no label draws as a
4073 // chip on `block_directive`'s recipe (`⧉ name attrs`, `Role::Image`),
4074 // the inline peer of the leaf form's placeholder row.
4075 //
4076 // Only the first glyph is a caret stop, and the whole chip shares the
4077 // directive's start offset: the caret treats it as one atomic thing
4078 // rather than walking hidden markup a byte at a time, and a paragraph
4079 // holding nothing but a chip still has a stop to be navigated to.
4080 "container" if container_is_directive(node) => {
4081 let start = node.span.start;
4082 let name = node.name.clone().unwrap_or_default();
4083 let shown = match directive_attr_label(&node.attrs) {
4084 Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
4085 Some(attrs) => format!("⧉ {attrs}"),
4086 None => format!("⧉ {name}"),
4087 };
4088 let style = base.role(Role::Image);
4089 for (i, ch) in shown.chars().enumerate() {
4090 out.push(Glyph {
4091 ch,
4092 style,
4093 src: start,
4094 stop: i == 0,
4095 });
4096 }
4097 }
4098 // A footnote reference (`[^1]`). The label bracketed is what a reader
4099 // needs — bare, `note1` reads as a typo rather than a reference — so
4100 // the `^` is hidden as the spelling artefact it is (a link's
4101 // `](dest)` goes the same way) and the brackets are kept as
4102 // decoration: one shared offset, never a caret stop, like a table's
4103 // borders, so the caret walks the label alone.
4104 //
4105 // Styled `Role::Link`: a reference *is* a link to its definition, and
4106 // every frontend already paints that role. A role of its own would
4107 // need one in each of them, and what a frontend needs to tell the two
4108 // apart is not a paint colour but an answer to "what does clicking
4109 // here do" — which is [`Doc::footnote_at_caret`]'s job, not a glyph's.
4110 //
4111 // Raised, though, because that a reference is *set* differently from
4112 // the prose it interrupts is exactly what makes it read as a
4113 // reference. `[1]` at body size reads as bracketed text.
4114 "footnote_reference" => {
4115 let style = base.role(Role::Link);
4116 // Revealed, the reference is just its source bytes: the `^` that
4117 // is normally elided comes back and every byte becomes a real
4118 // stop, so the brackets stop being decoration and start being
4119 // text. That's the whole point of the mode, and it replaces the
4120 // hand-built chip below rather than decorating it — including the
4121 // raised baseline, since what's on screen there is source, and
4122 // source is set as prose.
4123 if self.revealed(&node.span) {
4124 self.push_delim(out, &node.span, style);
4125 return;
4126 }
4127 let style = style.baseline(Baseline::Super);
4128 // The label's own span, so its glyphs map to their true bytes.
4129 // Absent one, it starts past the `[^` that opens the reference.
4130 let (label, at) = match &node.content_span {
4131 Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
4132 None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
4133 };
4134 out.push(Glyph {
4135 ch: '[',
4136 style,
4137 src: node.span.start,
4138 stop: false,
4139 });
4140 push_text(out, label, at, style);
4141 out.push(Glyph {
4142 ch: ']',
4143 style,
4144 src: node.span.end.saturating_sub(1),
4145 stop: false,
4146 });
4147 }
4148 "link" | "url" | "email" => {
4149 let style = base.role(Role::Link);
4150 if self.children(id).is_empty() {
4151 // A bare autolink (`<a@b.c>`, a naked URL): the destination
4152 // *is* the visible text, so there is nothing elided to
4153 // reveal and both modes draw the same thing.
4154 push_text(
4155 out,
4156 node.destination
4157 .as_deref()
4158 .or(node.text.as_deref())
4159 .unwrap_or("link"),
4160 node.span.start,
4161 style,
4162 );
4163 } else {
4164 // An inline link reveals asymmetrically — `[` before the
4165 // label, `](dest)` after it — which the generic
4166 // span-minus-content derivation already produces.
4167 self.inline_delimited(id, style, out);
4168 }
4169 }
4170 _ => {
4171 if self.children(id).is_empty() {
4172 if let Some(t) = &node.text {
4173 push_text(out, t, node.span.start, base);
4174 }
4175 } else {
4176 self.recurse(id, base, out);
4177 }
4178 }
4179 }
4180 }
4181
4182 fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
4183 for c in self.children(id) {
4184 self.inline(c, style, out);
4185 }
4186 }
4187
4188 /// Lay a block's inline `glyphs` into visual rows, prefixing the first with
4189 /// `pf` and the rest with `pc`. A preserved soft break arrives as a `'\n'`
4190 /// glyph (see the `soft_break` arm): a hard row boundary that splits the
4191 /// glyphs so each run lays out on its own and the author's line structure
4192 /// shows on screen. The `'\n'` is dropped from the row it closes and its
4193 /// source offset becomes that row's end stop — exactly how a table cell's
4194 /// in-line `<br>` is handled — so the caret can rest at the line's end
4195 /// without a zero-width control char leaking into what the frontends render.
4196 /// With no `'\n'` present (the folding default, and every build that isn't
4197 /// `LineFlow::Preserve`) there is one run and this is byte-identical to
4198 /// laying the glyphs out directly.
4199 fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
4200 if !glyphs.iter().any(|g| g.ch == '\n') {
4201 self.emit_line(glyphs, block_start, pf, pc, None);
4202 return;
4203 }
4204 // Each run up to a '\n' is a line of its own: the first wears the block's
4205 // opening prefix, every later one the continuation prefix, and the break's
4206 // own offset ends the run's last row. The break glyph is dropped. A
4207 // trailing '\n' flushes its run and leaves nothing behind, so no spurious
4208 // blank row follows it.
4209 let mut run: Vec<Glyph> = Vec::new();
4210 let mut first = true;
4211 for g in glyphs {
4212 if g.ch == '\n' {
4213 let lead = if first { pf } else { pc };
4214 self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
4215 first = false;
4216 } else {
4217 run.push(g);
4218 }
4219 }
4220 if !run.is_empty() {
4221 let lead = if first { pf } else { pc };
4222 self.emit_line(run, block_start, lead, pc, None);
4223 }
4224 }
4225
4226 /// Word-wrap a single line of `glyphs` (no interior line breaks) to the
4227 /// available width and push the visual rows, prefixing the first with `pf`
4228 /// and the rest with `pc`. `end`, when set, is the source offset that ends
4229 /// the line's final row — the offset of the break that terminated it, which
4230 /// the caller has already stripped from `glyphs`; when `None` the row ends
4231 /// just past its last glyph, as an unbroken block's does.
4232 fn emit_line(
4233 &mut self,
4234 glyphs: Vec<Glyph>,
4235 block_start: usize,
4236 pf: &[Glyph],
4237 pc: &[Glyph],
4238 end: Option<usize>,
4239 ) {
4240 // The line's final row ends at `end` when a break gave one, else just
4241 // past its last glyph (`push_row`'s default).
4242 let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
4243 Some(e) => b.push_row_at(row, e),
4244 None => b.push_row(row, block_start),
4245 };
4246
4247 // No column budget: emit the whole line as one row and let the frontend
4248 // wrap it at its own (pixel) width.
4249 let Some(width) = self.wrap else {
4250 let row = if glyphs.is_empty() {
4251 pf.to_vec()
4252 } else {
4253 concat(pf, &glyphs)
4254 };
4255 push_last(self, row);
4256 return;
4257 };
4258
4259 // Split into words (maximal non-space runs), each carrying the space
4260 // glyph that followed it (so its source offset is preserved).
4261 let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4262 let mut word: Vec<Glyph> = Vec::new();
4263 for g in glyphs {
4264 if g.ch == ' ' {
4265 words.push((std::mem::take(&mut word), Some(g)));
4266 } else {
4267 word.push(g);
4268 }
4269 }
4270 if !word.is_empty() {
4271 words.push((word, None));
4272 }
4273 if words.is_empty() {
4274 // An empty block (or an empty preserved line) still occupies one
4275 // (prefixed) row.
4276 push_last(self, pf.to_vec());
4277 return;
4278 }
4279
4280 let mut line: Vec<Glyph> = Vec::new();
4281 let mut used = 0usize;
4282 let mut first = true;
4283 for (w, space) in words {
4284 let avail = width
4285 .saturating_sub(prefix_width(if first { pf } else { pc }))
4286 .max(1);
4287 let cells = glyphs_width(&w);
4288 if used > 0 && used + cells > avail {
4289 let row = concat(if first { pf } else { pc }, &line);
4290 self.push_row(row, block_start);
4291 line = Vec::new();
4292 used = 0;
4293 first = false;
4294 }
4295 used += cells;
4296 line.extend(w);
4297 if let Some(sp) = space {
4298 used += 1;
4299 line.push(sp);
4300 }
4301 }
4302 let row = concat(if first { pf } else { pc }, &line);
4303 push_last(self, row);
4304 }
4305
4306 /// The source offset of each line of a code block's `text`.
4307 ///
4308 /// `content` is the block's `content_span` — where twig says the body lives
4309 /// in the source, fences already excluded. Its lines run 1:1 with the
4310 /// rendered `text` lines, so no search is needed; each is anchored at the
4311 /// *end* of its source line, which places it past whatever indent `text` had
4312 /// stripped (a fenced block's fences, an indented one's leading spaces)
4313 /// without having to know how much there was.
4314 ///
4315 /// `None` when the body and the rendered lines don't line up — a coarse
4316 /// fallback the caller turns into the block's start offset.
4317 fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
4318 let mut src_lines: Vec<(usize, &str)> = Vec::new();
4319 let mut at = content.start;
4320 for l in self.source.get(content.start..content.end)?.split('\n') {
4321 src_lines.push((at, l));
4322 at += l.len() + 1;
4323 }
4324 if src_lines.len() != lines.len() {
4325 return None;
4326 }
4327 Some(
4328 lines
4329 .iter()
4330 .zip(&src_lines)
4331 .map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
4332 .collect(),
4333 )
4334 }
4335
4336 fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
4337 // Step past the character the *source* holds at the last glyph's offset,
4338 // not past the glyph's own `ch`. The two agree for ordinary text, but a
4339 // glyph is not always the character it stands on: `synth` decoration and
4340 // a substituted run (an image's `⧉ label`) share one offset by design.
4341 // Trusting `ch` there yields an offset inside a multi-byte character,
4342 // which every later slice of `source` panics on.
4343 let end_src = glyphs
4344 .last()
4345 .map(|g| {
4346 let at = g.src.min(self.source.len());
4347 at + self.source[at..].chars().next().map_or(0, char::len_utf8)
4348 })
4349 .unwrap_or(fallback);
4350 self.push_row_at(glyphs, end_src);
4351 }
4352
4353 /// Push a row with an explicit end stop, for content that knows its own
4354 /// extent better than its last glyph does.
4355 fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
4356 self.last_off = end_src;
4357 let mark_ends = self.take_mark_ends(end_src);
4358 self.rows.push(VRow {
4359 glyphs,
4360 end_src,
4361 decoration: false,
4362 code: false,
4363 code_lang: None,
4364 directive: false,
4365 directive_label: None,
4366 media: None,
4367 task: None,
4368 leaf_directive: None,
4369 heading: None,
4370 align: None,
4371 line_height: None,
4372 boundary: None,
4373 mark_ends,
4374 });
4375 }
4376
4377 /// The quote's own trailing marker lines: the `>` / `> ` lines that lie past
4378 /// its last child but inside its span, one gutter row each.
4379 ///
4380 /// Pressing Enter at the end of `> a` writes `> a\n>\n> \n` — twig's
4381 /// spelling, and the right one. Those last two lines hold no block (a
4382 /// `block_quote`'s `content_span` still stops at its last child) so the
4383 /// children walk never reaches them, and they used to fall all the way to
4384 /// the document-level [`Builder::emit_trailing_blank_lines`], which knows no
4385 /// prefix: the gutter simply stopped, and a writer adding a line to a quote
4386 /// watched it draw as plain prose.
4387 ///
4388 /// This is only answerable since twig 3.2.0, where a Markdown `block_quote`'s
4389 /// span covers its own trailing marker lines (it reported `0..3` for that
4390 /// source and now reports `0..8`). Before that the lines belonged to no node
4391 /// at any level, and the only way to draw them was to sniff `>` off the raw
4392 /// source and re-derive the nesting depth by counting markers — format
4393 /// inference this crate exists to keep out of the render path.
4394 ///
4395 /// Each row is a real caret home rather than a decoration gap: the writer
4396 /// spelled every one of these lines with a marker of its own, so each is a
4397 /// line of the quote to stand on, not the spacing between two blocks (which
4398 /// is [`Builder::emit_separators_before`]'s, and falls *between* children
4399 /// where this never looks).
4400 fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
4401 let end = end.min(self.source.len());
4402 let mut at = self.rows.last().map_or(0, |r| r.end_src);
4403 // Walk line by line from the last child's end to the quote's, taking each
4404 // line's *end* as the row's offset — the caret home at the end of a line
4405 // is where one on an empty quoted line belongs, and it keeps every row's
4406 // offset distinct from its neighbours'.
4407 while at < end {
4408 let Some(k) = self.source[at..end].find('\n') else {
4409 break;
4410 };
4411 let line_start = at + k + 1;
4412 let line_end = self.source[line_start..end]
4413 .find('\n')
4414 .map_or(end, |i| line_start + i);
4415 self.push_row_at(pc.to_vec(), line_end);
4416 at = line_end;
4417 }
4418 }
4419
4420 /// The source offset the caret rests at on the blank line separating a block
4421 /// that ends at `prev_end` from the next block starting at `next_start`:
4422 /// just past the newline that terminates the previous block, but kept
4423 /// strictly before the next block so the offset is unique to this row.
4424 fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
4425 let after_nl = self.source[prev_end..]
4426 .find('\n')
4427 .map_or(prev_end, |p| prev_end + p + 1);
4428 after_nl.min(next_start.saturating_sub(1)).max(prev_end)
4429 }
4430
4431 /// The source offset of each blank row between a block ending at `prev_end`
4432 /// and content starting at `next_start` — one per blank source line. The
4433 /// first newline terminates the previous block's line; every line it opens up
4434 /// to (but not including) the line that holds `next_start` is a blank row the
4435 /// caret can occupy. Offsets are unique and ascending so `pos_of_offset`
4436 /// resolves each to its own row. Empty when the two blocks are tight (no
4437 /// blank line between them).
4438 fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
4439 // Spans aren't always in tidy source order (e.g. a block after
4440 // frontmatter can start *before* the previous block's rendered content
4441 // ends). There's no blank line to place then — fall back to the clamped
4442 // single separator (an empty return) rather than slicing an inverted
4443 // range.
4444 if next_start <= prev_end {
4445 return Vec::new();
4446 }
4447 let gap = &self.source[prev_end..next_start];
4448 let Some(nl) = gap.find('\n') else {
4449 return Vec::new();
4450 };
4451 // The line holding `next_start` belongs to the next block; blank rows
4452 // stop before it.
4453 let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
4454 let mut offs = Vec::new();
4455 let mut start = prev_end + nl + 1;
4456 while start < next_line_start {
4457 offs.push(start);
4458 match self.source[start..next_start].find('\n') {
4459 Some(k) => start += k + 1,
4460 None => break,
4461 }
4462 }
4463 offs
4464 }
4465
4466 /// Blank lines the user typed past the end of the last block (e.g. two
4467 /// `Enter`s to open a fresh paragraph) leave no AST node, so nothing renders
4468 /// and the caret appears stuck on the old line. Reconstruct one empty row
4469 /// per extra trailing newline from the source, each at its own offset, so
4470 /// the caret rides down onto the new line the moment it's created.
4471 ///
4472 /// `above` is the class of the last block in the document — the one this gap
4473 /// closes. A document with no blocks at all has nothing above these rows, and
4474 /// [`BlockClass::Paragraph`] is the honest answer there too: what they are is
4475 /// empty paragraphs, on both sides of the gap.
4476 fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
4477 // With no rows at all the count starts past any hidden frontmatter, not
4478 // at 0: its newlines are not trailing blank lines, and counting them
4479 // opened phantom rows *inside* the metadata for a frontmatter-only file.
4480 //
4481 // Or past the last hidden block, if that is later: a closing comment
4482 // draws no row, and its lines are not blank lines the author opened.
4483 let last_end = self
4484 .rows
4485 .last()
4486 .map_or(hidden_end, |r| r.end_src)
4487 .max(self.stepped_over);
4488 if last_end >= self.source.len() {
4489 return;
4490 }
4491 // The first newline after the last content just terminates that line, so
4492 // a lone trailing `\n` (an ordinary file ending) opens no blank row. A
4493 // *second* newline opens an empty paragraph: render it the way a block
4494 // boundary is rendered — a blank spacer row, then the empty paragraph row
4495 // the caret rests on — so the just-pressed-Enter view already shows the
4496 // gap it will keep once text is typed, and typing doesn't shift the line
4497 // down. One row per trailing newline (each its own caret offset), the
4498 // last landing at the document end where the caret sits.
4499 let extra = self.source[last_end..].matches('\n').count();
4500 if extra < 2 {
4501 return;
4502 }
4503 for k in 1..=extra {
4504 self.rows.push(VRow {
4505 glyphs: Vec::new(),
4506 end_src: last_end + k,
4507 // As between two blocks: the first blank row is the gap that
4508 // closes the block above, not somewhere to type. Nothing follows
4509 // to need a gap of its own, though, so every row after it is a
4510 // real empty paragraph — the end of the document bounds the last
4511 // one the way a following block would. Preserve flow makes even
4512 // that first row navigable, as it does every blank line.
4513 decoration: !self.preserve_soft && k == 1,
4514 code: false,
4515 code_lang: None,
4516 directive: false,
4517 directive_label: None,
4518 media: None,
4519 task: None,
4520 leaf_directive: None,
4521 heading: None,
4522 align: None,
4523 line_height: None,
4524 // The one drawn row here is a block boundary like any other —
4525 // "rendered the way a block boundary is rendered" is the whole
4526 // point of it — so it says so, and a frontend spacing boundaries
4527 // spaces this one the same. The rows below it are navigable empty
4528 // paragraphs, not gaps.
4529 boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
4530 above,
4531 below: BlockClass::Paragraph,
4532 }),
4533 mark_ends: Vec::new(),
4534 });
4535 }
4536 }
4537}
4538
4539// ── display width ────────────────────────────────────────────────────────────
4540//
4541// Two things a row can be counted in, and they are not the same number:
4542//
4543// *glyphs*, one per codepoint — how the text is stored here, and what an
4544// index into `VRow::glyphs` means; and
4545// *columns*, one per terminal cell — where the text is drawn, and what every
4546// `col` in this crate means.
4547//
4548// `你` is one glyph in two columns. Counting columns with `glyphs.len()` (or,
4549// in the source view, `chars().count()`) is the same number only for the ASCII
4550// that most fixtures are written in, and drifts one cell per wide character
4551// everywhere else — the caret drawn a column short of the text it types into.
4552// Everything below converts between the two; nothing else should have to.
4553
4554/// The display width of `s` in terminal cells.
4555///
4556/// Measured per grapheme cluster, because that is the unit a surface advances
4557/// by: `👨👩👧` is five codepoints measuring 2 + 0 + 2 + 0 + 2 cells one at a
4558/// time, but the character they spell is drawn in 2. Both frontends already
4559/// measure it that way — ratatui asks `unicode-width` per cluster, and the GUI
4560/// asks its own text system — so the caret only lands where the text is if this
4561/// agrees with them.
4562pub fn text_width(s: &str) -> usize {
4563 UnicodeWidthStr::width(s)
4564}
4565
4566/// One grapheme cluster of a laid-out row: the glyphs that spell it, and the
4567/// cells it is drawn in.
4568///
4569/// The cluster, not the glyph, is what has a width. A row's glyphs are one per
4570/// codepoint, so an accented letter or an emoji is several of them drawn in one
4571/// character's worth of cells — the glyph that opens the cluster claims those
4572/// cells, and the ones continuing it are drawn *inside* them rather than beside
4573/// them. It's the same cluster the stop table is built on: the opening glyph is
4574/// the one a caret can rest on, and so the only one whose column it can be
4575/// drawn at.
4576struct Cluster {
4577 /// Index of the glyph that opens it.
4578 glyph: usize,
4579 /// The display column it starts at.
4580 col: usize,
4581 /// How many cells it is drawn in. Zero for a cluster with no width of its
4582 /// own (a lone joiner), which therefore sits at no column at all.
4583 cells: usize,
4584}
4585
4586/// Walk a row's glyphs as the clusters they spell, in column order.
4587fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
4588 let text: String = glyphs.iter().map(|g| g.ch).collect();
4589 let mut out = Vec::new();
4590 let (mut glyph, mut col) = (0, 0);
4591 for cluster in text.graphemes(true) {
4592 let cells = text_width(cluster);
4593 out.push(Cluster { glyph, col, cells });
4594 // One glyph per codepoint, so a cluster spans exactly its own.
4595 glyph += cluster.chars().count();
4596 col += cells;
4597 }
4598 out
4599}
4600
4601/// The display width of a run of glyphs.
4602fn glyphs_width(glyphs: &[Glyph]) -> usize {
4603 clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
4604}
4605
4606/// A cell's display width — the widest of its lines, since an in-cell `\n` break
4607/// splits it into several. Sizes the column that must hold every line.
4608fn cell_width(glyphs: &[Glyph]) -> usize {
4609 glyphs
4610 .split(|g| g.ch == '\n')
4611 .map(glyphs_width)
4612 .max()
4613 .unwrap_or(0)
4614}
4615
4616/// Whether a raw inline HTML tag is a line break (`<br>`, `<br/>`, `<br />`,
4617/// case-insensitively) — the one tag a table cell reads as an in-cell break.
4618fn is_br(text: Option<&str>) -> bool {
4619 let Some(t) = text else { return false };
4620 matches!(
4621 t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
4622 "<br>" | "<br/>"
4623 )
4624}
4625
4626impl VRow {
4627 /// The row's width in display columns — and so the column of the caret
4628 /// placed past its last glyph, which is the rightmost column it can occupy.
4629 fn width(&self) -> usize {
4630 glyphs_width(&self.glyphs)
4631 }
4632
4633 /// The display column glyph `i` is drawn at. Glyphs continuing a cluster
4634 /// report the column of the glyph that opened it, since that is where they
4635 /// are drawn; none of them is ever a stop, so no caret is placed by it.
4636 fn col_of_glyph(&self, i: usize) -> usize {
4637 clusters(&self.glyphs)
4638 .iter()
4639 .rev()
4640 .find(|c| c.glyph <= i)
4641 .map_or(0, |c| c.col)
4642 }
4643
4644 /// The glyph drawn at display column `col`, or `None` past the row's last
4645 /// cell.
4646 ///
4647 /// A column landing on the *second* cell of a wide glyph resolves to that
4648 /// glyph: half a character is not a place to be, so clicking either cell of
4649 /// `你` means `你`, and the caret comes to rest at its start — the column it
4650 /// would be drawn at anyway. That rule is what makes the mapping invertible:
4651 /// every offset has one column, and every column has one offset.
4652 fn glyph_at_col(&self, col: usize) -> Option<usize> {
4653 clusters(&self.glyphs)
4654 .into_iter()
4655 .find(|c| col < c.col + c.cells)
4656 .map(|c| c.glyph)
4657 }
4658}
4659
4660// ── helpers ──────────────────────────────────────────────────────────────────
4661
4662/// The caret home inside an *empty* table cell (`col`, 0-based) whose node
4663/// `span` is `src` starting at byte `start`. twig gives an empty cell no
4664/// `content_span`, so its interior is read from the pipes: the home is one
4665/// space past the pipe that opens the cell — mimicking the `| ` padding a
4666/// filled cell has — and never at or past the pipe that closes it. So
4667/// `| | |` gives the two cells distinct, editable homes instead of both
4668/// collapsing onto the row's start.
4669///
4670/// Two shapes of span are read. A twig from 3.3.3 gave every cell of a row
4671/// the *row's* span, so the cell's own pipes are the `col`-th and
4672/// `col+1`-th unescaped `|` in it; a later twig spans a cell from the pipe
4673/// that opens it to the one that closes it, exclusive, so the span holds at
4674/// most that one pipe, at its start, and the closing one is the byte past
4675/// its end. The two are told apart by the pipes the span holds — a row's
4676/// span has several, or one that is not at its start.
4677fn empty_cell_offset(src: &str, start: usize, col: usize) -> usize {
4678 let bytes = src.as_bytes();
4679 let mut pipes = Vec::new();
4680 for (i, &b) in bytes.iter().enumerate() {
4681 if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
4682 pipes.push(i);
4683 }
4684 }
4685 let whole_row = pipes.len() > 1 || pipes.first().is_some_and(|&i| i != 0);
4686 let (open, close) = if whole_row {
4687 (pipes.get(col).copied(), pipes.get(col + 1).copied())
4688 } else {
4689 (pipes.first().copied(), Some(src.len()))
4690 };
4691 match (open, close) {
4692 (Some(open), Some(close)) => {
4693 let lo = open + 1; // just inside the opening pipe
4694 let hi = close.saturating_sub(1); // just inside the closing pipe
4695 let inside = if hi < lo {
4696 lo
4697 } else {
4698 (open + 2).clamp(lo, hi)
4699 };
4700 start + inside
4701 }
4702 (Some(open), None) => start + open + 1,
4703 _ => start,
4704 }
4705}
4706
4707/// One laid-out table cell: its rendered text, the source range that text
4708/// occupies (`start`/`end` are the caret anchors decoration points at), and the
4709/// column alignment its padding honours.
4710///
4711/// `glyphs` is the cell's inline content *unwrapped* — the box-drawn rows wrap
4712/// it to a column width, but a frontend laying the grid out itself needs the
4713/// text before that decision was made.
4714#[derive(Clone)]
4715pub struct TableCell {
4716 pub glyphs: Vec<Glyph>,
4717 pub start: usize,
4718 pub end: usize,
4719 pub align: Alignment,
4720}
4721
4722/// One row of a table's grid, as the document spells it — not as it's drawn.
4723#[derive(Clone)]
4724pub struct TableRow {
4725 /// A header row: drawn bold, and ruled off from the body below it.
4726 pub head: bool,
4727 pub cells: Vec<TableCell>,
4728}
4729
4730/// A table's structure, published alongside the box-drawn rows that spell it.
4731///
4732/// The rows in [`VisualMap::rows`] are the *default monospace* picture of a
4733/// table: every border a `│`, every column a whole number of character cells.
4734/// That picture is exactly right on any monospace surface, and unfixable off one
4735/// — in a proportional font the `│`s of two rows land at different x and the grid
4736/// shears. So a frontend that draws its own geometry reads this instead: the
4737/// cells, their alignment, and which rows are the head, with no opinion about
4738/// how wide a column is or what a border looks like.
4739///
4740/// Both are always built. The TUI paints `rows` and ignores this; the GUI skips
4741/// `rows` for the span in `rows_span` and draws from here. They describe the
4742/// same cells, so the caret lands on the same offsets either way.
4743#[derive(Clone)]
4744pub struct TableInfo {
4745 /// The `VisualMap::rows` this table's picture occupies, borders included —
4746 /// what a frontend drawing its own table skips over.
4747 pub rows_span: Range<usize>,
4748 /// The end of the table node's source span, and the offset its trailing
4749 /// caret stop sits at — the one caret home past the last cell, held by the
4750 /// bottom border row's end. Typing there opens a paragraph under the table
4751 /// rather than joining the block; see `Doc::open_paragraph_at_block_edge`.
4752 pub end_src: usize,
4753 /// The block prefix every row of this table carries — a blockquote's `│ `
4754 /// gutter, a list item's indent. Empty for a table at the top level.
4755 ///
4756 /// A frontend drawing its own grid has to render this and start the table
4757 /// past it, exactly as the picture does; a table nested in a quote that
4758 /// draws flush at the left margin has left the quote.
4759 pub prefix: Vec<Glyph>,
4760 pub grid: Vec<TableRow>,
4761}
4762
4763/// A fenced or indented code block, named by the [`VisualMap::rows`] it occupies.
4764///
4765/// Unlike a table, the rows *are* the block's content — a frontend still paints
4766/// them, it just draws a border and a tinted background around the whole span
4767/// and lets the code inside scroll horizontally instead of wrapping. So this
4768/// carries only the row range; there's no structural alternative to the picture
4769/// the way [`TableInfo`] is one. Derived from [`VRow::code`] — see
4770/// [`code_block_spans`].
4771#[derive(Clone, Debug, PartialEq, Eq)]
4772pub struct CodeBlockInfo {
4773 /// The contiguous run of [`VisualMap::rows`] this code block spans, blank
4774 /// code lines included.
4775 pub rows_span: Range<usize>,
4776 /// The block's language, from a fenced block's info string — what a frontend
4777 /// paints as a small label on the box (`` ```rust `` → `Some("rust")`).
4778 /// `None` for a fence written without one, or an indented block. Editing it
4779 /// goes through [`crate::Doc::set_code_language`], which re-finds the fence
4780 /// in the AST, so this stays a display string.
4781 pub lang: Option<String>,
4782}
4783
4784/// A block-level image (`` on its own line), named by the single
4785/// [`VisualMap::rows`] row it occupies.
4786///
4787/// Like [`CodeBlockInfo`], the row *is* the block's default rendering — a plain
4788/// surface paints the `🖼 alt` placeholder glyphs as-is. An image-capable
4789/// frontend instead **skips the row in `rows_span`** and paints the resolved
4790/// picture there, exactly as it skips a [`TableInfo`]'s box-drawn rows. Derived
4791/// from [`VRow::media`] by [`media_spans`], so it survives the row reuse of
4792/// [`BlockCache`] and [`build_spliced`].
4793#[derive(Clone, Debug, PartialEq, Eq)]
4794pub struct MediaInfo {
4795 /// The [`VisualMap::rows`] rows this media's placeholder occupies — what a
4796 /// capable frontend replaces with the picture or player.
4797 pub rows_span: Range<usize>,
4798 /// Whether this is a picture, a movie, or a sound — which widget the
4799 /// frontend builds over [`rows_span`](MediaInfo::rows_span). A frontend that
4800 /// handles only some kinds leaves the rest as core's placeholder rows, which
4801 /// already read sensibly on their own.
4802 pub kind: MediaKind,
4803 /// The media's link destination — a path, URL, or `data:` URI, verbatim from
4804 /// the AST. A frontend resolves a relative path against the document's own
4805 /// directory; core does no I/O. For a `<picture>` this is the `<img>`
4806 /// fallback — the source used when no [`sources`](MediaInfo::sources) media
4807 /// query matches (or the frontend has no theme). Empty when a `<video>`/
4808 /// `<audio>` carries no `src` and names its candidates in `<source>`s
4809 /// instead; [`resolve`](MediaInfo::resolve) already accounts for that.
4810 pub destination: String,
4811 /// The `<source>` alternatives in document order, or empty for a plain
4812 /// image. See [`MediaSource`]; a theme- or codec-aware frontend picks one and
4813 /// otherwise loads [`destination`](MediaInfo::destination).
4814 pub sources: Vec<MediaSource>,
4815 /// The media's alt text, flattened from its inline children (empty when it
4816 /// has none).
4817 pub alt: String,
4818 /// A `<video poster="…">`'s still frame, or empty when there is none — an
4819 /// image destination, resolved exactly as [`destination`] is.
4820 ///
4821 /// [`destination`]: MediaInfo::destination
4822 pub poster: String,
4823}
4824
4825/// One leaf directive (`::name{…}`) as a frontend sees it: which rows its
4826/// placeholder occupies, its type, and its attributes. A plain surface paints
4827/// the `⧉ name` placeholder glyphs as-is; a frontend that knows the host app's
4828/// vocabulary **skips the rows in `rows_span`** and paints the real thing there,
4829/// exactly as an image-capable one does with [`MediaInfo`]. Derived from
4830/// [`VRow::leaf_directive`] by [`directive_spans`].
4831///
4832/// Core resolves nothing here — it has no idea what an `embed` or a `toc` is,
4833/// and deliberately so: the directive vocabulary belongs to the app on top.
4834#[derive(Clone, Debug, PartialEq, Eq)]
4835pub struct DirectiveInfo {
4836 /// The [`VisualMap::rows`] rows this directive's placeholder occupies — the
4837 /// label row plus any blank fillers under it.
4838 pub rows_span: Range<usize>,
4839 /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
4840 pub name: String,
4841 /// Its `{…}` attributes in source order; a bare one has a `None` value.
4842 pub attrs: Vec<(String, Option<String>)>,
4843 /// Its `[label]` text, flattened from its inline children (empty when it has
4844 /// none) — what the placeholder row shows.
4845 pub label: String,
4846}
4847
4848impl DirectiveInfo {
4849 /// The value of attribute `key`, if it has one with a value. The convenience
4850 /// a frontend reaches for first (`info.attr("src")`), since almost every
4851 /// directive that draws as something real is pointed at by one attribute.
4852 pub fn attr(&self, key: &str) -> Option<&str> {
4853 self.attrs
4854 .iter()
4855 .find(|(k, _)| k == key)
4856 .and_then(|(_, v)| v.as_deref())
4857 }
4858}
4859
4860impl MediaInfo {
4861 /// The image URL to load under `scheme`: the first [`sources`] `<source>`
4862 /// whose media query matches, else the [`destination`] `<img>` fallback. The
4863 /// pick is a `<source>`'s first `srcset` URL or the destination — a frontend
4864 /// resolves whichever it gets against the document directory exactly as it
4865 /// resolves `destination`, and reserves/keys the picture under `destination`
4866 /// regardless, so a theme switch just re-picks without disturbing the layout.
4867 ///
4868 /// Only `prefers-color-scheme` is understood (that's what a light/dark banner
4869 /// uses); a `<source>` with any other media query is skipped, and one with no
4870 /// media at all always matches (an unconditional override). With no matching
4871 /// source — including every frontend that can't/doesn't theme and passes
4872 /// [`ColorScheme::Light`] to a dark-only picture — it's the plain `<img>`.
4873 ///
4874 /// [`sources`]: MediaInfo::sources
4875 /// [`destination`]: MediaInfo::destination
4876 pub fn resolve(&self, scheme: ColorScheme) -> &str {
4877 if let Some(url) = self
4878 .sources
4879 .iter()
4880 .find(|s| media_matches(&s.media, scheme))
4881 .and_then(|s| first_srcset_url(&s.srcset))
4882 {
4883 return url;
4884 }
4885 // A `<video>`/`<audio>` may carry no `src` of its own, naming its
4886 // candidates only in child `<source>`s — none of which matched above,
4887 // because a codec-typed `<source>` has no media query and core judges no
4888 // MIME types. Falling through to an empty destination would hand the
4889 // frontend nothing to load, so take the first candidate URL instead and
4890 // let the frontend reject it if it can't decode it. An `<img>` never
4891 // reaches this: its `src` is the picture.
4892 if self.destination.is_empty()
4893 && let Some(url) = self
4894 .sources
4895 .iter()
4896 .find_map(|s| first_srcset_url(&s.srcset))
4897 {
4898 return url;
4899 }
4900 &self.destination
4901 }
4902
4903 /// The **still picture** that stands for this media under `scheme`, for a
4904 /// frontend that can rasterize an image but not play a movie — a terminal, or
4905 /// a GUI still growing its player. `None` when there is no picture to draw,
4906 /// which is the honest answer for audio and for a poster-less video: the
4907 /// caller leaves core's labelled placeholder row, which already reads as
4908 /// *a thing that isn't text*.
4909 ///
4910 /// This exists so those frontends never hand a `.mp4` to an image decoder.
4911 /// That fails harmlessly today (a failed decode falls back to the same
4912 /// placeholder), but it spends a file read and a decode attempt per frame to
4913 /// arrive where this gets in one match.
4914 pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
4915 match self.kind {
4916 MediaKind::Image => Some(self.resolve(scheme)),
4917 // A `poster` is an image destination, so it resolves the same way —
4918 // but it is named directly and has no `<source>` alternatives of its
4919 // own, so it needs no theme matching.
4920 MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
4921 MediaKind::Video | MediaKind::Audio => None,
4922 }
4923 }
4924}
4925
4926/// A frontend's active color scheme — what a `<picture>`'s `prefers-color-scheme`
4927/// `<source>`s are matched against by [`MediaInfo::resolve`]. A frontend with no
4928/// notion of theme passes [`Light`](ColorScheme::Light), the web's own default.
4929#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4930pub enum ColorScheme {
4931 Light,
4932 Dark,
4933}
4934
4935/// Whether a `<source media="…">` query applies under `scheme`. Empty media is
4936/// an unconditional `<source>` (always matches); otherwise only a
4937/// `prefers-color-scheme: dark|light` feature is understood — anything else
4938/// (a width query, `print`, …) doesn't match, so resolution falls through to the
4939/// next source or the `<img>`. Deliberately lax about the surrounding syntax
4940/// (`(prefers-color-scheme: dark)`, `screen and (prefers-color-scheme:dark)`):
4941/// it keys off the feature and its value, which is all the theme case needs.
4942fn media_matches(media: &str, scheme: ColorScheme) -> bool {
4943 let media = media.trim();
4944 if media.is_empty() {
4945 return true;
4946 }
4947 let lower = media.to_ascii_lowercase();
4948 let Some(after) = lower
4949 .split_once("prefers-color-scheme")
4950 .map(|(_, rest)| rest)
4951 else {
4952 return false;
4953 };
4954 // Skip the `:` and any spaces to reach the value word.
4955 let value = after.trim_start_matches([':', ' ', '\t']);
4956 let wanted = match scheme {
4957 ColorScheme::Light => "light",
4958 ColorScheme::Dark => "dark",
4959 };
4960 value.starts_with(wanted)
4961}
4962
4963/// The first URL in a `srcset`: its first comma-separated candidate, before any
4964/// `1x`/`2x`/width descriptor. The theme case only ever puts one URL per
4965/// `<source>`, so the first candidate is the picture.
4966fn first_srcset_url(srcset: &str) -> Option<&str> {
4967 let first = srcset.split(',').next()?.trim();
4968 first.split_whitespace().next().filter(|u| !u.is_empty())
4969}
4970
4971/// The narrowest a column may be squeezed. Below a few characters a column
4972/// stops carrying text and just shreds it one letter per line, which is worse
4973/// than letting the grid run wide.
4974const MIN_COL_WIDTH: usize = 3;
4975
4976/// Shrink `widths` until the grid fits `avail` screen columns, taking from the
4977/// widest column each time so the loss is shared out rather than falling on
4978/// whichever column happens to be last. No column goes below
4979/// [`MIN_COL_WIDTH`]; a table with more columns than the surface has room for
4980/// still overflows, which is the honest outcome — there's nothing left to give.
4981fn fit_widths(widths: &mut [usize], avail: usize) {
4982 // Chrome: each column is its content plus a gutter either side, and every
4983 // column is closed by a `│` — with one more opening the row.
4984 let budget = avail.saturating_sub(3 * widths.len() + 1);
4985 while widths.iter().sum::<usize>() > budget {
4986 let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
4987 return;
4988 };
4989 *w -= 1;
4990 }
4991}
4992
4993/// Word-wrap `glyphs` into lines of at most `width` columns, hard-breaking any
4994/// single word too long to fit.
4995///
4996/// Unlike a paragraph — where an overlong word just trails off the end of the
4997/// line — a table column is a hard boundary: a glyph past it lands on top of
4998/// the border, or on the next cell. So the width here is a promise, and a word
4999/// that won't keep it is broken.
5000///
5001/// The space at a break is dropped rather than hung past the edge. Its offset
5002/// isn't lost: the caller gives every line an end stop just past its last
5003/// glyph, which is exactly where that space was.
5004///
5005/// `width` is in display columns, and a break only ever falls between grapheme
5006/// clusters. Both matter to more than the picture: the caller anchors each
5007/// line's end stop just past its last glyph, so a line cut mid-cluster would
5008/// put a caret stop inside a character — reachable by Down or a click, and the
5009/// next Backspace would take the cluster apart from the middle.
5010///
5011/// An explicit in-cell break (a `\n` glyph, from a `<br>`) is a hard boundary:
5012/// each run between the breaks wraps on its own and the results stack. The break
5013/// glyphs are dropped — the caller's per-line end stop already sits exactly where
5014/// each break was, so no offset is lost.
5015fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
5016 if glyphs.iter().any(|g| g.ch == '\n') {
5017 return glyphs
5018 .split(|g| g.ch == '\n')
5019 .flat_map(|seg| wrap_segment(seg, width))
5020 .collect();
5021 }
5022 wrap_segment(glyphs, width)
5023}
5024
5025/// [`wrap_glyphs`] for a run with no explicit breaks — the word-wrap proper.
5026fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
5027 let width = width.max(1);
5028 // Words are maximal non-space runs, each carrying the space that followed it
5029 // — which survives only if the next word joins it on this line.
5030 let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
5031 let mut word: Vec<Glyph> = Vec::new();
5032 for g in glyphs {
5033 if g.ch == ' ' {
5034 words.push((std::mem::take(&mut word), Some(g.clone())));
5035 } else {
5036 word.push(g.clone());
5037 }
5038 }
5039 if !word.is_empty() {
5040 words.push((word, None));
5041 }
5042
5043 let mut lines: Vec<Vec<Glyph>> = Vec::new();
5044 let mut line: Vec<Glyph> = Vec::new();
5045 let mut used = 0usize;
5046 let mut gap: Option<Glyph> = None;
5047 for (word, space) in words {
5048 for chunk in hard_break(&word, width) {
5049 let sep = gap.is_some() as usize;
5050 let cells = glyphs_width(chunk);
5051 if !line.is_empty() && used + sep + cells > width {
5052 lines.push(std::mem::take(&mut line));
5053 used = 0;
5054 gap = None; // the break swallows the space
5055 }
5056 if let Some(sp) = gap.take() {
5057 line.push(sp);
5058 used += 1;
5059 }
5060 line.extend_from_slice(chunk);
5061 used += cells;
5062 }
5063 gap = space;
5064 }
5065 // An empty cell is still one (empty) line — it has an end the caret can
5066 // sit at, which is how you type into it.
5067 if !line.is_empty() || lines.is_empty() {
5068 lines.push(line);
5069 }
5070 lines
5071}
5072
5073/// Break a single word into pieces of at most `width` columns, cutting only
5074/// between grapheme clusters — the replacement for slicing it into fixed runs
5075/// of glyphs, which measures a wide character as one column and can cut an
5076/// emoji in half.
5077///
5078/// A cluster wider than the whole column still gets a piece to itself: there is
5079/// nowhere legal to cut it, and overflowing by a cell is better than splitting a
5080/// character. An empty word yields no pieces at all, which is what keeps a
5081/// double space from opening a line of its own.
5082fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
5083 let mut out = Vec::new();
5084 if word.is_empty() {
5085 return out;
5086 }
5087 let (mut start, mut used) = (0usize, 0usize);
5088 for c in clusters(word) {
5089 if used > 0 && used + c.cells > width {
5090 out.push(&word[start..c.glyph]);
5091 start = c.glyph;
5092 used = 0;
5093 }
5094 used += c.cells;
5095 }
5096 out.push(&word[start..]);
5097 out
5098}
5099
5100/// A table rule spanning `widths`, e.g. `┌──────┬─────┐`. Each column is its
5101/// content width plus the one-space gutter on either side.
5102fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
5103 let mut s = String::new();
5104 s.push(left);
5105 for (i, w) in widths.iter().enumerate() {
5106 if i > 0 {
5107 s.push(mid);
5108 }
5109 for _ in 0..w + 2 {
5110 s.push('─');
5111 }
5112 }
5113 s.push(right);
5114 s
5115}
5116
5117/// Push real document text: each glyph maps to its own source byte, and the one
5118/// that opens a grapheme cluster is the caret stop for the whole cluster.
5119///
5120/// Per cluster rather than per codepoint because a cluster is the character the
5121/// user sees, and it's the unit backspace and delete already step by. A stop
5122/// inside 👨👩👧 — five codepoints strung together with joiners — is a caret
5123/// parked in the middle of a character: one press of Right lands there, and the
5124/// next Backspace severs a joiner from what it joined, leaving a dangling ZWJ in
5125/// the source. The rest of the cluster still gets its glyph (it has to be
5126/// drawn); it just isn't somewhere to stand.
5127fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
5128 for (gi, cluster) in text.grapheme_indices(true) {
5129 for (ci, ch) in cluster.char_indices() {
5130 out.push(Glyph {
5131 ch,
5132 style,
5133 src: base_src + gi + ci,
5134 stop: ci == 0,
5135 });
5136 }
5137 }
5138}
5139
5140/// [`push_text`] for one line of a highlighted code block: the same glyphs at
5141/// the same offsets, each additionally carrying the [`Token`] of the span it
5142/// falls in — `spans` being the line's entry from [`code_tokens`], ascending
5143/// byte ranges *into `text`*. A byte between spans keeps `style` as it is.
5144///
5145/// Offsets are what matters here: a token changes how a glyph is painted and
5146/// nothing about where it is or which source byte it stands on, so a caret
5147/// walks a highlighted block exactly as it walks an unhighlighted one.
5148fn push_code_text(
5149 out: &mut Vec<Glyph>,
5150 text: &str,
5151 base_src: usize,
5152 style: Style,
5153 spans: &[(Range<usize>, Token)],
5154) {
5155 let mut spans = spans.iter().peekable();
5156 for (gi, cluster) in text.grapheme_indices(true) {
5157 // Spans are ascending, so the one covering this cluster's first byte
5158 // is at or after the one that covered the last; step past those ended.
5159 while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
5160 spans.next();
5161 }
5162 let token = spans
5163 .peek()
5164 .filter(|(r, _)| r.contains(&gi))
5165 .map(|(_, t)| *t);
5166 // A cluster is classed whole, by its first byte: a grammar that split
5167 // an emoji's scalars between two tokens would otherwise split the
5168 // glyph, and no grammar means to.
5169 let style = style.token(token);
5170 for (ci, ch) in cluster.char_indices() {
5171 out.push(Glyph {
5172 ch,
5173 style,
5174 src: base_src + gi + ci,
5175 stop: ci == 0,
5176 });
5177 }
5178 }
5179}
5180
5181/// One line's highlighting — `crate::syntax::LineTokens`, spelled here so the
5182/// shape exists whether or not the feature that fills it does.
5183type LineTokens = Vec<(Range<usize>, Token)>;
5184
5185/// The syntax highlighting for a code block's lines, or `None` when the fence's
5186/// language is not one the grammars know. Without the `syntax` feature nothing
5187/// is known, and every code glyph draws in the plain code colour.
5188#[cfg(feature = "syntax")]
5189fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
5190 crate::syntax::highlight(lang, lines)
5191}
5192
5193#[cfg(not(feature = "syntax"))]
5194fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
5195 None
5196}
5197
5198/// Emit an inline `str`/`smart_punctuation` run, mapping every visible char back
5199/// to its *true* source byte even when the source carries backslash escapes the
5200/// parsed `text` dropped (`\*` → `*`). The naive `span.start + text_offset`
5201/// mapping [`push_text`] uses drifts by one byte after each escape, so a caret or
5202/// click past an escaped `*` would land on the wrong character; walking the text
5203/// against its source keeps them aligned, and the hidden escape backslash gets no
5204/// glyph of its own (it is a spelling artefact, not something the caret lands on).
5205fn push_escaped_text(
5206 out: &mut Vec<Glyph>,
5207 text: &str,
5208 span: Range<usize>,
5209 source: &str,
5210 style: Style,
5211) {
5212 let end = span.end.min(source.len());
5213 let src = source.get(span.start..end).unwrap_or("");
5214 // Fast path — no dropped bytes, so text and source align 1:1 (the common
5215 // case: prose with no escapes). Byte lengths equal ⇒ no backslash was eaten.
5216 if src.len() == text.len() {
5217 push_text(out, text, span.start, style);
5218 return;
5219 }
5220 // Slow path: some `\` was consumed. Walk char-by-char, skipping a backslash
5221 // in the source exactly when it escapes the next visible char (a real escape),
5222 // never when it is a literal backslash the parse kept (that case has equal
5223 // lengths and takes the fast path above).
5224 let sb = src.as_bytes();
5225 let mut si = 0usize;
5226 'text: for (_, cluster) in text.grapheme_indices(true) {
5227 for (ci, ch) in cluster.char_indices() {
5228 // The text outlasted the source it is being mapped onto. In a
5229 // consistent document that cannot happen on this path: the slow path
5230 // is only entered when the two lengths differ, and everything that
5231 // makes them differ makes the *source* the longer one — an escape
5232 // backslash the parse ate, or source folded into a neighbouring node.
5233 // A `smart_punctuation` node reports its canonical ASCII spelling
5234 // (`--`, `...`, `"`), which is never longer than what was written.
5235 //
5236 // So reaching here means `span` was measured against a document that
5237 // `source` is no longer, and there is no honest offset left to give
5238 // the remaining characters. Stop: the row comes out short, which is
5239 // a wrong picture of a document that is already inconsistent. The
5240 // alternative was `si` stepping past the end and the slice below
5241 // panicking — which is what it did, in a paint loop.
5242 if si >= sb.len() {
5243 break 'text;
5244 }
5245 // Advance to the source character this one came from, stepping over
5246 // whatever the parse dropped on the way. An escape backslash is the
5247 // common case, but not the only one: a span can cover source that
5248 // was folded into a neighbouring node (smart punctuation next to a
5249 // bracket gives `text: "]"` over a source span of `"…]"`). Advancing
5250 // by the *text* character's length assumed escapes were the only
5251 // divergence, so one dropped multi-byte character desynchronized
5252 // every glyph after it — placing `]` inside the `…` before it.
5253 while si < sb.len() && !src[si..].starts_with(ch) {
5254 si += src[si..].chars().next().map_or(1, char::len_utf8);
5255 }
5256 out.push(Glyph {
5257 ch,
5258 style,
5259 src: span.start + si.min(src.len()),
5260 stop: ci == 0,
5261 });
5262 si += src[si..]
5263 .chars()
5264 .next()
5265 .map_or(ch.len_utf8(), char::len_utf8);
5266 }
5267 }
5268}
5269
5270/// Build synthetic decoration glyphs (a bullet, a gutter) all pointing at `src`,
5271/// each carrying `role` so the frontend can style it (`Role::Body` for plain
5272/// padding). Synthetic glyphs are never caret stops — they share one offset, so
5273/// the caret steps over them (a click still lands at `src`).
5274fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
5275 let style = Style::default().role(role);
5276 text.chars()
5277 .map(|ch| Glyph {
5278 ch,
5279 style,
5280 src,
5281 stop: false,
5282 })
5283 .collect()
5284}
5285
5286fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
5287 let mut v = a.to_vec();
5288 v.extend_from_slice(b);
5289 v
5290}
5291
5292/// The columns a row's prefix (a bullet, a quote gutter, an indent) takes up
5293/// before the text it introduces — what the wrap budget has left to spend.
5294fn prefix_width(prefix: &[Glyph]) -> usize {
5295 glyphs_width(prefix)
5296}
5297
5298/// The label shown for an image with no alt text: the final path segment of its
5299/// destination (`img/cat.png` → `cat.png`), the whole destination when it has no
5300/// separator, and `"image"` when it's empty. A `data:` URI (which has no useful
5301/// tail) shows its scheme so the placeholder isn't a wall of base64.
5302fn media_label(dest: &str) -> String {
5303 if dest.is_empty() {
5304 return "image".to_string();
5305 }
5306 if dest.starts_with("data:") {
5307 return "data:…".to_string();
5308 }
5309 // Trim a query/fragment so a URL's `?v=2#frag` doesn't ride along.
5310 let clean = dest.split(['?', '#']).next().unwrap_or(dest);
5311 let tail = clean
5312 .trim_end_matches('/')
5313 .rsplit(['/', '\\'])
5314 .next()
5315 .unwrap_or(clean);
5316 if tail.is_empty() {
5317 dest.to_string()
5318 } else {
5319 tail.to_string()
5320 }
5321}
5322
5323/// A directive's attributes read as a human label — what a frontend puts on a
5324/// container's tinted panel, and what an attribute-bearing inline directive
5325/// shows in its chip.
5326///
5327/// Reads BOTH conventions diaryx content actually uses: twig's own dot-prefixed
5328/// classes (`{.public .family}`, arriving as one combined `class` attr) and bare
5329/// pandoc-style words with no leading dot (`{public family}` — what
5330/// `diaryx_core::visibility`'s publish-time filter and apps/web's directive
5331/// serializer both write, and which twig parses as one valueless attribute
5332/// each). Reading only `.class` would leave every *existing* diaryx `:::vis{…}`
5333/// block unlabeled. A `key=value` attr is configuration rather than a name, so
5334/// it contributes nothing. `None` when nothing readable is left.
5335fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
5336 let mut parts: Vec<String> = Vec::new();
5337 for (k, v) in attrs {
5338 if k == "class" {
5339 if let Some(v) = v
5340 && !v.is_empty()
5341 {
5342 parts.push(v.clone());
5343 }
5344 } else if v.as_deref().unwrap_or("").is_empty() {
5345 parts.push(k.clone());
5346 }
5347 }
5348 (!parts.is_empty()).then(|| parts.join(" "))
5349}
5350
5351fn heading_style(level: u32) -> Style {
5352 // Just the role — a frontend decides how a heading of this level *looks*
5353 // (the terminal cycles a color and bolds it, the GUI scales the font). The
5354 // author wrote no emphasis here, so core records none. `level as u8` is safe:
5355 // Markdown/Djot cap headings at 6.
5356 Style::default().role(Role::Heading(level.min(255) as u8))
5357}
5358
5359/// Is this `container` node a *directive* (`:::note{…}`, `::embed{…}`,
5360/// `:vis[…]`) rather than an HTML element (`<video>`, `<picture>`, `<div>`)?
5361///
5362/// twig 2.8 folded `div`/`span`/`directive`/`element` into one `container` kind,
5363/// and left nothing that separated them: `kind`, `name` and `directive_form` all
5364/// agree, field for field, on an HTML `<div>` and a Markdown `:::div`. Leaf
5365/// answered it by sniffing the span for whichever of `:` or `<` came first.
5366/// twig 3.0 records the answer at parse time as [`ContainerOrigin`], so this is
5367/// now the parser's own knowledge rather than a guess rebuilt from the bytes it
5368/// consumed.
5369pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
5370 node.origin == Some(ContainerOrigin::Directive)
5371}
5372
5373/// The tag a `container` node carries when it is an HTML element rather than a
5374/// directive — `Some("video")` for a promoted `<video>`, `None` for a `:::note`
5375/// or for any node that is not a container at all.
5376pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
5377 (node.origin == Some(ContainerOrigin::Element))
5378 .then_some(node.name.as_deref())
5379 .flatten()
5380}
5381
5382/// A leaf directive's name and whatever attributes are not part of spelling
5383/// it — the two things a [`DirectiveMark`] carries, which twig hands back
5384/// differently per format and which a frontend must not be able to tell apart.
5385///
5386/// Markdown's `::page-break` is a `Leaf`-form directive *named* `page-break`
5387/// with no attributes, and this returns it verbatim. Djot has no leaf form:
5388/// `insert_directive` writes the same document as an empty `::: page-break`
5389/// fence, whose container is anonymous (a djot div carries no name) and whose
5390/// name arrives as the fence's one class. So where the node has no name of its
5391/// own the first `class` token *is* the name, and whatever else the class said
5392/// — an author's `::: page-break {.wide}` — stays an attribute.
5393fn leaf_directive_identity(node: &FlatNode) -> (String, Vec<(String, Option<String>)>) {
5394 let named = node.name.clone().unwrap_or_default();
5395 if !named.is_empty() {
5396 return (named, node.attrs.clone());
5397 }
5398 let class = node
5399 .attrs
5400 .iter()
5401 .find(|(k, _)| k == "class")
5402 .and_then(|(_, v)| v.as_deref())
5403 .unwrap_or_default();
5404 let mut tokens = class.split_whitespace();
5405 let Some(name) = tokens.next().map(str::to_string) else {
5406 return (named, node.attrs.clone());
5407 };
5408 let rest = tokens.collect::<Vec<_>>().join(" ");
5409 let attrs = node
5410 .attrs
5411 .iter()
5412 .filter_map(|(k, v)| {
5413 if k != "class" {
5414 return Some((k.clone(), v.clone()));
5415 }
5416 (!rest.is_empty()).then(|| (k.clone(), Some(rest.clone())))
5417 })
5418 .collect();
5419 (name, attrs)
5420}
5421
5422/// Is this inline `container` an **attributed span** — the node leaf's run-level
5423/// vocabulary rides — rather than a named directive?
5424///
5425/// The four formats spell one span four ways and twig hands the name back for
5426/// two of them: HTML's and Markdown's `<span …>` arrive named `span` with
5427/// `Element` origin, while djot's `[text]{…}` and AsciiDoc's `[.a]#text#`
5428/// arrive anonymous (an empty name) with `Directive` origin. All four are the
5429/// same node to `wrap_range_attrs`, which is what writes them, so they are the
5430/// same node here.
5431///
5432/// A *named* directive is not one, whatever its name: a Markdown `:span[…]{…}`
5433/// is a directive the parser read as a directive, twig's own
5434/// `wrap_range_attrs` says so, and it keeps the handling it has.
5435///
5436/// **Anonymous is not enough**, and the form is what finishes the question:
5437/// a djot fenced div (`{.center}` / `:::` / … / `:::`) is anonymous too, with
5438/// the same `Directive` origin, and is a *block* — `Container` form against the
5439/// span's `Text`. Reading one as a span made every gesture and every query lie
5440/// about it: `set_text_color` over a word inside such a div copied the whole
5441/// div's attribute set — its `id` along with the rest — onto the new span, and
5442/// `alignment_at_caret` reported the div's `.center` as a *run's* answer while
5443/// the walker drew none. So the anonymous arm asks the form [`is_inline`] asks.
5444pub(crate) fn is_run_span(node: &FlatNode) -> bool {
5445 if node.kind != Kind::Container {
5446 return false;
5447 }
5448 match node.name.as_deref() {
5449 None | Some("") => node.directive_form == Some(DirectiveForm::Text),
5450 Some("span") => node.origin == Some(ContainerOrigin::Element),
5451 Some(_) => false,
5452 }
5453}
5454
5455/// `base` with an attributed span's three run-level keys written over it — the
5456/// nearest-wins fold [`is_run_span`] describes, for one span.
5457fn run_style(node: &FlatNode, base: Style) -> Style {
5458 Style {
5459 size: SizeStep::from_attrs(&node.attrs).or(base.size),
5460 font: FontFamily::from_attrs(&node.attrs).or(base.font),
5461 color: MarkColor::from_attrs(&node.attrs).or(base.color),
5462 ..base
5463 }
5464}
5465
5466pub(crate) fn is_inline(node: &FlatNode) -> bool {
5467 // A directive is inline only in its `text` form (`:name[label]{…}`); the
5468 // `leaf` and `container` forms are blocks. All three report the same `kind`,
5469 // so the form is the only thing telling them apart — and getting it wrong
5470 // costs a whole paragraph: a text directive misread as a block makes its
5471 // paragraph fail the "all children inline" test in `block`, and the line is
5472 // then walked as a container of blocks, rendering as empty rows with no
5473 // caret home at all.
5474 //
5475 // An HTML element shares the `container` kind, and twig sets the same form
5476 // on the two tags the lightweight formats have a generic spelling for: a
5477 // `<span>` is `Text` and a `<div>` is `Container`, while a `<video>` or a
5478 // `<picture>` has no form at all. So the form answers for an element as it
5479 // answers for a directive, and the origin is not consulted — which is what
5480 // makes a `<span …>` inside a paragraph an inline node.
5481 //
5482 // It has to. `wrap_range_attrs` spells an attributed run as exactly that
5483 // span in Markdown and HTML, and a paragraph holding one whose kids were
5484 // not all inline failed the test below and was walked as a container of
5485 // blocks: the text either side of the span rendered as nothing at all.
5486 if node.kind == Kind::Container {
5487 return node.directive_form == Some(DirectiveForm::Text);
5488 }
5489 is_inline_kind(&node.kind)
5490}
5491
5492/// [`is_inline`] by kind alone — for the ancestor walks, whose `QueryMatch`es
5493/// carry no `directive_form`. It answers `false` for every directive, which its
5494/// callers must (and do) reconcile: they pair it with `is_block_container`,
5495/// which claims every directive, so the pair's verdict is the same one a form
5496/// would have given. Anything looking at a *directive itself* wants [`is_inline`]
5497/// and a real node.
5498pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
5499 matches!(
5500 kind,
5501 Kind::Str
5502 | Kind::SoftBreak
5503 | Kind::HardBreak
5504 | Kind::NonBreakingSpace
5505 | Kind::Emph
5506 | Kind::Strong
5507 | Kind::Mark
5508 | Kind::Insert
5509 | Kind::Delete
5510 | Kind::Verbatim
5511 | Kind::InlineMath
5512 | Kind::DisplayMath
5513 | Kind::Url
5514 | Kind::Email
5515 | Kind::Link
5516 | Kind::Image
5517 | Kind::SmartPunctuation
5518 | Kind::Superscript
5519 | Kind::Subscript
5520 | Kind::FootnoteReference
5521 )
5522}
5523
5524/// Assert two maps are identical down to every glyph, stop, and table span — the
5525/// contract `build_cached` and `build_spliced` must hold against `build`. Lives
5526/// at module scope (not in `mod tests`) so the Doc-driven differential test in
5527/// `doc.rs` can reach it and the private `stops` field it compares.
5528#[cfg(test)]
5529pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
5530 assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
5531 for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
5532 assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
5533 assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
5534 // The incremental walk labels a boundary from a query match's kind
5535 // string and the whole-arena walk from a `FlatNode`'s; this is what says
5536 // the two doors reach the same answer.
5537 assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
5538 assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
5539 assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
5540 assert_eq!(ra.align, rb.align, "row {i} align ({ctx})");
5541 assert_eq!(
5542 ra.line_height, rb.line_height,
5543 "row {i} line_height ({ctx})"
5544 );
5545 assert_eq!(
5546 ra.glyphs.len(),
5547 rb.glyphs.len(),
5548 "row {i} glyph count ({ctx})"
5549 );
5550 for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
5551 assert_eq!(
5552 (ga.ch, ga.src, ga.stop, ga.style),
5553 (gb.ch, gb.src, gb.stop, gb.style),
5554 "row {i} glyph {j} ({ctx})"
5555 );
5556 }
5557 }
5558 assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
5559 assert_eq!(a.stops, b.stops, "stops ({ctx})");
5560 assert_eq!(a.mark_ends, b.mark_ends, "mark_ends ({ctx})");
5561 assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
5562 for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
5563 assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
5564 assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
5565 }
5566 assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
5567 assert_eq!(a.media, b.media, "images ({ctx})");
5568}
5569
5570#[cfg(test)]
5571mod tests {
5572 use super::*;
5573 use twig::{Editor, Format, NodeId};
5574
5575 fn map(src: &str) -> VisualMap {
5576 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5577 build_t(&ed.nodes().unwrap(), src, Some(80))
5578 }
5579
5580 /// [`map`] over a Djot source. Djot is the format that spells superscript
5581 /// and subscript at all — Markdown has no syntax for either.
5582 fn map_djot(src: &str) -> VisualMap {
5583 let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5584 build_t(&ed.nodes().unwrap(), src, Some(80))
5585 }
5586
5587 /// The baseline every glyph spelling `ch` was built with, in row order —
5588 /// how a test reads a raised or lowered run off the map without caring
5589 /// which row it landed on.
5590 fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
5591 m.rows
5592 .iter()
5593 .flat_map(|r| r.glyphs.iter())
5594 .filter(|g| g.ch == ch)
5595 .map(|g| g.style.baseline)
5596 .collect()
5597 }
5598
5599 /// [`map`] at a chosen wrap width.
5600 fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
5601 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5602 build_t(&ed.nodes().unwrap(), src, wrap)
5603 }
5604
5605 /// [`map`], but with twig's `directives` extension on (off by twig's own
5606 /// default) — the `:::name{.class}` fenced-div containers leaf-core's
5607 /// `"directive"` wysiwyg arm renders.
5608 fn map_directives(src: &str) -> VisualMap {
5609 let mut ed = Editor::new_ext(
5610 src.as_bytes(),
5611 Format::Markdown,
5612 twig::MarkdownExtensions {
5613 directives: true,
5614 ..Default::default()
5615 },
5616 )
5617 .unwrap();
5618 build_t(&ed.nodes().unwrap(), src, Some(80))
5619 }
5620
5621 /// [`map`] in `format`, parsed the way every leaf document is — the
5622 /// extensions [`crate::doc::parse_extensions`] turns on, which is what
5623 /// pairs a Markdown `<div …>` with its `</div>` into a container and makes
5624 /// `::page-break` a directive rather than a paragraph of colons.
5625 fn map_leaf(src: &str, format: Format) -> VisualMap {
5626 let mut ed =
5627 Editor::new_ext(src.as_bytes(), format, crate::doc::parse_extensions()).unwrap();
5628 build_t(&ed.nodes().unwrap(), src, Some(80))
5629 }
5630
5631 /// The alignment and line spacing of every row that draws text, in order —
5632 /// how a test reads a block property off the map.
5633 fn line_facts(m: &VisualMap) -> Vec<(Option<Align>, Option<LineSpacing>)> {
5634 m.rows
5635 .iter()
5636 .filter(|r| r.glyphs.iter().any(|g| !g.ch.is_whitespace()))
5637 .map(|r| (r.align, r.line_height))
5638 .collect()
5639 }
5640
5641 /// The style of the glyph spelling `ch`, first occurrence — how a test reads
5642 /// a run property off the map.
5643 fn style_of(m: &VisualMap, ch: char) -> Style {
5644 m.rows
5645 .iter()
5646 .flat_map(|r| r.glyphs.iter())
5647 .find(|g| g.ch == ch)
5648 .unwrap_or_else(|| panic!("no glyph {ch:?} in the map"))
5649 .style
5650 }
5651
5652 /// [`map`] with soft breaks preserved (`LineFlow::Preserve`).
5653 fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
5654 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5655 build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
5656 }
5657
5658 /// The cache-free reference [`build`], with no per-image height overrides —
5659 /// every block image stays its default one-row placeholder. The tests that
5660 /// need a taller image drive it through [`crate::Doc::set_media_rows`] instead.
5661 fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
5662 build(nodes, src, wrap, false, &HashMap::new(), None)
5663 }
5664
5665 /// An arena and a string that disagree — spans reaching past the source they
5666 /// are built against.
5667 ///
5668 /// [`crate::Doc`] keeps the two in step, so this is a "cannot happen" that
5669 /// nonetheless *did*: `examples/bench` timed a loop of `edit_range` and then
5670 /// went on handing the grown editor's spans to a builder holding the string
5671 /// from before it, and every run ended in a slice panic rather than a
5672 /// number. `push_escaped_text` was already written to survive the mismatch —
5673 /// it clamps the span's end and falls back to an empty slice — and this is
5674 /// the half of that intent it did not carry through.
5675 ///
5676 /// Rendering the wrong thing is the acceptable answer here; panicking in a
5677 /// paint loop is not.
5678 #[test]
5679 fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
5680 // An escape puts the run on `push_escaped_text`'s slow path — the fast
5681 // path is a length comparison that a truncated source fails anyway.
5682 let src = "alpha \\*beta\\* gamma delta epsilon\n";
5683 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5684 let nodes = ed.nodes().unwrap();
5685
5686 // Every truncation of it, so the cut lands before, inside and after the
5687 // escaped run rather than only where one hand-picked index put it.
5688 for cut in 0..=src.len() {
5689 if !src.is_char_boundary(cut) {
5690 continue;
5691 }
5692 let map = build_t(&nodes, &src[..cut], Some(80));
5693 for row in &map.rows {
5694 for g in &row.glyphs {
5695 assert!(
5696 g.src <= src.len(),
5697 "cut {cut}: glyph {:?} points past the source at {}",
5698 g.ch,
5699 g.src
5700 );
5701 }
5702 }
5703 }
5704 }
5705
5706 fn rendered(m: &VisualMap) -> String {
5707 m.rows
5708 .iter()
5709 .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
5710 .collect::<Vec<_>>()
5711 .join("\n")
5712 }
5713
5714 /// Render a source both ways: `build` over the whole marshalled arena (the
5715 /// reference), and `build_cached` driven the way [`crate::Doc`] drives it —
5716 /// top-level blocks from `child_spans`, per-block subtrees on a miss.
5717 fn render_both(
5718 ed: &mut Editor,
5719 src: &str,
5720 wrap: Option<usize>,
5721 cache: &mut BlockCache,
5722 ) -> (VisualMap, VisualMap) {
5723 let all = ed.nodes().unwrap();
5724 let media_rows = HashMap::new();
5725 let plain = build(&all, src, wrap, false, &media_rows, None);
5726 let top = top_blocks(ed);
5727 let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
5728 ed.subtree(NodeId(id)).unwrap_or_default()
5729 });
5730 (plain, cached)
5731 }
5732
5733 /// The whole correctness claim of the block cache: `build_cached` produces a
5734 /// byte-identical map to `build`, on a fresh cache *and* — the case that
5735 /// actually exercises reuse-and-shift plus per-block subtree marshalling — on
5736 /// a warm cache after the source has been edited underneath it.
5737 /// **Every glyph must stand on the character it claims.** A row's source
5738 /// extent is computed from its last glyph's offset, so a glyph carrying an
5739 /// offset that is not its own character's start yields a row end inside a
5740 /// multi-byte character — and every later slice of the source panics on it.
5741 ///
5742 /// Reproduces a real crash from a journal entry: a bracketed elision inside
5743 /// a blockquote (`[…]`) gave the closing bracket a `text` of `"]"` over a
5744 /// source span covering `"…]"`, because the parse folded the ellipsis into a
5745 /// neighbouring node. `push_escaped_text` walked that span assuming a
5746 /// dropped backslash was the only way text and source could diverge, so the
5747 /// `]` landed on the `…`'s first byte:
5748 /// `byte index 1236 is not a char boundary; it is inside '…'`.
5749 #[test]
5750 fn a_glyph_never_lands_inside_the_character_before_it() {
5751 let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
5752 let vmap = map(src);
5753 for (r, row) in vmap.rows.iter().enumerate() {
5754 assert!(
5755 src.is_char_boundary(row.end_src.min(src.len())),
5756 "row {r} ends at {} — inside a character",
5757 row.end_src
5758 );
5759 for g in &row.glyphs {
5760 assert!(
5761 src.is_char_boundary(g.src.min(src.len())),
5762 "row {r} has {:?} at {}, which is inside a character",
5763 g.ch,
5764 g.src
5765 );
5766 }
5767 }
5768 // The elision survives, and its bracket sits on the real `]`.
5769 let text: String = vmap
5770 .rows
5771 .iter()
5772 .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5773 .collect();
5774 assert!(text.contains("[…]"), "the elision should render: {text:?}");
5775 let close = vmap
5776 .rows
5777 .iter()
5778 .flat_map(|r| r.glyphs.iter())
5779 .find(|g| g.ch == ']')
5780 .expect("a closing bracket");
5781 assert_eq!(
5782 src[close.src..].chars().next(),
5783 Some(']'),
5784 "the bracket glyph should stand on the source's own `]`"
5785 );
5786 }
5787
5788 #[test]
5789 fn build_cached_matches_build() {
5790 let docs = [
5791 "# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
5792 "## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
5793 "para one\n\n```\ncode\nlines\n```\n\nafter code\n",
5794 "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
5795 "line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
5796 "> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
5797 "intro\n\n\n\nbetween\n\n\n\nend\n",
5798 "- text item\n- \n- more text\n",
5799 // Footnotes: twig parses each definition as a root beside `doc`, so
5800 // these are the docs where the reference build and the incremental
5801 // one could disagree about what the top-level blocks even are.
5802 "A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
5803 "note[^a]\n\n[^a]: body **bold**\n wrapped on\n three lines\n\nafter\n",
5804 // No trailing newline. twig closes the document's last block on the
5805 // virtual newline it supplies at EOF, so that block's `span.end` is
5806 // `source.len() + 1` — a range that slices no bytes at all. Keying
5807 // the block cache off such a slice made every last block hash alike;
5808 // see [`block_bytes`].
5809 "# Title\n\nThe quick brown fox.\n\nA tail with no newline",
5810 "A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
5811 // Comments draw nothing. The per-block builder the cached path
5812 // renders one with starts at offset 0 and, drawing nothing, never
5813 // moved — so the walk went on from 0 and spelled every line of the
5814 // document as a blank row. One at the start, one between blocks,
5815 // one at the end, so each position is covered.
5816 "<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
5817 // A Markdown `<div>` ends with a hidden `</div>` line the walk
5818 // steps over — between blocks and closing the file, so both the
5819 // separator after it and the trailing count are covered.
5820 "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
5821 "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n",
5822 // Link reference definitions: roots beside `doc` like footnotes,
5823 // but drawing nothing. Alone between blocks, glued under a
5824 // paragraph, and closing the file under a comment — the README
5825 // shape.
5826 "see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
5827 ];
5828 for wrap in [None, Some(80usize), Some(20)] {
5829 for src in docs {
5830 let ctx = format!("wrap={wrap:?} src={src:?}");
5831 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5832 let mut cache = BlockCache::default();
5833
5834 // 1) Fresh cache equals the cache-free build.
5835 let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
5836 assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
5837
5838 // 2) Type a char mid-document, reparse, rebuild with the now-warm
5839 // cache: the edited block is re-marshalled and re-rendered,
5840 // every block below it is reused shifted, and the result must
5841 // still match a from-scratch build.
5842 let at = (src.len() / 2..=src.len())
5843 .find(|&i| src.is_char_boundary(i))
5844 .unwrap();
5845 ed.edit_range(at, at, "Z").unwrap();
5846 let src2 = ed.source_str().unwrap();
5847 let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
5848 assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
5849
5850 // 3) Delete it again: offsets shift back the other way, and the
5851 // warm cache must not hand back stale shifted rows.
5852 ed.edit_range(at, at + 1, "").unwrap();
5853 let src3 = ed.source_str().unwrap();
5854 let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
5855 assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
5856 }
5857 }
5858 }
5859
5860 /// A document that does not end in a newline is the one place twig hands
5861 /// leaf a top-level span that addresses no source: the last block is closed
5862 /// on the virtual newline the parser supplies at EOF, so its `span.end` is
5863 /// `source.len() + 1`. The block cache keys on the bytes under that span, and
5864 /// reading the out-of-range slice as *no bytes* broke it two ways at once —
5865 /// [`block_bytes`] has the full account. Both ways are checked here, because
5866 /// they fail independently.
5867 #[test]
5868 fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
5869 // One: two overrunning blocks collide. A footnote definition is a root
5870 // beside `doc` that [`top_blocks`] merges into the top level, while the
5871 // `section` above it spans the definition's bytes too — so when the
5872 // definition ends the file, both blocks end past it. The second was
5873 // served the first's rows, and the definition rendered as a copy of the
5874 // heading.
5875 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.";
5876 let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5877 let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
5878 assert_maps_eq(&plain, &cached, "a definition ending the file");
5879 let text = rendered(&cached);
5880 assert!(
5881 text.ends_with("[note] A note with a word for a label."),
5882 "the last definition should render itself: {text:?}"
5883 );
5884 assert_eq!(
5885 text.matches("A heading with a reference").count(),
5886 1,
5887 "the heading should render exactly once: {text:?}"
5888 );
5889
5890 // Two: one overrunning block goes stale. Its bytes are its cache key, so
5891 // a block that keeps hashing the same however it is edited is served the
5892 // rows built before the edit — the whole last line frozen as the user
5893 // types in it.
5894 let mut cache = BlockCache::default();
5895 let first = "first para\n\n# A heading\n\nlast para with no newline";
5896 let mut ed = Editor::new_str(first, Format::Djot).unwrap();
5897 let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
5898 assert!(rendered(&warm).ends_with("last para with no newline"));
5899
5900 let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
5901 let mut ed = Editor::new_str(second, Format::Djot).unwrap();
5902 let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
5903 assert_maps_eq(&plain, &cached, "edited last block, warm cache");
5904 let text = rendered(&cached);
5905 assert!(
5906 text.ends_with("DIFFERENT text without a newline"),
5907 "the warm cache served the pre-edit rows: {text:?}"
5908 );
5909 }
5910
5911 #[test]
5912 fn resolves_markup_to_plain_text() {
5913 let text = rendered(&map("# Title\n\na **bold** word\n"));
5914 assert!(!text.contains('#'), "heading marker shown: {text:?}");
5915 assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
5916 assert!(text.contains("Title") && text.contains("bold word"));
5917 }
5918
5919 #[test]
5920 fn every_glyph_points_at_its_source_byte() {
5921 let src = "a **bold** c\n";
5922 let m = map(src);
5923 for row in &m.rows {
5924 for g in &row.glyphs {
5925 // A real (non-synthetic) glyph's source byte is the glyph's char.
5926 if g.src < src.len()
5927 && src.is_char_boundary(g.src)
5928 && let Some(sc) = src[g.src..].chars().next()
5929 && sc == g.ch
5930 {
5931 continue;
5932 }
5933 // Synthetic prefixes (none here) would be the only exceptions.
5934 panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
5935 }
5936 }
5937 }
5938
5939 #[test]
5940 fn offset_and_position_round_trip_on_visible_text() {
5941 let m = map("hello world\n");
5942 let (r, c) = m.pos_of_offset(6); // the 'w'
5943 assert_eq!(m.offset_of_pos(r, c), 6);
5944 }
5945
5946 #[test]
5947 fn visible_utf16_indices_count_the_text_the_system_sees() {
5948 // Hidden delimiters, a two-unit emoji, and a block gap — every way the
5949 // visible text's UTF-16 length parts company with a source byte count.
5950 let src = "a **b\u{1F600}** c\n\nd\n";
5951 let m = map(src);
5952 let end = m.snap_to_stop(src.len());
5953 let text = m.visible_text(0, end);
5954 assert_eq!(text, "a b\u{1F600} c\nd");
5955
5956 // Forward: the index of each offset is where that character sits in
5957 // the visible string, in UTF-16 units.
5958 for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
5959 let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
5960 assert_eq!(
5961 m.visible_utf16_len(0, *src_off),
5962 expect,
5963 "utf16 index of source offset {src_off}"
5964 );
5965 // And back: the index resolves to the offset it came from.
5966 assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
5967 }
5968 // Inside the emoji's surrogate pair resolves to the emoji.
5969 let emoji_src = src.find('\u{1F600}').unwrap();
5970 let emoji_idx = m.visible_utf16_len(0, emoji_src);
5971 assert_eq!(
5972 m.offset_at_visible_utf16(end, emoji_idx + 1),
5973 Some(emoji_src)
5974 );
5975 // At or past the end is nobody's character.
5976 let total = m.visible_utf16_len(0, end);
5977 assert_eq!(total, text.encode_utf16().count());
5978 assert_eq!(m.offset_at_visible_utf16(end, total), None);
5979 }
5980
5981 #[test]
5982 fn visible_text_spends_exactly_one_character_on_every_stop() {
5983 // A list (whose items' ends no gap row follows), a table (whose cells'
5984 // ends draw a gutter space), and a code block (one row per line):
5985 // every place the text used to part company with the stop count, in
5986 // both directions. `UITextInput`'s tokenizer indexes this text by
5987 // that count, so the two must agree exactly between any two stops.
5988 let src = "- one\n- two\n\n| a | b |\n| - | - |\n| c | d |\n\n```\nx\ny\n```\n\nend\n";
5989 let m = map(src);
5990 let end = m.snap_to_stop(src.len());
5991 // The table's trailing stop draws no glyph, so it is spelled as a line
5992 // end too: to the system the table ends on a blank line, which is
5993 // where the caret past it stands.
5994 assert_eq!(m.visible_text(0, end), "one\ntwo\na\nb\nc\nd\n\nx\ny\nend");
5995 // Between any two stops, one character per hop.
5996 let first = m.snap_to_glyph_stop(0);
5997 let stops: Vec<usize> = std::iter::successors(Some(first), |&o| m.stop_after(o)).collect();
5998 for (i, &a) in stops.iter().enumerate() {
5999 for (j, &b) in stops.iter().enumerate().skip(i) {
6000 assert_eq!(
6001 m.visible_text(a, b).chars().count(),
6002 j - i,
6003 "text between stops {a} and {b}"
6004 );
6005 }
6006 }
6007 // A cell's end is spelled as a line end, not the space it draws, so a
6008 // tap landing past `a`'s last letter has nothing to step over into `b`.
6009 let a_end = src.find("a |").unwrap() + 1;
6010 assert_eq!(m.visible_text(a_end, a_end + 1), "\n");
6011 }
6012
6013 #[test]
6014 fn unwrapped_mode_emits_one_row_per_paragraph() {
6015 // A long paragraph that would wrap under a column budget stays a single
6016 // row when wrap is None (the GUI wraps it at pixel width instead).
6017 let long = "one two three four five six seven eight nine ten eleven twelve\n";
6018 let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
6019 let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
6020 let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
6021 assert!(wrapped.num_rows() > 1, "narrow column should wrap");
6022 assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
6023 // Every glyph's source byte is preserved in the single row.
6024 let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
6025 assert_eq!(text.trim_end(), long.trim_end());
6026 }
6027
6028 fn line_texts(m: &VisualMap) -> Vec<String> {
6029 m.rows
6030 .iter()
6031 .map(|r| {
6032 // Trim the trailing whitespace a row may carry — the zero-width
6033 // '\n' that closes a preserved line, and any space glyph left at
6034 // a wrap boundary (both real caret stops, neither visible text).
6035 r.glyphs
6036 .iter()
6037 .map(|g| g.ch)
6038 .collect::<String>()
6039 .trim_end()
6040 .to_string()
6041 })
6042 .collect()
6043 }
6044
6045 #[test]
6046 fn preserve_lays_each_soft_break_on_its_own_row() {
6047 // A soft break (a bare newline inside a paragraph) folds into a space by
6048 // default — the whole paragraph is one reflowed row...
6049 let src = "one two\nthree four\n";
6050 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6051 let folded = build_t(&ed.nodes().unwrap(), src, None);
6052 assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
6053 assert_eq!(
6054 line_texts(&folded),
6055 vec!["one two three four"],
6056 "break folded to a space"
6057 );
6058
6059 // ...and under Preserve it renders where it was written, a row per line.
6060 let kept = map_preserve(src, None);
6061 assert_eq!(
6062 line_texts(&kept),
6063 vec!["one two", "three four"],
6064 "preserve: a row per line"
6065 );
6066 }
6067
6068 #[test]
6069 fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
6070 // The break must leave a caret stop at the newline byte, or the caret
6071 // could not rest at the end of the first line. The '\n' glyph is dropped
6072 // from the row (so nothing stray renders); its offset (7 here) becomes the
6073 // row's end stop instead — the same offset the folded space would carry.
6074 let src = "one two\nthree four\n";
6075 let m = map_preserve(src, None);
6076 assert!(
6077 !m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
6078 "the break glyph is dropped"
6079 );
6080 assert_eq!(
6081 m.rows[0].end_src, 7,
6082 "the first row ends at the newline byte"
6083 );
6084 assert!(m.is_stop(7), "the newline offset is a caret stop");
6085 // Row end offsets stay strictly ascending — no two rows pin one offset.
6086 let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
6087 assert!(
6088 offs.windows(2).all(|w| w[0] < w[1]),
6089 "offsets not unique: {offs:?}"
6090 );
6091 }
6092
6093 #[test]
6094 fn preserved_lines_wrap_independently() {
6095 // Each preserved line wraps to the column on its own; the break between
6096 // them is hard, so a word never crosses it — "gamma" and "delta" could
6097 // share a row on width alone but the soft break keeps them apart.
6098 let src = "alpha beta gamma\ndelta epsilon\n";
6099 let m = map_preserve(src, Some(12));
6100 assert_eq!(
6101 line_texts(&m),
6102 vec!["alpha beta", "gamma", "delta", "epsilon"],
6103 "each source line wraps on its own"
6104 );
6105 }
6106
6107 #[test]
6108 fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
6109 // "A", then two blank lines (an empty paragraph opened with Enter), then
6110 // "B": the empty paragraph must be navigable rows, not collapsed onto B.
6111 // Rows: "A", spacer, empty-paragraph, spacer, "B" — each blank row a
6112 // distinct source offset.
6113 let m = map("A\n\n\n\nB\n");
6114 let text: Vec<String> = m
6115 .rows
6116 .iter()
6117 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6118 .collect();
6119 assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
6120 let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
6121 // Strictly ascending — no two rows share an offset (else the caret pins).
6122 assert!(
6123 offs.windows(2).all(|w| w[0] < w[1]),
6124 "offsets not unique: {offs:?}"
6125 );
6126 }
6127
6128 #[test]
6129 fn a_tight_block_boundary_still_gets_one_separator() {
6130 // A heading directly above text (no blank line between) keeps the single
6131 // conventional separator row, as before.
6132 let m = map("# H\ntext\n");
6133 let text: Vec<String> = m
6134 .rows
6135 .iter()
6136 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6137 .collect();
6138 assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
6139 }
6140
6141 #[test]
6142 fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
6143 // `a\*b` renders the three visible chars `a * b` — the escape backslash
6144 // is hidden — and every glyph points at its real source byte, so a caret
6145 // past the escape lands right (the `*` at source 2, `b` at source 3, not
6146 // the drifted 1/2 the naive text-offset mapping gave).
6147 let m = map("a\\*b\n");
6148 let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
6149 assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
6150 }
6151
6152 #[test]
6153 fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
6154 // `\# hi` is a paragraph beginning with a literal `#`, not a heading —
6155 // the backslash is hidden, the `#` shown at its true offset.
6156 let m = map("\\# hi\n");
6157 let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
6158 assert_eq!(text, "# hi");
6159 assert_eq!(
6160 m.rows[0].glyphs[0].src, 1,
6161 "the # is at source byte 1, past the \\"
6162 );
6163 }
6164
6165 #[test]
6166 fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
6167 // A list item's own text and the sub-list nested under it are written on
6168 // adjacent source lines, so the rich view butts them together — no
6169 // fabricated blank row. Regression: the synthetic "breathe" separator
6170 // used to open a gap between `• a` and its ` • b`.
6171 assert_eq!(rendered(&map("- a\n - b\n")), "• a\n • b");
6172 }
6173
6174 #[test]
6175 fn a_loose_nested_list_keeps_its_real_blank_line() {
6176 // A genuine blank source line (a loose list) still parts the item from
6177 // its sub-list — only the *fabricated* separator is suppressed, never a
6178 // real one the author typed. The gap row wears the item's continuation
6179 // prefix (the two-space indent), so it renders as " ", not empty.
6180 assert_eq!(rendered(&map("- a\n\n - b\n")), "• a\n \n • b");
6181 }
6182
6183 #[test]
6184 fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
6185 // Leading YAML frontmatter renders nothing — no phantom blank rows for
6186 // its lines, no leading gap — and `content_start` points at the first
6187 // real block so the caret floor can keep out of the hidden metadata.
6188 let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
6189 let src = format!("{fm}# leaf\n\nA line.\n");
6190 let m = map(&src);
6191 let text = rendered(&m);
6192 assert!(
6193 !text.contains("config"),
6194 "frontmatter body leaked: {text:?}"
6195 );
6196 assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
6197 assert_eq!(
6198 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6199 "leaf"
6200 );
6201 assert_eq!(
6202 m.content_start,
6203 fm.len(),
6204 "floor should be the first real block"
6205 );
6206 }
6207
6208 #[test]
6209 fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
6210 // Nothing to render, so the caret floor is the end of the hidden
6211 // frontmatter — not 0, which is *before* the opening `---` and made the
6212 // first keystroke in a fresh metadata-only note land ahead of it. And
6213 // the frontmatter's own newlines are not trailing blank lines: they used
6214 // to open phantom rows at offsets 1..4, inside the metadata.
6215 let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
6216 let m = map(src);
6217 assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
6218 assert!(
6219 m.rows.is_empty(),
6220 "frontmatter must render no rows: {:?}",
6221 rendered(&m)
6222 );
6223 assert!(
6224 m.stops.is_empty(),
6225 "no stop may sit inside the metadata: {:?}",
6226 m.stops
6227 );
6228 }
6229
6230 #[test]
6231 fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
6232 // Two blank lines after the frontmatter are the author's empty paragraph
6233 // and still render, counted from the metadata's end rather than from 0.
6234 let fm = "---\ntitle: n\n---\n";
6235 let m = map(&format!("{fm}\n\n"));
6236 assert_eq!(m.content_start, fm.len());
6237 assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
6238 assert!(
6239 m.rows.iter().all(|r| r.end_src > fm.len()),
6240 "rows must sit past the frontmatter"
6241 );
6242 }
6243
6244 #[test]
6245 fn a_document_without_frontmatter_has_a_zero_floor() {
6246 let m = map("# leaf\n\nbody\n");
6247 assert_eq!(m.content_start, 0);
6248 }
6249
6250 #[test]
6251 fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
6252 // Markdown/Djot drop the trailing space in `hello ` from the `str` node,
6253 // so without help the row would end at `hello` and the caret couldn't be
6254 // drawn past column 5 — typing a space at a line's end wouldn't move it
6255 // on screen until the next visible character reparsed the space into an
6256 // interior node. The builder recovers it from the block's span/content_span
6257 // gap and emits it as a real, caret-stoppable glyph.
6258 let m = map("hello \n");
6259 assert_eq!(
6260 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6261 "hello "
6262 );
6263 assert_eq!(
6264 m.rows[0].end_src, 6,
6265 "the row now ends past the trailing space"
6266 );
6267 // The caret can rest both on and past the space.
6268 assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
6269 assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
6270 // Two trailing spaces, both stops.
6271 let m = map("hello \n");
6272 assert_eq!(
6273 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6274 "hello "
6275 );
6276 assert_eq!(m.pos_of_offset(7), (0, 7));
6277 }
6278
6279 #[test]
6280 fn a_headings_trailing_space_is_a_caret_stop_too() {
6281 // The hidden `# ` marker means `# hi ` renders as `hi ` in three columns;
6282 // the caret past the trailing space lands on the third.
6283 let m = map("# hi \n");
6284 assert_eq!(
6285 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6286 "hi "
6287 );
6288 assert_eq!(m.pos_of_offset(5), (0, 3));
6289 }
6290
6291 #[test]
6292 fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
6293 // A cell's own `span` is the whole row, so the trailing-whitespace
6294 // recovery must not run for cells or it would swallow the `│` delimiters
6295 // and neighbours between the cell text and the row's end. The grid stays
6296 // exactly as before.
6297 let text = rendered(&map(TABLE));
6298 assert!(
6299 text.contains("│ Pear │ 3 │"),
6300 "cell padding disturbed:\n{text}"
6301 );
6302 }
6303
6304 #[test]
6305 fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
6306 // A drag into the empty space under a short document used to resolve to
6307 // offset 0 — the wrong direction, and not even a caret stop when the
6308 // document opens on hidden frontmatter (its `content_start` floor is not
6309 // a stop), which crashed the caret invariant. It now lands on the last
6310 // stop: the end of the document, where dragging downward should reach.
6311 let fm = "---\ntitle: n\n---\n";
6312 let m = map(&format!("{fm}# Hi\n\nbody\n"));
6313 let below = m.num_rows() + 5;
6314 let off = m.offset_of_pos(below, 0);
6315 assert!(
6316 m.is_stop(off),
6317 "offset {off} from a below-content click is not a stop"
6318 );
6319 assert_eq!(
6320 off,
6321 m.stops.last().copied().unwrap(),
6322 "should be the document's last stop"
6323 );
6324 assert!(
6325 off > fm.len(),
6326 "must not fall onto the hidden frontmatter floor"
6327 );
6328 }
6329
6330 #[test]
6331 fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
6332 // The invariant the caret motion asserts: whatever cell a click names,
6333 // the offset it resolves to is one the caret can actually rest at.
6334 for src in [
6335 "hello \n",
6336 "# A heading here \n\nbody text goes on \n",
6337 "---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
6338 ] {
6339 let m = map(src);
6340 for row in 0..m.num_rows() + 3 {
6341 for col in 0..30 {
6342 let off = m.offset_of_pos(row, col);
6343 assert!(
6344 m.is_stop(off),
6345 "row {row} col {col} → {off} is not a stop in {src:?}"
6346 );
6347 }
6348 }
6349 }
6350 }
6351
6352 /// `| Name | Qty |` with Name left-aligned and Qty right-aligned.
6353 const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
6354
6355 #[test]
6356 fn a_table_renders_as_an_aligned_grid() {
6357 let text = rendered(&map(TABLE));
6358 assert_eq!(
6359 text,
6360 "┌──────┬─────┐\n\
6361 │ Name │ Qty │\n\
6362 ├──────┼─────┤\n\
6363 │ Pear │ 3 │\n\
6364 │ Fig │ 12 │\n\
6365 └──────┴─────┘",
6366 "got:\n{text}"
6367 );
6368 }
6369
6370 #[test]
6371 fn table_columns_honour_their_alignment() {
6372 // Centre and default(left) come straight from twig's cell.alignment —
6373 // the delimiter row it's spelled in is consumed and has no node.
6374 let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
6375 assert!(text.contains("│ x │ y │"), "centred column: {text:?}");
6376 }
6377
6378 #[test]
6379 fn table_borders_are_decoration_the_caret_never_lands_on() {
6380 let m = map(TABLE);
6381 // The top and header rules are whole decoration rows.
6382 for r in [0, 2] {
6383 assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
6384 assert!(
6385 !m.rows[r].glyphs.iter().any(|g| g.stop),
6386 "row {r} has a stop"
6387 );
6388 }
6389 // The bottom border is the exception: no glyph of it is a stop, but
6390 // its end is the table's trailing caret home — the one place the caret
6391 // can stand past the last cell.
6392 let bottom = &m.rows[5];
6393 assert!(
6394 !bottom.decoration,
6395 "the bottom border holds the trailing stop"
6396 );
6397 assert!(
6398 !bottom.glyphs.iter().any(|g| g.stop),
6399 "the bottom border's glyphs are not stops"
6400 );
6401 assert!(m.is_stop(bottom.end_src), "the trailing stop is a stop");
6402 assert!(m.table_end_stop(bottom.end_src));
6403 assert_eq!(
6404 bottom.end_src,
6405 TABLE.trim_end_matches('\n').len(),
6406 "the trailing stop is the table's own end, before its newline"
6407 );
6408 assert!(
6409 !m.table_end_stop(TABLE.rfind("12").unwrap() + 2),
6410 "a cell's end is not the trailing stop"
6411 );
6412 // A content row's `│` and padding are decoration; only the cell text
6413 // and each cell's one end-stop are stops.
6414 let header = &m.rows[1];
6415 assert!(!header.decoration);
6416 for g in &header.glyphs {
6417 if g.ch == '│' {
6418 assert!(!g.stop, "a border is not a caret stop");
6419 }
6420 }
6421 let stops: String = header
6422 .glyphs
6423 .iter()
6424 .filter(|g| g.stop)
6425 .map(|g| g.ch)
6426 .collect();
6427 assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
6428 }
6429
6430 #[test]
6431 fn a_cell_maps_to_its_own_source_text() {
6432 let m = map(TABLE);
6433 // "Pear" starts at byte 32 in TABLE; the caret there draws on the 'P'.
6434 let pear = TABLE.find("Pear").unwrap();
6435 let (r, c) = m.pos_of_offset(pear);
6436 assert_eq!(m.rows[r].glyphs[c].ch, 'P');
6437 assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
6438 }
6439
6440 #[test]
6441 fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
6442 // Columns wider than the surface used to run off the right edge, where
6443 // nothing could reach them. They're cut to the budget instead, and the
6444 // text wraps down inside the column — the header rule stays put, and
6445 // an alignment holds on every line of a wrapped cell, not just the first.
6446 let src = "| Ingredient | Notes |\n|---|---:|\n\
6447 | flour milled coarse | sift it twice |\n| salt | a pinch |\n";
6448 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6449 let m = build_t(&ed.nodes().unwrap(), src, Some(30));
6450 let text = rendered(&m);
6451 assert_eq!(
6452 text,
6453 "┌──────────────┬─────────────┐\n\
6454 │ Ingredient │ Notes │\n\
6455 ├──────────────┼─────────────┤\n\
6456 │ flour milled │ sift it │\n\
6457 │ coarse │ twice │\n\
6458 │ salt │ a pinch │\n\
6459 └──────────────┴─────────────┘",
6460 "got:\n{text}"
6461 );
6462 for (r, row) in m.rows.iter().enumerate() {
6463 assert!(
6464 row.glyphs.len() <= 30,
6465 "row {r} overflows: {}",
6466 row.glyphs.len()
6467 );
6468 }
6469 }
6470
6471 #[test]
6472 fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
6473 // A paragraph lets an overlong word trail off the end of the line; a
6474 // table column can't — a glyph past the border lands on the border.
6475 let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
6476 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6477 let m = build_t(&ed.nodes().unwrap(), src, Some(20));
6478 for (r, row) in m.rows.iter().enumerate() {
6479 assert!(
6480 row.glyphs.len() <= 20,
6481 "row {r} overflows: {}",
6482 row.glyphs.len()
6483 );
6484 }
6485 // Broken across lines, but whole: every letter is still drawn, at its
6486 // own source byte, where the caret can reach it.
6487 let word = "antidisestablishmentarianism";
6488 let at = src.find(word).unwrap();
6489 for (i, ch) in word.char_indices() {
6490 assert!(
6491 m.rows
6492 .iter()
6493 .flat_map(|r| r.glyphs.iter())
6494 .any(|g| g.stop && g.src == at + i && g.ch == ch),
6495 "{ch:?} at {} was lost to the break",
6496 at + i
6497 );
6498 }
6499 }
6500
6501 #[test]
6502 fn a_code_block_maps_each_line_to_its_own_source_text() {
6503 // Every glyph used to point at the block's start, which made the whole
6504 // block one offset — visible, but impossible to put a caret inside.
6505 let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
6506 let m = map(src);
6507 for row in &m.rows {
6508 for g in row.glyphs.iter().filter(|g| g.stop) {
6509 assert_eq!(
6510 src[g.src..].chars().next(),
6511 Some(g.ch),
6512 "glyph {:?} at {} isn't the source byte it claims",
6513 g.ch,
6514 g.src
6515 );
6516 }
6517 }
6518 }
6519
6520 #[test]
6521 fn an_indented_code_block_maps_past_its_stripped_indent() {
6522 // twig strips the four-space indent, so `text` isn't a source slice and
6523 // the lines have to be re-found. Offsets land on the code, not the indent.
6524 let src = " indented\n code\n";
6525 let m = map(src);
6526 let stops: Vec<(char, usize)> = m
6527 .rows
6528 .iter()
6529 .flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
6530 .collect();
6531 assert_eq!(
6532 stops[0],
6533 ('i', 4),
6534 "first line should start past the indent"
6535 );
6536 assert!(
6537 stops.contains(&('c', 17)),
6538 "second line misplaced: {stops:?}"
6539 );
6540 }
6541
6542 #[test]
6543 fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
6544 // The one case that defeats a forward search: the opening fence
6545 // ```` ```rust ```` ends with the same text as the code under it.
6546 let src = "```rust\nrust\n```\n";
6547 let m = map(src);
6548 let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
6549 assert_eq!(first.src, 8, "matched the info string, not the code");
6550 }
6551
6552 #[test]
6553 fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
6554 // The old `▏ ` gutter is gone: a code row is the block prefix (none, at
6555 // the top level) plus the code text, and the whole run is named in
6556 // `code_blocks` so a frontend can box it.
6557 let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
6558 let m = map(src);
6559 assert_eq!(m.code_blocks.len(), 1, "one code block");
6560 let span = m.code_blocks[0].rows_span.clone();
6561 let rows: Vec<String> = m.rows[span.clone()]
6562 .iter()
6563 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6564 .collect();
6565 assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
6566 assert!(!rendered(&m).contains('▏'), "gutter still drawn");
6567 assert!(
6568 m.rows[span].iter().all(|r| r.code),
6569 "every row in the span is flagged code"
6570 );
6571 }
6572
6573 #[test]
6574 fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
6575 // `trim_end_matches('\n')` cut the block's terminator *and* the newline
6576 // that spells a trailing empty line, so the row the Return had just made
6577 // never appeared and the caret on it fell through to the block below.
6578 // Every empty line is a row, wherever in the block it falls.
6579 let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
6580 let m = map(src);
6581 let span = m.code_blocks[0].rows_span.clone();
6582 let rows: Vec<String> = m.rows[span.clone()]
6583 .iter()
6584 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6585 .collect();
6586 assert_eq!(
6587 rows,
6588 vec!["alpha".to_string(), "beta".to_string(), String::new()],
6589 "the empty last line gets a row"
6590 );
6591 assert!(
6592 m.rows[span.clone()].iter().all(|r| r.code),
6593 "the empty row is flagged code like the rest of the block"
6594 );
6595 // And it is the *source's* empty line, not a coarse fallback to the
6596 // block start: the offset the caret resolves to is the one Return made.
6597 let empty = span.end - 1;
6598 assert_eq!(
6599 m.rows[empty].end_src,
6600 src.find("beta\n\n").unwrap() + "beta\n".len(),
6601 "the empty row maps to the line the Return opened"
6602 );
6603
6604 // Nothing is invented where there is no empty line, and a second one is
6605 // a second row.
6606 assert_eq!(
6607 map("```\nalpha\nbeta\n```\n").code_blocks[0]
6608 .rows_span
6609 .len(),
6610 2,
6611 "a block that ends at its last code line keeps two rows"
6612 );
6613 assert_eq!(
6614 map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
6615 3,
6616 "two trailing empty lines are two rows"
6617 );
6618 }
6619
6620 #[test]
6621 fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
6622 // diaryx's `:::vis{.public .family}` visibility block, and any other
6623 // `:::name{.class}` fenced div — core is agnostic of `name`.
6624 let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
6625 let m = map_directives(src);
6626
6627 let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
6628 assert!(!content_rows.is_empty(), "some row is flagged directive");
6629
6630 let after_rows: Vec<usize> = (0..m.rows.len())
6631 .filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
6632 .collect();
6633 assert!(
6634 after_rows.iter().all(|&i| !m.rows[i].directive),
6635 "content outside the fence isn't tinted"
6636 );
6637
6638 let labels: Vec<&str> = content_rows
6639 .iter()
6640 .filter_map(|&i| m.rows[i].directive_label.as_deref())
6641 .collect();
6642 assert_eq!(
6643 labels,
6644 vec!["public family"],
6645 "only the first row carries the label"
6646 );
6647
6648 assert_eq!(
6649 rendered(&m)
6650 .lines()
6651 .filter(|l| !l.is_empty())
6652 .collect::<Vec<_>>(),
6653 vec!["hello", "world", "after"],
6654 "fence markers don't leak into the rendered text"
6655 );
6656 }
6657
6658 #[test]
6659 fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
6660 // diaryx_core::visibility's own `:::vis{public family}` — no leading
6661 // dots — is what apps/web's directive serializer and the native
6662 // publish-time filter both actually write today, distinct from twig's
6663 // `.class` convention. Both must label the same way so every existing
6664 // diaryx `:::vis{...}` block reads, not just newly dot-authored ones.
6665 let src = ":::vis{public family}\nhello\n:::\n";
6666 let m = map_directives(src);
6667 let label = m.rows.iter().find_map(|r| r.directive_label.clone());
6668 assert_eq!(label.as_deref(), Some("public family"));
6669 }
6670
6671 #[test]
6672 fn a_text_directive_keeps_its_paragraph_visible() {
6673 // Regression: an inline `:name[label]{…}` used to make its paragraph
6674 // fail the "all children inline" test, so the whole line was walked as
6675 // a container of blocks and rendered as empty rows with NO caret stops —
6676 // the text vanished from the editor and the caret couldn't enter it.
6677 // diaryx's inline `:vis[…]` is exactly this shape.
6678 let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
6679 let m = map_directives(src);
6680 assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
6681 // Every character of the line is a caret home, markup excluded — the
6682 // label reads as ordinary text, the way a link's does.
6683 let stops: usize = m
6684 .rows
6685 .iter()
6686 .map(|r| r.glyphs.iter().filter(|g| g.stop).count())
6687 .sum();
6688 assert_eq!(stops, "Text with HTML inline.".chars().count());
6689 // It is inline, so it is not the container form's tinted panel.
6690 assert!(m.rows.iter().all(|r| !r.directive));
6691 }
6692
6693 #[test]
6694 fn a_text_directives_label_maps_to_its_true_source_bytes() {
6695 // Regression (needs twig-doc >= 2.5.0): twig parses a `[label]` as a
6696 // detached slice, and until it rebased the enclosing scan's segments
6697 // onto it every node inside the label reported a span of `(0,0)`. Read
6698 // by anything that trusts a span that means "byte 0", so the label's
6699 // glyphs mapped to the START OF THE DOCUMENT — a click on the label put
6700 // the caret at the top of the file, its stops collided with the real
6701 // first line's, and an edit there landed on the wrong bytes entirely.
6702 //
6703 // The sibling test `a_text_directive_keeps_its_paragraph_visible` only
6704 // counts stops, which is exactly why this went unnoticed: the right
6705 // NUMBER of stops at completely wrong offsets.
6706 let src = "x :abbr[HTML]{title=\"y\"} z\n";
6707 let m = map_directives(src);
6708 let stops: Vec<(char, usize)> = m
6709 .rows
6710 .iter()
6711 .flat_map(|r| &r.glyphs)
6712 .filter(|g| g.stop)
6713 .map(|g| (g.ch, g.src))
6714 .collect();
6715 // `HTML` sits at 8..12. The name, brackets and `{…}` are hidden markup
6716 // the caret steps over, so the line's stops run 0, 1, 8..12, then 24.
6717 assert_eq!(
6718 stops,
6719 [
6720 ('x', 0),
6721 (' ', 1),
6722 ('H', 8),
6723 ('T', 9),
6724 ('M', 10),
6725 ('L', 11),
6726 (' ', 24),
6727 ('z', 25)
6728 ]
6729 );
6730 }
6731
6732 #[test]
6733 fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
6734 // The `every_glyph_points_at_its_source_byte` invariant, extended over
6735 // directive labels now that their offsets are real. Nested markup is
6736 // included: its delimiters are hidden, so the visible glyphs must skip
6737 // them and still name their own bytes.
6738 let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
6739 let m = map_directives(src);
6740 for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
6741 let at = src[g.src..].chars().next();
6742 assert_eq!(
6743 at,
6744 Some(g.ch),
6745 "glyph {:?} claims byte {}, which is {at:?}",
6746 g.ch,
6747 g.src
6748 );
6749 }
6750 assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
6751 }
6752
6753 #[test]
6754 fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
6755 let src = "x :abbr[a *b* c] y\n";
6756 let m = map_directives(src);
6757 let b = m
6758 .rows
6759 .iter()
6760 .flat_map(|r| &r.glyphs)
6761 .find(|g| g.ch == 'b')
6762 .expect("the emphasised char");
6763 assert!(b.style.italic, "the label's *b* lost its emphasis");
6764 assert_eq!(b.src, 11, "the label's *b* lost its source byte");
6765 }
6766
6767 #[test]
6768 fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
6769 // Regression: twig matches a colon followed by any letter-led word, so
6770 // ordinary prose is full of "text directives" nobody meant to write.
6771 // With no `[label]` there are no children, and the arm recursed into
6772 // them — rendering *nothing*. The word vanished from the document with
6773 // no caret stop left behind, so it could not even be deleted.
6774 for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
6775 let m = map_directives(src);
6776 assert_eq!(
6777 rendered(&m).trim_end(),
6778 src.trim_end(),
6779 "prose was eaten: {src:?}"
6780 );
6781 }
6782 }
6783
6784 #[test]
6785 fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
6786 let src = "a :word b\n";
6787 let m = map_directives(src);
6788 // Nothing here is markup, so nothing is hidden: each byte maps to
6789 // itself and can be stood on, which is what makes the colon deletable.
6790 let stops: Vec<(char, usize)> = m
6791 .rows
6792 .iter()
6793 .flat_map(|r| &r.glyphs)
6794 .filter(|g| g.stop)
6795 .map(|g| (g.ch, g.src))
6796 .collect();
6797 assert_eq!(
6798 stops,
6799 "a :word b"
6800 .chars()
6801 .enumerate()
6802 .map(|(i, c)| (c, i))
6803 .collect::<Vec<_>>()
6804 );
6805 }
6806
6807 #[test]
6808 fn an_attribute_bearing_text_directive_draws_a_chip() {
6809 // `{…}` is deliberate in a way a bare colon is not — diaryx writes
6810 // `:vis{.family}` inline — so this one reads as an embed, on the same
6811 // `⧉ label` recipe the leaf form's placeholder row uses.
6812 // Both attribute conventions label it: twig's dot-prefixed classes and
6813 // the bare pandoc-style words diaryx also writes.
6814 for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
6815 let m = map_directives(src);
6816 assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
6817 }
6818 // A `key=value` attr is configuration, not a name, so it adds nothing.
6819 let m = map_directives("a :foo{title=\"x\"} b\n");
6820 assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
6821 }
6822
6823 #[test]
6824 fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
6825 let src = "a :vis{.family} b\n";
6826 let m = map_directives(src);
6827 let stops: Vec<usize> = m
6828 .rows
6829 .iter()
6830 .flat_map(|r| &r.glyphs)
6831 .filter(|g| g.stop)
6832 .map(|g| g.src)
6833 .collect();
6834 // The chip contributes exactly one stop, at the directive's start (2),
6835 // so the caret steps over it whole instead of walking hidden markup a
6836 // byte at a time. `{.family}`'s bytes (3..15) are never stood on.
6837 assert_eq!(stops, [0, 1, 2, 15, 16]);
6838 }
6839
6840 #[test]
6841 fn a_paragraph_holding_only_a_chip_is_still_navigable() {
6842 // With no stop of its own the row would be unreachable — the caret
6843 // could never be put on the line to edit or delete the directive.
6844 let m = map_directives(":vis{.family}\n");
6845 assert!(
6846 m.row_is_navigable(0),
6847 "a chip-only paragraph has no caret home"
6848 );
6849 assert_eq!(
6850 m.offset_of_pos(0, 0),
6851 0,
6852 "its caret home isn't the directive's start"
6853 );
6854 }
6855
6856 #[test]
6857 fn a_ratio_or_a_clock_time_is_never_a_directive() {
6858 // twig requires a letter after the colon, so these stay prose — the
6859 // verbatim arm must not be reached for them at all.
6860 let src = "ratio 3:4 and 10:30\n";
6861 assert_eq!(
6862 rendered(&map_directives(src)).trim_end(),
6863 "ratio 3:4 and 10:30"
6864 );
6865 }
6866
6867 #[test]
6868 fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
6869 // `::name{…}` is a standalone block with no body — an embed, a table of
6870 // contents. It used to emit no rows at all: invisible, no caret home,
6871 // vertical motion crossing a void. Now it draws the image recipe's
6872 // placeholder and publishes what the host app needs to paint the real
6873 // thing.
6874 let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
6875 let m = map_directives(src);
6876
6877 let row = m
6878 .rows
6879 .iter()
6880 .position(|r| r.leaf_directive.is_some())
6881 .expect("a placeholder row");
6882 assert_eq!(
6883 m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
6884 "⧉ embed"
6885 );
6886 assert!(
6887 m.rows[row].glyphs.iter().any(|g| g.stop),
6888 "the caret can land on it"
6889 );
6890 assert!(
6891 m.rows[row].directive,
6892 "a frontend frames it like the container form"
6893 );
6894
6895 assert_eq!(m.directives.len(), 1);
6896 let info = &m.directives[0];
6897 assert_eq!(info.name, "embed");
6898 assert_eq!(info.rows_span, row..row + 1);
6899 assert_eq!(info.attr("src"), Some("demo.html"));
6900 assert_eq!(info.attr("height"), Some("400"));
6901 assert_eq!(info.attr("nope"), None);
6902 // The prose around it is untouched.
6903 assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
6904 }
6905
6906 #[test]
6907 fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
6908 // A `[label]` names the placeholder (the way an image's alt does), and a
6909 // quoted directive keeps the quote's gutter — it is a block like any
6910 // other, not a special case that escapes its container.
6911 let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
6912 assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
6913 assert_eq!(m.directives[0].label, "Audience demo");
6914
6915 let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
6916 assert_eq!(rendered("ed).trim_end(), "│ ⧉ embed");
6917 assert_eq!(quoted.directives[0].name, "embed");
6918 }
6919
6920 #[test]
6921 fn a_container_directive_is_still_a_panel_not_a_placeholder() {
6922 // The three forms must not bleed into each other: only the leaf form is
6923 // a placeholder, and only the container form tints the blocks it wraps.
6924 let m = map_directives(":::note{.warning}\nBody\n:::\n");
6925 assert!(
6926 m.directives.is_empty(),
6927 "a container publishes no placeholder"
6928 );
6929 assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
6930 assert_eq!(rendered(&m).trim_end(), "Body");
6931 assert!(
6932 m.rows
6933 .iter()
6934 .any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
6935 );
6936 }
6937
6938 /// A production-path build with both extensions on — the only way to put a
6939 /// promoted HTML element and a directive in one document, which is what the
6940 /// `container` kind made necessary to tell apart. Returns the whole `Doc`
6941 /// because [`VisualMap`] is not `Clone`; read `doc.vmap`.
6942 fn doc_built(src: &str) -> crate::Doc {
6943 let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
6944 doc.build_visual(80);
6945 doc
6946 }
6947
6948 /// Every `container` node in `src`, parsed the way production does (both
6949 /// extensions on), paired with what [`container_is_directive`] makes of it.
6950 fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
6951 let mut ed = Editor::new_ext(
6952 src.as_bytes(),
6953 Format::Markdown,
6954 twig::MarkdownExtensions {
6955 directives: true,
6956 html_elements: true,
6957 ..Default::default()
6958 },
6959 )
6960 .unwrap();
6961 ed.nodes()
6962 .unwrap()
6963 .iter()
6964 .filter(|n| n.kind == Kind::Container)
6965 .map(|n| {
6966 (
6967 n.name.clone().unwrap_or_default(),
6968 container_is_directive(n),
6969 n.directive_form,
6970 )
6971 })
6972 .collect()
6973 }
6974
6975 #[test]
6976 fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
6977 // twig 2.8 folded `div`/`span`/`directive`/`element` into one `container`
6978 // kind. `directive_form` reads as though it separates them and does not:
6979 // a block-level `<div>` reports `Some(DirectiveForm::Container)` exactly
6980 // as a `:::note` does. Trusting it would draw directive chrome — a tinted
6981 // panel, a `.class` audience label — on every pasted Slack/Docs div.
6982 for (src, name, want) in [
6983 (":::note{.a}\nbody\n:::\n", "note", true),
6984 ("::embed{src=x}\n", "embed", true),
6985 ("a :vis[hi]{.b} b\n", "vis", true),
6986 ("<div class=\"x\">\nhi\n</div>\n", "div", false),
6987 ("<video src=\"v.mp4\" controls></video>\n", "video", false),
6988 ("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
6989 ("<figure>\n\nhi\n\n</figure>\n", "figure", false),
6990 // The `:` in an attribute must not read as a directive opener: the
6991 // `<` of the tag comes first, and first one wins.
6992 (
6993 "<video src=\"http://x.test/v.mp4\" controls></video>\n",
6994 "video",
6995 false,
6996 ),
6997 (
6998 "<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
6999 "source",
7000 false,
7001 ),
7002 ] {
7003 let found = containers(src);
7004 let hit = found.iter().find(|(n, ..)| n == name);
7005 let Some((_, is_directive, form)) = hit else {
7006 panic!("no `{name}` container in {src:?} — found {found:?}");
7007 };
7008 assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
7009 }
7010
7011 // And the reason this can't just read the field: for the one collision
7012 // that matters, the field says the same thing for both.
7013 let div = containers("<div class=\"x\">\nhi\n</div>\n");
7014 let note = containers(":::note{.a}\nbody\n:::\n");
7015 assert_eq!(
7016 div[0].2, note[0].2,
7017 "if these ever differ, `directive_form` became usable and this rule can go"
7018 );
7019 }
7020
7021 #[test]
7022 fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
7023 // A container's span opens with its *block prefix*, not its own markup —
7024 // `> ::embed{…}` starts at the `>`. Reading only the first byte to tell a
7025 // directive from an element (both `container` since 2.8) therefore misses
7026 // every nested one, and the placeholder silently renders as nothing.
7027 for (src, ctx) in [
7028 ("> ::embed{src=\"x\"}\n", "quoted"),
7029 ("- ::embed{src=\"x\"}\n", "listed"),
7030 (">> ::embed{src=\"x\"}\n", "twice quoted"),
7031 ] {
7032 let m = map_directives(src);
7033 assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
7034 assert_eq!(m.directives[0].name, "embed", "{ctx}");
7035 }
7036 }
7037
7038 #[test]
7039 fn a_video_is_still_media_and_not_a_directive() {
7040 // The other side of the same coin: `<video>` is a `container` too, and
7041 // must reach `block_media` rather than the directive arms.
7042 let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
7043 assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
7044 assert!(
7045 doc.vmap.rows.iter().all(|r| !r.directive),
7046 "the video drew directive chrome"
7047 );
7048 }
7049
7050 #[test]
7051 fn a_directive_needs_the_extension_flag() {
7052 // `map` (twig's default extensions) leaves `directives` off — the fence
7053 // renders as literal paragraph text, same as any other unrecognized
7054 // punctuation, never corrupting or panicking.
7055 let src = ":::vis{.public}\nhello\n:::\n";
7056 let m = map(src);
7057 assert!(m.rows.iter().all(|r| !r.directive));
7058 assert!(rendered(&m).contains(":::vis{.public}"));
7059 }
7060
7061 #[test]
7062 fn a_footnote_reference_keeps_its_paragraph_visible() {
7063 // Regression: `footnote_reference` was in neither `is_inline_kind` nor
7064 // the inline walker, so a paragraph carrying one failed the "all children
7065 // inline" test, was walked as a container of blocks, and rendered as
7066 // empty rows with no caret stop anywhere — the whole line vanished.
7067 let src = "A claim[^1] and more.\n";
7068 let m = map(src);
7069 assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
7070 // The `^` is spelling, not text: hidden the way a link's `](dest)` is.
7071 assert!(!rendered(&m).contains('^'));
7072 }
7073
7074 #[test]
7075 fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
7076 // What makes `[1]` read as a reference rather than as bracketed text.
7077 // The brackets ride with the label: the chip is one raised mark.
7078 let m = map("A claim[^1] and more.\n");
7079 assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
7080 assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
7081 assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
7082 assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
7083 }
7084
7085 #[test]
7086 fn a_footnote_reference_keeps_the_link_role_it_had() {
7087 // The raised baseline is added to the role, not swapped for it: every
7088 // frontend already paints `Role::Link`, and a reference is one.
7089 let m = map("A claim[^1].\n");
7090 let label = m
7091 .rows
7092 .iter()
7093 .flat_map(|r| &r.glyphs)
7094 .find(|g| g.ch == '1')
7095 .unwrap();
7096 assert_eq!(label.style.role, Role::Link);
7097 assert_eq!(label.style.baseline, Baseline::Super);
7098 }
7099
7100 /// The [`Role`] of the first glyph spelling `ch` — how a test reads one
7101 /// run's styling off a map without caring which row it landed on.
7102 fn role_of(m: &VisualMap, ch: char) -> Role {
7103 m.rows
7104 .iter()
7105 .flat_map(|r| r.glyphs.iter())
7106 .find(|g| g.ch == ch)
7107 .unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
7108 .style
7109 .role
7110 }
7111
7112 #[test]
7113 fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
7114 // twig 3.3's `highlight`/`highlight_colors`, which `parse_extensions`
7115 // turns on for every leaf document: `==text==` is a `mark` in Markdown
7116 // and not the literal `==` it used to be, and `==🔴 text==` is one
7117 // carrying a colour.
7118 //
7119 // `doc_built` rather than `map`, deliberately — the extensions are
7120 // leaf's choice, not twig's default, so a test that parsed bare
7121 // Markdown here would be testing a document leaf never builds.
7122 let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
7123 assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
7124 assert_eq!(
7125 role_of(&doc.vmap, 'r'),
7126 Role::Mark(Some(MarkColor::Red)),
7127 "the `data-color` twig stripped the emoji into"
7128 );
7129 assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
7130 }
7131
7132 #[test]
7133 fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
7134 // The colour is *spelling*: twig strips the emoji out of the mark's
7135 // content, so the reader sees the words and the wash, never the circle.
7136 // Drawing it would put a character in the rendered text that the author
7137 // wrote as syntax — the same mistake as drawing an emphasis's `*`.
7138 let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
7139 let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
7140 assert_eq!(drawn, "Plain yes and red ok");
7141 }
7142
7143 #[test]
7144 fn a_superscript_and_a_subscript_sit_off_the_baseline() {
7145 // Regression: both rendered flat, so the toolbar's superscript button
7146 // produced markup that looked exactly like the text around it.
7147 let m = map_djot("H~2~O and x^2^\n");
7148 assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
7149 assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
7150 assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
7151 }
7152
7153 #[test]
7154 fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
7155 // Why this is a `Baseline` and not a `Role`: raising a glyph says where
7156 // it sits, and must not cost it what it already was.
7157 let m = map_djot("# Heading x^2^\n");
7158 let two = m
7159 .rows
7160 .iter()
7161 .flat_map(|r| &r.glyphs)
7162 .find(|g| g.ch == '2')
7163 .unwrap();
7164 assert_eq!(two.style.baseline, Baseline::Super);
7165 assert_eq!(two.style.role, Role::Heading(1), "still heading text");
7166 }
7167
7168 #[test]
7169 fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
7170 let src = "see[^note] here\n";
7171 let m = map(src);
7172 // `[^note]` spans 3..10, its label `note` 5..9. The caret walks the
7173 // label; the brackets are drawn but never stood on, as a table's are,
7174 // and the `[^`/`]` bytes are stepped over like any hidden delimiter.
7175 let stops: Vec<usize> = m
7176 .rows
7177 .iter()
7178 .flat_map(|r| &r.glyphs)
7179 .filter(|g| g.stop)
7180 .map(|g| g.src)
7181 .collect();
7182 for off in 5..9 {
7183 assert!(
7184 stops.contains(&off),
7185 "label byte {off} isn't a caret stop: {stops:?}"
7186 );
7187 }
7188 for off in [3usize, 4, 9] {
7189 assert!(
7190 !stops.contains(&off),
7191 "delimiter byte {off} is a caret stop: {stops:?}"
7192 );
7193 }
7194 }
7195
7196 #[test]
7197 fn a_task_item_draws_its_box_where_the_bullet_would_be() {
7198 // Regression: the `[ ] ` is markup twig consumes — the item's paragraph
7199 // content starts past it — so a task item used to render as `• todo`,
7200 // identical to a plain bullet and with no way to see it was ticked.
7201 let m = map("- [ ] todo\n- [x] done\n- plain\n");
7202 assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
7203
7204 // The tick rides the item's first row, for a GUI that paints its own box.
7205 let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
7206 assert_eq!(ticks, [Some(false), Some(true), None]);
7207 }
7208
7209 #[test]
7210 fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
7211 let m = map_at(
7212 "- [x] a much longer task that has to wrap somewhere\n",
7213 Some(20),
7214 );
7215 assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
7216 assert_eq!(m.rows[0].task, Some(true));
7217 assert!(
7218 m.rows[1..].iter().all(|r| r.task.is_none()),
7219 "only the first row"
7220 );
7221 // The continuation lines hang under the box, not under column zero.
7222 assert!(
7223 rendered(&m)
7224 .lines()
7225 .nth(1)
7226 .is_some_and(|l| l.starts_with(" "))
7227 );
7228 }
7229
7230 #[test]
7231 fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
7232 // `task_checked` finds the box past the list marker; a plain item whose
7233 // text merely contains a bracket has none, and must keep its bullet.
7234 let m = map("- see [1] below\n");
7235 assert_eq!(rendered(&m), "• see [1] below");
7236 assert_eq!(m.rows[0].task, None);
7237 }
7238
7239 #[test]
7240 fn a_footnote_definition_renders_where_it_was_written() {
7241 // Regression: twig parses `[^1]: …` as a root *beside* `doc` — not a
7242 // child of it — so the walk from `doc` never reached one and every byte
7243 // of the note's body rendered as nothing at all.
7244 let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
7245 let m = map(src);
7246 let text = rendered(&m);
7247 assert!(
7248 text.contains("The note body."),
7249 "the note body is invisible: {text:?}"
7250 );
7251 // In source order — between the paragraph that cites it and the one
7252 // after — not hoisted to the end, and marked to match its reference.
7253 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
7254 assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
7255 }
7256
7257 #[test]
7258 fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
7259 let src = "x[^a].\n\n[^a]: body\n";
7260 let m = map(src);
7261 // `body` sits at 14..18. Its glyphs must map there — a marker that ate
7262 // the offsets would put the caret in the wrong place on every click.
7263 let body: Vec<(char, usize)> = m
7264 .rows
7265 .iter()
7266 .flat_map(|r| &r.glyphs)
7267 .filter(|g| g.stop && g.src >= 14)
7268 .map(|g| (g.ch, g.src))
7269 .collect();
7270 assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
7271 }
7272
7273 #[test]
7274 fn an_empty_footnote_definition_still_shows_its_marker() {
7275 // The instant `[^1]: ` has been typed and nothing after it. `blocks`
7276 // renders no child, so without the explicit marker row the definition
7277 // wouldn't appear at all until something was typed into it.
7278 let src = "x[^1]\n\n[^1]:\n";
7279 let m = map(src);
7280 assert!(
7281 rendered(&m).contains("[1] "),
7282 "no marker row: {:?}",
7283 rendered(&m)
7284 );
7285 }
7286
7287 #[test]
7288 fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
7289 let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
7290 let m = map_at(src, Some(24));
7291 let text = rendered(&m);
7292 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
7293 // Continuation lines hang under the marker, as a list item's do — the
7294 // indent is the marker's own width, not a fixed one.
7295 assert_eq!(lines[1].trim_end(), "[src] one two three four");
7296 assert!(
7297 lines[2].starts_with(" "),
7298 "body doesn't hang: {:?}",
7299 lines[2]
7300 );
7301 assert_eq!(lines[2].trim(), "five six seven");
7302 }
7303
7304 #[test]
7305 fn a_code_block_leaves_exactly_one_blank_row_below_it() {
7306 // The closing fence line used to be miscounted as a blank separator,
7307 // opening a phantom second gap under the block. One block boundary is
7308 // one blank row, code block or not.
7309 let src = "para\n\n```\ncode\n```\n\nafter\n";
7310 let m = map(src);
7311 let code_end = m.code_blocks[0].rows_span.end;
7312 let after = m
7313 .rows
7314 .iter()
7315 .position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
7316 .unwrap();
7317 assert_eq!(
7318 after - code_end,
7319 1,
7320 "exactly one row between code and 'after'"
7321 );
7322 }
7323
7324 #[test]
7325 fn a_fenced_block_publishes_its_language_on_its_code_block() {
7326 // The info string becomes the block's label; a bare fence and an indented
7327 // block carry none.
7328 assert_eq!(
7329 map("```rust\nlet x = 1;\n```\n").code_blocks[0]
7330 .lang
7331 .as_deref(),
7332 Some("rust")
7333 );
7334 assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
7335 assert_eq!(map(" indented\n").code_blocks[0].lang, None);
7336 }
7337
7338 /// The token every glyph spelling `ch` carries, in row order — how a test
7339 /// reads a block's highlighting off the map.
7340 fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
7341 m.rows
7342 .iter()
7343 .flat_map(|r| r.glyphs.iter())
7344 .filter(|g| g.ch == ch)
7345 .map(|g| g.style.token)
7346 .collect()
7347 }
7348
7349 #[cfg(feature = "syntax")]
7350 #[test]
7351 fn a_fenced_block_in_a_known_language_carries_tokens() {
7352 // `let` is a keyword, the string literal a string, and the plain
7353 // identifier `x` nothing at all — it draws in the code colour. Every
7354 // glyph is still `Role::Code`: a token is beside the role, not instead.
7355 let m = map("```rust\nlet x = \"s\";\n```\n");
7356 assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
7357 assert_eq!(tokens_of(&m, 'x'), vec![None]);
7358 assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
7359 assert!(
7360 m.rows
7361 .iter()
7362 .filter(|r| r.code)
7363 .flat_map(|r| r.glyphs.iter())
7364 .all(|g| g.style.role == Role::Code),
7365 "a token replaced the code role"
7366 );
7367 }
7368
7369 #[cfg(feature = "syntax")]
7370 #[test]
7371 fn a_token_changes_nothing_about_where_a_glyph_is() {
7372 // The same block with and without a language it can be highlighted in
7373 // lays out identically: same rows, same offsets, same stops. Only the
7374 // token differs, so the caret walks a highlighted block as it walked an
7375 // unhighlighted one.
7376 let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
7377 let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
7378 assert_eq!(hl.rows.len(), plain.rows.len());
7379 for (a, b) in hl.rows.iter().zip(&plain.rows) {
7380 assert_eq!(a.end_src, b.end_src);
7381 assert_eq!(a.glyphs.len(), b.glyphs.len());
7382 for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
7383 assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
7384 assert_eq!(ga.style.token(None), gb.style);
7385 }
7386 }
7387 assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
7388 assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
7389 }
7390
7391 #[test]
7392 fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
7393 // A bare fence, an indented block, a fence in a language no grammar
7394 // covers, and inline code all draw as plain code — and so does a
7395 // `rust` fence when the `syntax` feature is off.
7396 for src in [
7397 "```\nlet x = 1;\n```\n",
7398 " let x = 1;\n",
7399 "```no-such-language\nlet x = 1;\n```\n",
7400 "a `let x` b\n",
7401 ] {
7402 assert!(
7403 tokens_of(&map(src), 'l').iter().all(Option::is_none),
7404 "{src:?} was highlighted"
7405 );
7406 }
7407 #[cfg(not(feature = "syntax"))]
7408 assert!(
7409 tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
7410 .iter()
7411 .all(Option::is_none)
7412 );
7413 }
7414
7415 #[test]
7416 fn inline_code_is_not_a_code_block() {
7417 // A `code` span inside prose is styled by role, not boxed: it's part of a
7418 // normal paragraph row, so it names no `code_blocks` entry.
7419 let m = map("a `snippet` b\n");
7420 assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
7421 assert!(
7422 m.rows.iter().all(|r| !r.code),
7423 "inline code flagged a code row"
7424 );
7425 }
7426
7427 #[test]
7428 fn caret_steps_over_hidden_delimiters() {
7429 // "a **bold** c": bytes 8,9 are the closing ** — no glyph. Moving right
7430 // from 'd' (src 7) lands on the space before 'c' (src 10), not inside **.
7431 let m = map("a **bold** c\n");
7432 let (r, c) = m.pos_of_offset(7);
7433 assert_eq!(m.offset_of_pos(r, c + 1), 10);
7434 }
7435
7436 // ── the structural view of a table ───────────────────────────────────────
7437
7438 #[test]
7439 fn a_table_is_published_structurally_beside_its_picture() {
7440 let m = map(TABLE);
7441 let t = &m.tables[0];
7442 let cell = |r: usize, c: usize| -> String {
7443 t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
7444 };
7445 assert_eq!(t.grid.len(), 3, "head + two body rows");
7446 assert_eq!(
7447 (cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
7448 ("Name".into(), "Qty".into(), "Pear".into(), "12".into())
7449 );
7450 assert_eq!(
7451 t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
7452 [true, false, false]
7453 );
7454 // The alignment the delimiter row spelled, carried per cell — the only
7455 // place it survives, since the parser consumes that row.
7456 assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
7457 assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
7458 }
7459
7460 #[test]
7461 fn a_block_media_is_published_structurally_beside_its_placeholder() {
7462 let m = map("intro\n\n\n\nend\n");
7463 assert_eq!(m.media.len(), 1, "one block image");
7464 let img = &m.media[0];
7465 assert_eq!(img.destination, "img/cat.png");
7466 assert_eq!(img.alt, "a cat");
7467 // The placeholder row named by `rows_span` carries the label a plain
7468 // surface paints and a capable frontend replaces.
7469 let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7470 assert_eq!(
7471 img.rows_span.end - img.rows_span.start,
7472 1,
7473 "one placeholder row"
7474 );
7475 assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
7476 // The row carries the mark `media_spans` derives the side-table from.
7477 assert!(m.rows[img.rows_span.start].media.is_some());
7478 }
7479
7480 #[test]
7481 fn an_image_without_alt_labels_itself_with_its_filename() {
7482 let m = map("\n");
7483 let row = &m.rows[m.media[0].rows_span.start];
7484 assert_eq!(
7485 row.glyphs.iter().map(|g| g.ch).collect::<String>(),
7486 "🖼 beach.jpg"
7487 );
7488 assert_eq!(m.media[0].alt, "");
7489 }
7490
7491 #[test]
7492 fn an_empty_cells_home_is_read_from_either_shape_of_span() {
7493 // A whole-row span: the cell's pipes are the `col`-th and next.
7494 let row = "| | |";
7495 assert_eq!(empty_cell_offset(row, 10, 0), 12);
7496 assert_eq!(empty_cell_offset(row, 10, 1), 15);
7497 // A cell's own span, opening pipe to closing pipe exclusive: the same
7498 // homes, each read from its own span.
7499 assert_eq!(empty_cell_offset("| ", 10, 0), 12);
7500 assert_eq!(empty_cell_offset("| ", 13, 1), 15);
7501 // Nothing to stand in: just inside the pipe, never past the span.
7502 assert_eq!(empty_cell_offset("|", 10, 0), 11);
7503 assert_eq!(empty_cell_offset("", 10, 1), 10);
7504 }
7505
7506 #[test]
7507 fn a_hidden_marks_content_end_is_a_caret_home_but_not_a_glyph_stop() {
7508 // `a **bold** b`: the `d` is at 7, the content ends at 8, the closing
7509 // `**` draws nothing, and the space after it is at 10. Two homes at one
7510 // spot on screen: 8 (inside the bold) and 10 (past it).
7511 let m = map("a **bold** b\n");
7512 assert!(
7513 !m.stops.contains(&8),
7514 "8 has no glyph, so it is no glyph stop"
7515 );
7516 assert_eq!(m.mark_ends, vec![8]);
7517 assert!(m.is_stop(8), "but the caret may rest there");
7518 assert_eq!(m.snap_to_stop(8), 8, "and is left there when placed there");
7519 // Left/Right take both homes; the character-pairing walk takes one.
7520 assert_eq!(m.caret_stop_after(7), Some(8));
7521 assert_eq!(m.caret_stop_after(8), Some(10));
7522 assert_eq!(m.caret_stop_before(10), Some(8));
7523 assert_eq!(m.caret_stop_before(8), Some(7));
7524 assert_eq!(m.stop_after(7), Some(10));
7525 assert_eq!(m.stop_before(10), Some(7));
7526 // Drawn where the next glyph is: after the `d`, not on it.
7527 assert_eq!(m.pos_of_offset(8), m.pos_of_offset(10));
7528 }
7529
7530 #[test]
7531 fn every_hidden_inline_mark_gives_its_content_end_a_home() {
7532 // One end per mark, whatever it is spelled with; nested marks closing
7533 // together share the outer's end and the inner's alike.
7534 assert_eq!(
7535 map("*em* `code` [link](u) ~~del~~\n").mark_ends,
7536 vec![3, 10, 17, 27]
7537 );
7538 assert_eq!(map("***both***\n").mark_ends, vec![7]);
7539 // A mark that closes at its row's end coincides with the row's own end
7540 // stop — one offset, in both tables.
7541 let m = map("**bold**\n");
7542 assert_eq!(m.mark_ends, vec![6]);
7543 assert!(m.stops.contains(&6));
7544 // Revealed, the delimiter is glyphs of its own and the end is an
7545 // ordinary glyph stop: nothing to add.
7546 let mut ed = Editor::new_str("a **bold** b\n", Format::Markdown).unwrap();
7547 let src = "a **bold** b\n";
7548 let revealed = build(
7549 &ed.nodes().unwrap(),
7550 src,
7551 Some(80),
7552 false,
7553 &HashMap::new(),
7554 Some(0..src.len()),
7555 );
7556 assert!(revealed.mark_ends.is_empty());
7557 assert!(revealed.stops.contains(&8));
7558 }
7559
7560 #[test]
7561 fn a_marks_content_end_is_a_home_inside_a_table_cell() {
7562 let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
7563 let m = map(src);
7564 let end = src.find("bold").unwrap() + 4; // 32, before the closing `**`
7565 assert_eq!(m.mark_ends, vec![end]);
7566 assert_eq!(m.snap_to_stop(end), end);
7567 // Drawn after the `d`, in this cell — where the cell's own end stop is.
7568 assert_eq!(m.pos_of_offset(end), m.pos_of_offset(end + 2));
7569 }
7570
7571 #[test]
7572 fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
7573 // `` on its own line: the caret can rest in front of the image
7574 // (its start) and just past it (the row end), and nowhere inside the
7575 // markup — the same coarse mapping a thematic break uses.
7576 let src = "\n";
7577 let m = map(src);
7578 let img = &m.rows[m.media[0].rows_span.start];
7579 let start = 0; // the image opens the document
7580 let end = "".len();
7581 // Every placeholder glyph maps to the image start and is a stop there.
7582 assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
7583 assert_eq!(img.end_src, end, "the row ends past the image");
7584 assert_eq!(m.stops.first(), Some(&start));
7585 assert!(m.stops.contains(&end), "a stop sits after the image");
7586 // Nothing inside the markup is a stop.
7587 assert!(!m.stops.iter().any(|&s| s > start && s < end));
7588 }
7589
7590 #[test]
7591 fn an_inline_image_amid_text_is_not_a_block_media() {
7592 // An image sharing its line with prose isn't block-level: it stays in the
7593 // inline path (rendered as its alt text), and publishes no MediaInfo.
7594 let m = map("see  here\n");
7595 assert!(m.media.is_empty(), "not a block image");
7596 assert!(
7597 rendered(&m).contains("a cat"),
7598 "alt text still renders inline"
7599 );
7600 }
7601
7602 /// The block images `Doc` publishes for `src`, driven through the real
7603 /// production build (`build_visual` → `build_cached`) with `html_elements`
7604 /// on — the path a `<picture>` actually travels. Not the raw `build` the
7605 /// other tests use: the editor's flat whole-arena snapshot tangles the links
7606 /// of inline-promoted HTML (phantom roots, dangling `parent`s), which only
7607 /// the per-block subtree walk `build_cached` does untangles.
7608 fn doc_media(src: &str) -> Vec<MediaInfo> {
7609 let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7610 doc.build_visual(80);
7611 doc.vmap.media.clone()
7612 }
7613
7614 #[test]
7615 fn a_video_block_is_media_with_its_src_poster_and_kind() {
7616 // The load-bearing assumption of video support: twig has no `video` node
7617 // kind, so `html_elements` promotion must land a `<video>` as a generic
7618 // `element` whose tag name and attributes survive onto `FlatNode` — the
7619 // same treatment `<picture>` gets. If that ever stops holding, this is
7620 // the test that says so.
7621 let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
7622 assert_eq!(m.len(), 1, "the video is one block media");
7623 assert_eq!(m[0].kind, MediaKind::Video);
7624 assert_eq!(m[0].destination, "clip.mp4");
7625 assert_eq!(m[0].poster, "still.png");
7626 }
7627
7628 #[test]
7629 fn a_single_line_video_is_a_block_too() {
7630 // The spelling everyone actually writes. It used to parse as a paragraph
7631 // of raw inline HTML — CommonMark opens a block on a complete tag only
7632 // when the line ends there, and its fixed tag list predates `<video>` —
7633 // so the tags never reached core as an element at all. twig 2.5.1 widened
7634 // that list under `html_elements`; this is the test that would catch the
7635 // pin sliding back.
7636 let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
7637 assert_eq!(m.len(), 1, "single-line <video> is a block");
7638 assert_eq!(m[0].kind, MediaKind::Video);
7639 assert_eq!(m[0].destination, "clip.mp4");
7640 }
7641
7642 #[test]
7643 fn a_single_line_picture_is_a_block_with_its_alternatives() {
7644 // `<picture>` had the identical gap and it went unnoticed because the
7645 // conventional spelling breaks the lines. Same twig fix covers it.
7646 let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
7647 <img src=\"l.svg\" alt=\"banner\"></picture>\n";
7648 let m = doc_media(src);
7649 assert_eq!(m.len(), 1);
7650 assert_eq!(m[0].kind, MediaKind::Image);
7651 assert_eq!(m[0].destination, "l.svg");
7652 assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
7653 }
7654
7655 #[test]
7656 fn an_audio_block_is_media_with_no_poster() {
7657 let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
7658 assert_eq!(m.len(), 1);
7659 assert_eq!(m[0].kind, MediaKind::Audio);
7660 assert_eq!(m[0].destination, "take.mp3");
7661 assert!(m[0].poster.is_empty(), "audio has no poster frame");
7662 }
7663
7664 #[test]
7665 fn a_videos_source_children_are_its_candidates_typed_by_mime() {
7666 // A `<video>` with no `src` of its own — the common shape, since it's how
7667 // you offer more than one codec. The candidates come from `<source src>`
7668 // (not `srcset`, which is `<picture>`'s spelling) and carry their MIME.
7669 let src = "<video controls>\n\
7670 <source src=\"a.webm\" type=\"video/webm\">\n\
7671 <source src=\"a.mp4\" type=\"video/mp4\">\n\
7672 fallback\n\
7673 </video>\n";
7674 let m = doc_media(src);
7675 assert_eq!(m.len(), 1);
7676 assert!(
7677 m[0].destination.is_empty(),
7678 "no src attribute on the element"
7679 );
7680 assert_eq!(m[0].sources.len(), 2);
7681 assert_eq!(m[0].sources[0].srcset, "a.webm");
7682 assert_eq!(m[0].sources[0].mime, "video/webm");
7683 assert_eq!(m[0].sources[1].srcset, "a.mp4");
7684 // With an empty destination, `resolve` falls through to the first
7685 // candidate rather than handing the frontend nothing to load.
7686 assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
7687 }
7688
7689 #[test]
7690 fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
7691 // The placeholder contract images already hold, now for a video: the row
7692 // renders as a labelled stand-in a plain surface can paint as-is, and
7693 // carries the mark a capable frontend replaces it from.
7694 let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
7695 let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7696 doc.build_visual(80);
7697 let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
7698 let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7699 assert!(
7700 text.starts_with('🎬'),
7701 "video sigil, not the image one: {text:?}"
7702 );
7703 assert!(row.media.is_some(), "the mark rides the placeholder row");
7704 }
7705
7706 #[test]
7707 fn a_picture_block_carries_its_source_alternatives() {
7708 // A `<picture>` with a dark-mode `<source>`: one block image, whose
7709 // fallback destination is the `<img>` and whose `sources` carry the
7710 // `<source>`'s media + srcset for a theme-aware frontend to pick.
7711 let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
7712 let images = doc_media(src);
7713 assert_eq!(images.len(), 1, "the picture is one block image");
7714 let img = &images[0];
7715 assert_eq!(img.destination, "light.svg", "fallback is the <img>");
7716 assert_eq!(img.alt, "banner");
7717 assert_eq!(
7718 img.sources,
7719 vec![MediaSource {
7720 media: "(prefers-color-scheme: dark)".into(),
7721 srcset: "dark.svg".into(),
7722 mime: String::new(),
7723 }],
7724 );
7725 }
7726
7727 #[test]
7728 fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
7729 // fig.md's shape: the banner is an `<h1>` wrapping the `<picture>`.
7730 let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
7731 let images = doc_media(src);
7732 assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
7733 assert_eq!(images[0].destination, "l.svg");
7734 assert_eq!(images[0].sources.len(), 1);
7735 assert_eq!(images[0].sources[0].srcset, "d.svg");
7736 }
7737
7738 #[test]
7739 fn a_plain_image_has_no_media_sources() {
7740 // A bare Markdown image carries an empty `sources` — nothing to pick from.
7741 let images = doc_media("\n");
7742 assert_eq!(images.len(), 1);
7743 assert!(
7744 images[0].sources.is_empty(),
7745 "no <picture>, no alternatives"
7746 );
7747 }
7748
7749 #[test]
7750 fn resolve_picks_the_source_matching_the_scheme() {
7751 let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
7752 let images = doc_media(src);
7753 let img = &images[0];
7754 // Dark theme takes the dark source; light falls through to the <img>.
7755 assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
7756 assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
7757 }
7758
7759 #[test]
7760 fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
7761 // A plain image ignores the scheme.
7762 let plain = doc_media("\n");
7763 assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
7764
7765 // A <source> with an unrecognized media query is skipped; a light source
7766 // is taken under a light theme.
7767 let m = doc_media(
7768 "<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
7769 );
7770 assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
7771 assert_eq!(
7772 m[0].resolve(ColorScheme::Dark),
7773 "f.svg",
7774 "no dark source → <img>"
7775 );
7776 }
7777
7778 #[test]
7779 fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
7780 // A comma/descriptor srcset resolves to its first URL.
7781 assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
7782 assert_eq!(first_srcset_url(" solo.svg "), Some("solo.svg"));
7783 assert_eq!(first_srcset_url(""), None);
7784 // An empty (unconditional) media always matches.
7785 assert!(media_matches("", ColorScheme::Light));
7786 assert!(media_matches(
7787 "(prefers-color-scheme:dark)",
7788 ColorScheme::Dark
7789 ));
7790 assert!(!media_matches(
7791 "(prefers-color-scheme: dark)",
7792 ColorScheme::Light
7793 ));
7794 }
7795
7796 #[test]
7797 fn a_block_media_carries_its_list_prefix() {
7798 // An image that is a list item's body opens past the bullet, like every
7799 // other block does.
7800 let m = map("- \n");
7801 let row = &m.rows[m.media[0].rows_span.start];
7802 let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7803 assert!(
7804 text.starts_with("• "),
7805 "the list marker prefixes the image row: {text:?}"
7806 );
7807 assert!(text.contains("🖼 alt"));
7808 }
7809
7810 #[test]
7811 fn the_structural_table_spans_exactly_its_drawn_rows() {
7812 // A frontend drawing its own grid skips `rows_span` and renders from
7813 // `grid`. If the span were short the leftover border rows would be
7814 // painted as text under the real table; if long it would eat a
7815 // neighbouring paragraph. Both are silent, so pin it to the picture.
7816 let m = map(&format!("before\n\n{TABLE}\nafter\n"));
7817 let t = &m.tables[0];
7818 let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7819 assert!(
7820 row_text(t.rows_span.start).starts_with('┌'),
7821 "opens on the top border"
7822 );
7823 assert!(
7824 row_text(t.rows_span.end - 1).starts_with('└'),
7825 "closes on the bottom border"
7826 );
7827 assert!(
7828 !row_text(t.rows_span.start - 1).contains('┌'),
7829 "the row before the span is not the table's"
7830 );
7831 assert_eq!(
7832 row_text(t.rows_span.end),
7833 "",
7834 "the span ends before the gap row"
7835 );
7836 }
7837
7838 #[test]
7839 fn a_nested_tables_structure_carries_the_block_prefix() {
7840 // The picture puts the quote's gutter on every row of the grid. A
7841 // frontend drawing its own table has to draw that too and start past it,
7842 // so the prefix has to travel with the structure — without it a quoted
7843 // table renders flush at the margin and leaves the quote it's in.
7844 let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
7845 let t = &m.tables[0];
7846 let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
7847 assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
7848 // And it matches what the picture actually drew.
7849 let drawn: String = m.rows[t.rows_span.start]
7850 .glyphs
7851 .iter()
7852 .map(|g| g.ch)
7853 .collect();
7854 assert!(
7855 drawn.starts_with(&prefix),
7856 "picture and structure disagree: {drawn:?}"
7857 );
7858 }
7859
7860 #[test]
7861 fn a_top_level_table_carries_no_prefix() {
7862 assert!(map(TABLE).tables[0].prefix.is_empty());
7863 }
7864
7865 #[test]
7866 fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
7867 // The picture wraps a cell to its column; a frontend laying the grid out
7868 // in pixels needs the text as the document spells it, before that
7869 // decision. Narrow enough that the drawn cell must break.
7870 let src = "| Name |\n|------|\n| alpha beta gamma |\n";
7871 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7872 let m = build_t(&ed.nodes().unwrap(), src, Some(12));
7873 let drawn = rendered(&m);
7874 let cell: String = m.tables[0].grid[1].cells[0]
7875 .glyphs
7876 .iter()
7877 .map(|g| g.ch)
7878 .collect();
7879 assert_eq!(
7880 cell, "alpha beta gamma",
7881 "structure must not carry the wrap"
7882 );
7883 assert!(
7884 drawn.lines().count() > 5,
7885 "the picture should have wrapped, else this proves nothing:\n{drawn}"
7886 );
7887 }
7888
7889 // ── display columns ──────────────────────────────────────────────────────
7890
7891 #[test]
7892 fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
7893 // A column sized by counting characters is drawn narrower than the text
7894 // it has to hold — `你好` is two characters in four cells — and the cell
7895 // spills over the border it is supposed to sit inside, taking the whole
7896 // grid out of square with it. Squareness is the property: every row of a
7897 // grid is drawn to the same column, whatever its cells are spelled with.
7898 for src in [
7899 "| A | B |\n|---|---|\n| 你好 | y |\n",
7900 "| A | B |\n|---|---|\n| a👨👩👧b | y |\n",
7901 "| A | 漢字 |\n|---|---|\n| x | y |\n",
7902 ] {
7903 let m = map(src);
7904 let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
7905 assert!(
7906 widths.windows(2).all(|w| w[0] == w[1]),
7907 "ragged grid {widths:?} for {src:?}:\n{}",
7908 rendered(&m)
7909 );
7910 }
7911 }
7912
7913 #[test]
7914 fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
7915 // A column too narrow for its cell hard-breaks the text, and every line
7916 // of it is given an end stop just past its last glyph. Broken into runs
7917 // of four glyphs, the first line of this cell ends between `👨👩` and the
7918 // joiner holding `👧` on — so its end stop lands inside a character,
7919 // where a click or Down can reach it and the next Backspace takes the
7920 // cluster apart from the middle.
7921 let src = "| A |\n|---|\n| 👨👩👧👨👩👧 |\n";
7922 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7923 let m = build_t(&ed.nodes().unwrap(), src, Some(8));
7924 let boundaries: Vec<usize> = src
7925 .grapheme_indices(true)
7926 .map(|(i, _)| i)
7927 .chain(std::iter::once(src.len()))
7928 .collect();
7929 for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
7930 assert!(
7931 boundaries.contains(&off),
7932 "stop at {off} is inside a character:\n{}",
7933 rendered(&m)
7934 );
7935 }
7936 }
7937
7938 #[test]
7939 fn a_wrapped_cell_keeps_every_line_inside_its_column() {
7940 // The width is a promise in a table, where a glyph past the column lands
7941 // on the border or in the next cell — and it is a promise about cells,
7942 // which is not what a count of glyphs measures.
7943 let src = "| A |\n|---|\n| 你好世界漢字 |\n";
7944 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7945 let m = build_t(&ed.nodes().unwrap(), src, Some(14));
7946 for r in &m.rows {
7947 assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
7948 }
7949 }
7950
7951 #[test]
7952 fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
7953 let glyphs = |s: &str| {
7954 let mut out = Vec::new();
7955 push_text(&mut out, s, 0, Style::default());
7956 out
7957 };
7958 let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
7959
7960 // Six cells of CJK broken at four: two characters, then one — never
7961 // between the two cells of `好`.
7962 let w = glyphs("你好世");
7963 let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
7964 assert_eq!(pieces, ["你好", "世"]);
7965
7966 // A character wider than the column has nowhere legal to break, so it
7967 // keeps its cells rather than being cut in half.
7968 let w = glyphs("你好");
7969 let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
7970 assert_eq!(pieces, ["你", "好"]);
7971
7972 // An empty word yields no pieces at all — a double space stays a space.
7973 assert!(hard_break(&[], 4).is_empty());
7974 }
7975
7976 #[test]
7977 fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
7978 // Pressing Enter at the end of a list item opens a new, empty item —
7979 // a childless `list_item`. Without a row of its own the new bullet
7980 // wouldn't appear until something was typed into it (the caret would be
7981 // stranded on an offset no row draws). It now renders as one prefixed
7982 // row whose end is a caret stop, so the bullet shows and the caret lands
7983 // just past the marker.
7984 let m = map("- item\n- \n");
7985 assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
7986 assert_eq!(
7987 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7988 "• ",
7989 "the empty item draws just its bullet",
7990 );
7991 // Its end is the caret home (past the `- ` marker), and it's a real stop.
7992 assert!(
7993 m.is_stop(m.rows[1].end_src),
7994 "the empty item's caret home is not a stop"
7995 );
7996 assert_eq!(
7997 m.pos_of_offset(m.rows[1].end_src),
7998 (1, 2),
7999 "caret sits after '• '"
8000 );
8001 }
8002
8003 #[test]
8004 fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
8005 // The peek bug: a note whose body ends in a link has its last byte
8006 // inside the hidden destination, so mapping `end - 1` through
8007 // `pos_of_offset` snapped *forward* — past its own row, past the drawn
8008 // gap, and onto the next note's row. The popover then drew both notes.
8009 let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
8010 let m = map(src);
8011 let body = src.find("[title]").unwrap();
8012 let end = src.find("\n\n[^3]").unwrap();
8013
8014 let (first, last) = m.row_range_for(body..end);
8015 assert_eq!(
8016 first, last,
8017 "a one-block note is one row, not a span onto the next"
8018 );
8019
8020 // The old arithmetic, kept here as the thing that must stay wrong: it
8021 // is what this method exists instead of.
8022 assert_ne!(
8023 m.pos_of_offset(end - 1).0,
8024 last,
8025 "the forward snap still leaves the note's row — that is the whole point",
8026 );
8027
8028 // A note ending in *visible* text was never broken, and still isn't:
8029 // both readings agree there, which is why the original test missed it.
8030 let plain = src.find("bare text").unwrap();
8031 let plain_end = src.find("\n\n[^2]").unwrap();
8032 let (pf, pl) = m.row_range_for(plain..plain_end);
8033 assert_eq!(pf, pl);
8034 assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
8035 }
8036
8037 #[test]
8038 fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
8039 // The range is a span, not a point: a quote of two paragraphs covers its
8040 // gap row and both of its text rows, so a peek draws the whole thing.
8041 let src = "> one\n>\n> two\n\nafter\n";
8042 let m = map(src);
8043 let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
8044 assert_eq!((first, last), (0, 2));
8045
8046 // And a range with no visible byte at all still covers the row it opened
8047 // on, rather than collapsing to nothing.
8048 let (f, l) = m.row_range_for(0..1);
8049 assert_eq!((f, l), (0, 0));
8050 }
8051
8052 #[test]
8053 fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
8054 // The peer of the empty list item, and the case that made an empty line
8055 // in a quote draw as plain body text: a childless `block_quote` — a bare
8056 // `> `, which is what the toolbar's Quote button leaves on a blank line —
8057 // has no inner block to carry the gutter, so the whole quote used to
8058 // render as *nothing*. It didn't merely lose its bar; the row went away
8059 // and the caret had no home on it.
8060 let m = map("a\n\n> \n\nb\n");
8061 assert_eq!(
8062 m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
8063 "│ ",
8064 "the empty quote draws just its gutter",
8065 );
8066 assert!(
8067 m.rows[2]
8068 .glyphs
8069 .iter()
8070 .all(|g| g.style.role == Role::QuoteGutter)
8071 );
8072 assert!(
8073 !m.rows[2].decoration,
8074 "it is a line text can go on, not a drawn gap"
8075 );
8076 assert!(
8077 m.is_stop(m.rows[2].end_src),
8078 "the empty quote's caret home is not a stop"
8079 );
8080 assert_eq!(
8081 m.pos_of_offset(m.rows[2].end_src),
8082 (2, 2),
8083 "caret sits after '│ '"
8084 );
8085
8086 // And a document that is *only* an empty quote still renders a row — it
8087 // used to render none at all, leaving the caret nowhere to stand.
8088 let m = map("> \n");
8089 assert_eq!(m.num_rows(), 1);
8090 assert_eq!(
8091 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
8092 "│ "
8093 );
8094 }
8095
8096 #[test]
8097 fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
8098 // Enter at the end of `> a` writes `> a\n>\n> \n`. Those last two lines
8099 // hold no block — a quote's `content_span` stops at its last child — so
8100 // the children walk never reaches them, and they used to fall through to
8101 // the document-level trailing pass, which knows no prefix: the gutter
8102 // stopped and the writer's new line drew as plain prose. Fixable only
8103 // since twig 3.2.0, where the quote's *span* covers its own marker lines
8104 // (`0..3` before, `0..8` now) and there is finally a node saying they
8105 // are the quote's.
8106 let m = map("> a\n>\n> \n");
8107 assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
8108 for (i, row) in m.rows.iter().enumerate() {
8109 let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
8110 assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
8111 assert!(
8112 !row.decoration,
8113 "row {i} is a line to type on, not a drawn gap"
8114 );
8115 assert!(m.is_stop(row.end_src), "row {i} has no caret home");
8116 }
8117 assert_eq!(
8118 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
8119 "│ a"
8120 );
8121 // Distinct offsets, so ↑/↓ between them moves the caret rather than
8122 // landing twice on the same byte.
8123 assert!(m.rows[0].end_src < m.rows[1].end_src);
8124 assert!(m.rows[1].end_src < m.rows[2].end_src);
8125
8126 // A blank line *after* the quote is not the quote's: it is spelled with
8127 // no marker, so it stays an ordinary boundary and the gutter ends.
8128 let m = map("> a\n\nb\n");
8129 assert_eq!(m.num_rows(), 3);
8130 assert_eq!(
8131 m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
8132 "b"
8133 );
8134 assert!(
8135 !m.rows[1]
8136 .glyphs
8137 .iter()
8138 .any(|g| g.style.role == Role::QuoteGutter)
8139 );
8140
8141 // Nesting is the case this could get wrong, and the depth has to come
8142 // from which quote's span the line falls in rather than from the row
8143 // above it. A trailing `>` under `> > a` matches only the OUTER quote,
8144 // so it wears one gutter; spell it `> >` and it wears two.
8145 let m = map("> > a\n>\n");
8146 assert_eq!(
8147 m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
8148 "│ │ a"
8149 );
8150 assert_eq!(
8151 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8152 "│ "
8153 );
8154 let m = map("> > a\n> >\n");
8155 assert_eq!(
8156 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8157 "│ │ "
8158 );
8159
8160 // And a marker line BETWEEN two quoted paragraphs is untouched: that is
8161 // the boundary `emit_separators_before` spells, and it stays a drawn gap
8162 // rather than becoming a line to type on.
8163 let m = map("> a\n>\n> b\n");
8164 assert_eq!(m.num_rows(), 3);
8165 assert!(
8166 m.rows[1].decoration,
8167 "the gap between two quoted blocks is still a gap"
8168 );
8169 }
8170
8171 #[test]
8172 fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
8173 let m = map("1. item\n2. \n");
8174 assert_eq!(m.num_rows(), 2);
8175 assert_eq!(
8176 m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8177 "2. "
8178 );
8179 assert!(m.is_stop(m.rows[1].end_src));
8180 assert_eq!(
8181 m.pos_of_offset(m.rows[1].end_src),
8182 (1, 3),
8183 "caret sits after '2. '"
8184 );
8185 }
8186
8187 #[test]
8188 fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
8189 // The toolbar's H1 on a blank line writes `# ` and nothing else. The row
8190 // it renders is empty (the marker is hidden), so its end *is* its only
8191 // caret stop — and it has to be the offset past the `# `, where typing
8192 // continues the heading. Anchored at the block's start instead, the caret
8193 // drew in front of the hashes and the first character typed there landed
8194 // before them (`x# `), which isn't a heading at all.
8195 let m = map("# \n");
8196 assert_eq!(m.num_rows(), 1);
8197 assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
8198 assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
8199 assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
8200 }
8201
8202 #[test]
8203 fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
8204 // The row-level fact a proportional frontend sizes a whole line by. An
8205 // empty heading has no glyph to read a `Role::Heading` off, so a renderer
8206 // scanning glyphs drew `# ` (and its caret) at body height until the
8207 // first character landed.
8208 let m = map("# \n");
8209 assert_eq!(
8210 m.rows[0].heading,
8211 Some(1),
8212 "the empty heading knows its level"
8213 );
8214
8215 // Every row of one that wraps, not just the first — and nothing else.
8216 let m = map_at(
8217 "## a heading long enough to wrap over two rows\n\nbody\n",
8218 Some(20),
8219 );
8220 let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
8221 assert!(
8222 heads.iter().filter(|h| **h == Some(2)).count() >= 2,
8223 "got {heads:?}"
8224 );
8225 assert_eq!(
8226 m.rows.last().and_then(|r| r.heading),
8227 None,
8228 "the paragraph under it is not a heading",
8229 );
8230 }
8231
8232 #[test]
8233 fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
8234 // The row's end is also what the *next* row's separator is measured from,
8235 // so an empty heading that under-reported it shifted every offset below —
8236 // and the blank line under the heading then claimed the same offset as the
8237 // heading's own end. `pos_of_offset` resolves such a tie downstream (a
8238 // soft wrap belongs to the row below), so the caret at the end of the
8239 // heading was drawn two rows lower, on the blank line.
8240 // `text\n\n# \n\n`: the heading's content opens at 8, and the two rows
8241 // under it end at 9 and 10 — the blank line and the document's end.
8242 let m = map("text\n\n# \n\n");
8243 let end = m.rows.last().expect("a trailing blank row").end_src;
8244 assert_eq!(end, 10, "the trailing rows must end at their real offsets");
8245 // The heading's caret home is its own row's, not one shared with a row
8246 // below — the tie that drew the caret two rows down.
8247 assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
8248 assert!(
8249 m.rows[3..].iter().all(|r| r.end_src > 8),
8250 "rows below own later offsets"
8251 );
8252 }
8253
8254 // ── block boundaries ─────────────────────────────────────────────────────
8255
8256 /// Every drawn boundary in `src`, in order, as `(above, below)`.
8257 fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
8258 m.rows
8259 .iter()
8260 .filter_map(|r| r.boundary)
8261 .map(|b| (b.above, b.below))
8262 .collect()
8263 }
8264
8265 #[test]
8266 fn a_boundary_says_which_blocks_it_divides() {
8267 use BlockClass::*;
8268 let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
8269 assert_eq!(
8270 boundaries(&m),
8271 vec![
8272 (Paragraph, Paragraph),
8273 (Paragraph, Heading),
8274 (Heading, Paragraph),
8275 (Paragraph, Quote),
8276 (Quote, Code),
8277 // The blank the document trails off with is a boundary too — it
8278 // closes the last block above the empty paragraph the caret rests
8279 // on. See `emit_trailing_blank_lines`.
8280 (Code, Paragraph),
8281 ],
8282 "each gap names the pair it falls between, in document order"
8283 );
8284 }
8285
8286 // ── hidden blocks ────────────────────────────────────────────────────────
8287
8288 /// The row texts of `m`, one string per row.
8289 fn row_texts(m: &VisualMap) -> Vec<String> {
8290 m.rows
8291 .iter()
8292 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8293 .collect()
8294 }
8295
8296 #[test]
8297 fn a_div_s_closing_tag_is_not_a_blank_row() {
8298 // The `</div>` sits on a line of its own under the div's last child and
8299 // draws nothing. Counting the separator from the child's end read that
8300 // line as a blank line between the div and the block below — a
8301 // navigable empty row the author never opened — and at the end of the
8302 // file, as an empty trailing paragraph.
8303 let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n");
8304 assert_eq!(row_texts(&m), ["above", "", "hello", "", "below"]);
8305 assert!(!m.is_stop(36), "the `</div>` line is not a caret home");
8306 assert_eq!(
8307 m.stop_after(34),
8308 Some(44),
8309 "from `hello` the next stop is `below`"
8310 );
8311
8312 let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n");
8313 assert_eq!(row_texts(&m), ["above", "", "hello"], "no trailing rows");
8314 }
8315
8316 #[test]
8317 fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
8318 // `<!-- exec -->` is a top-level block that draws no rows. The blocks
8319 // either side of it meet across the one boundary a paragraph and a code
8320 // block always meet across — not that boundary *plus* one blank row per
8321 // line of the comment, which is what counting the separator from the
8322 // paragraph's end used to spell.
8323 let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
8324 assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
8325 assert_eq!(
8326 boundaries(&m),
8327 vec![
8328 (BlockClass::Paragraph, BlockClass::Code),
8329 (BlockClass::Code, BlockClass::Paragraph),
8330 ],
8331 "the boundary names the drawn blocks either side, not the comment"
8332 );
8333 // The gap stands past the comment, so the caret's row lookup never
8334 // resolves inside it.
8335 assert_eq!(
8336 m.rows[1].end_src, 23,
8337 "the gap row ends at the comment's end"
8338 );
8339 }
8340
8341 #[test]
8342 fn a_comment_opening_the_document_draws_no_leading_gap() {
8343 let m = map("<!-- lead -->\n\npara\n");
8344 assert_eq!(row_texts(&m), ["para"]);
8345 assert_eq!(m.content_start, 0, "the comment is still the first block");
8346 }
8347
8348 #[test]
8349 fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
8350 // Its lines are not blank lines the author opened with Enter, so no
8351 // gap-plus-empty-paragraph is fabricated under the last drawn block.
8352 let m = map("para\n\n<!-- trail -->\n");
8353 assert_eq!(row_texts(&m), ["para"]);
8354 // Enter at the end of the document still opens the empty paragraph the
8355 // caret rests on: the newlines *after* the comment count as they would
8356 // after any block.
8357 let m = map("para\n\n<!-- trail -->\n\n");
8358 assert_eq!(row_texts(&m), ["para", "", ""]);
8359 }
8360
8361 #[test]
8362 fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
8363 // The first *drawn* child wears the item's marker; a hidden first child
8364 // would otherwise take it and leave the text without one.
8365 let m = map("- <!-- note -->\n\n text\n- two\n");
8366 let texts = row_texts(&m);
8367 assert!(
8368 texts.iter().any(|t| t == "• text"),
8369 "the text wears the bullet: {texts:?}"
8370 );
8371 assert!(
8372 !texts.iter().any(|t| t == "• "),
8373 "no empty bullet row for the comment: {texts:?}"
8374 );
8375 }
8376
8377 #[test]
8378 fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
8379 // The bug as seen: a 200-line document with one comment in it rendered
8380 // ~200 blank rows after the comment, one per source line, because the
8381 // comment's per-block builder handed back a `last_off` of 0. Parity with
8382 // `build` alone would not catch a *shared* wrong answer, so the count is
8383 // pinned outright.
8384 let body = (0..200)
8385 .map(|i| format!("line {i}"))
8386 .collect::<Vec<_>>()
8387 .join("\n\n");
8388 let src = format!("intro\n\n<!-- exec -->\n{body}\n");
8389 let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
8390 let mut cache = BlockCache::default();
8391 let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
8392 assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
8393 // intro, then 200 × (gap, paragraph): 401 rows and not a row more.
8394 assert_eq!(cached.rows.len(), 401);
8395 }
8396
8397 #[test]
8398 fn a_link_reference_definition_is_stepped_over_like_a_comment() {
8399 // `[a]: /a` is a root beside `doc` with no rows of its own. Merged into
8400 // the walk it is a hidden block: the blocks either side meet across one
8401 // boundary, and its line is not a blank row.
8402 let m = map("see [a]\n\n[a]: /a\n\nafter\n");
8403 assert_eq!(row_texts(&m), ["see a", "", "after"]);
8404 assert_eq!(
8405 boundaries(&m),
8406 vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
8407 );
8408 }
8409
8410 #[test]
8411 fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
8412 // The README shape: prose, then a `[links]` block nobody reads. Its
8413 // lines used to be counted as blank ones, an empty paragraph per
8414 // definition under the last real block.
8415 let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
8416 assert_eq!(row_texts(&m), ["see a and b"]);
8417 }
8418
8419 #[test]
8420 fn a_definition_glued_under_a_paragraph_stays_inside_it() {
8421 // `[a]: /a` at the front of a paragraph's lines is stripped from the
8422 // paragraph's text, but the paragraph's span still starts on its line.
8423 // Both blocks start at the same offset; the definition, sorted first,
8424 // is stepped over, and the paragraph draws as it always did — one gap
8425 // above it, none inside.
8426 let m = map("intro\n\n[a]: /a\ntext [a]\n");
8427 assert_eq!(row_texts(&m), ["intro", "", "text a"]);
8428 }
8429
8430 #[test]
8431 fn a_definition_with_no_span_is_left_out_of_the_walk() {
8432 // twig before 3.3.3 reported `0..0` for every link reference
8433 // definition. One of those has nowhere to be merged: sorted first by
8434 // its zero start it would open the document with a phantom block, and
8435 // the walk would step back to offset 0. It is simply not a block. A
8436 // footnote definition is always placed; it has a body to draw.
8437 assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
8438 assert!(is_placed_definition(&Kind::Reference, &(7..14)));
8439 assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
8440 assert!(!is_placed_definition(&Kind::Str, &(7..14)));
8441 }
8442
8443 #[test]
8444 fn the_trailing_gap_closes_the_last_block() {
8445 // Two Enters at the end of a document: a drawn gap, then the navigable
8446 // empty paragraph. Only the gap is labelled, so a frontend that shrinks
8447 // boundaries shrinks the spacer and leaves the row being typed on alone.
8448 let m = map("# Head\n\n\n");
8449 assert_eq!(
8450 boundaries(&m),
8451 vec![(BlockClass::Heading, BlockClass::Paragraph)]
8452 );
8453 }
8454
8455 #[test]
8456 fn only_the_drawn_gap_rows_carry_a_boundary() {
8457 let m = map("one\n\ntwo\n");
8458 for row in &m.rows {
8459 assert_eq!(
8460 row.boundary.is_some(),
8461 row.decoration,
8462 "a boundary is exactly a drawn gap row: {:?}",
8463 row.glyphs.iter().map(|g| g.ch).collect::<String>()
8464 );
8465 }
8466 }
8467
8468 #[test]
8469 fn preserve_flow_labels_no_boundary() {
8470 // Every blank line is a caret home there — somewhere text can go, not a
8471 // gap between blocks — so nothing is drawn-only and nothing is labelled.
8472 // A frontend keying its spacing off `boundary` can't shrink a row the
8473 // author is about to type on.
8474 let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
8475 assert!(boundaries(&m).is_empty());
8476 }
8477
8478 #[test]
8479 fn a_list_draws_no_boundary_between_its_items() {
8480 // Tight or loose, core puts no gap row between two items of one list —
8481 // so an item↔item boundary is a shape no frontend will ever be handed,
8482 // and spacing one is spacing something that isn't there.
8483 for src in ["- one\n- two\n", "- one\n\n- two\n"] {
8484 let m = map(src);
8485 assert!(
8486 boundaries(&m).is_empty(),
8487 "no gap row inside the list of {src:?}"
8488 );
8489 }
8490 // Leaving the list is an ordinary boundary, and the list is named as
8491 // what sits above it.
8492 let m = map("- one\n- two\n\npara\n");
8493 assert_eq!(
8494 boundaries(&m),
8495 vec![(BlockClass::List, BlockClass::Paragraph)]
8496 );
8497 }
8498
8499 #[test]
8500 fn a_nested_boundary_names_the_blocks_inside_the_container() {
8501 // Two paragraphs inside a blockquote are divided by a Paragraph↔Paragraph
8502 // boundary — the quote is the container they're both in, not what the gap
8503 // separates.
8504 let m = map("> one\n>\n> two\n");
8505 assert_eq!(
8506 boundaries(&m),
8507 vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
8508 );
8509 }
8510
8511 #[test]
8512 fn a_directive_container_draws_one_boundary_like_every_other_block() {
8513 // A container's rows stop at its last *child*, so without anchoring
8514 // `last_off` past the closing `:::` the separator logic counted the fence
8515 // line as a blank row of its own and drew the gap twice — one authored
8516 // blank line, two boundaries, and a frontend spacing each of them put
8517 // double margin under every fenced div. The code-block arm anchors past
8518 // its ``` for exactly this reason; compare the two here.
8519 let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
8520 assert_eq!(
8521 boundaries(&fenced),
8522 vec![(BlockClass::Directive, BlockClass::Paragraph)],
8523 "one authored gap, one boundary row"
8524 );
8525 let code = map("```\nc\n```\n\ntwo\n");
8526 assert_eq!(
8527 boundaries(&code).len(),
8528 boundaries(&fenced).len(),
8529 "a fenced div spaces like a fenced code block"
8530 );
8531 // Nesting closes several fences at once; still one gap.
8532 let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
8533 assert_eq!(
8534 boundaries(&nested),
8535 vec![(BlockClass::Directive, BlockClass::Paragraph)]
8536 );
8537 }
8538
8539 #[test]
8540 fn a_block_media_names_itself_in_the_boundaries_either_side() {
8541 use BlockClass::*;
8542 // A block image is never a node of its own — `media_only` promotes the
8543 // *paragraph* wrapping it — so classifying the node the walk stands on
8544 // called the picture `Paragraph` and left `BlockClass::Media` unreachable:
8545 // a frontend could not give a photo more air than a line of prose.
8546 // `label_media_boundaries` reads it back off the finished rows instead.
8547 let m = map("one\n\n\n\ntwo\n");
8548 assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
8549 // At the edges of the document too: the leading gap has no boundary of
8550 // its own, and the trailing one is `emit_trailing_blank_lines`'.
8551 let edges = map("\n\nmid\n\n\n");
8552 assert_eq!(
8553 boundaries(&edges),
8554 vec![(Media, Paragraph), (Paragraph, Media)]
8555 );
8556 // One gap spelled with several rows — the row closing the block above and
8557 // the row opening the one below, with the author's spare blank line
8558 // navigable between them — carries the same pair on every drawn row.
8559 let roomy = map("one\n\n\n\n\n");
8560 assert_eq!(
8561 boundaries(&roomy),
8562 vec![(Paragraph, Media), (Paragraph, Media)]
8563 );
8564 }
8565
8566 #[test]
8567 fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
8568 // Worse than the image case before `label_media_boundaries`: a `<video>`
8569 // arrives as twig's generic `container`, which classifies `Directive` —
8570 // the one class a frontend reads as "draw a tinted panel here". A movie
8571 // got the chrome of a fenced div.
8572 let mut doc = crate::Doc::from_source(
8573 "one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
8574 Format::Markdown,
8575 )
8576 .unwrap();
8577 doc.build_visual(80);
8578 assert_eq!(
8579 boundaries(&doc.vmap),
8580 vec![
8581 (BlockClass::Paragraph, BlockClass::Media),
8582 (BlockClass::Media, BlockClass::Paragraph),
8583 ]
8584 );
8585 }
8586
8587 #[test]
8588 fn the_incremental_walk_labels_boundaries_like_the_full_one() {
8589 // `assert_maps_eq` compares boundaries too, so this pins the two doors
8590 // into `BlockClass::from_node_kind` — a `FlatNode`'s kind on the full
8591 // build, a query match's on the cached one — against a document with one
8592 // of every boundary in it.
8593 let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
8594 let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
8595 let mut cache = BlockCache::default();
8596 let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
8597 assert_maps_eq(&full, &cached, "boundary labelling");
8598 assert!(
8599 !boundaries(&full).is_empty(),
8600 "the fixture has boundaries to compare"
8601 );
8602 }
8603
8604 #[test]
8605 fn every_caret_stop_opens_a_cluster_of_its_row() {
8606 // The two ways of finding a cluster have to agree. `push_text` marks the
8607 // stops by segmenting one run of text; the column mapping segments the
8608 // whole row, decoration and all. A stop that came out as the *middle* of
8609 // some row-level cluster would be a caret with no column of its own —
8610 // drawn at the column of whatever swallowed it.
8611 let src = "# 標題\n\na **bold** e\u{0301}mo👨👩👧ji `x` 你好\n\n\
8612 - 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
8613 | A | 值 |\n|---|---|\n| 你好 | 👩🚀 |\n";
8614 let m = map(src);
8615 for (r, row) in m.rows.iter().enumerate() {
8616 let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
8617 for (i, g) in row.glyphs.iter().enumerate() {
8618 assert!(
8619 !g.stop || openers.contains(&i),
8620 "row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
8621 so it is drawn at another glyph's column",
8622 g.ch
8623 );
8624 }
8625 }
8626 }
8627
8628 // ── the presentation vocabulary ─────────────────────────────────────────
8629
8630 /// A block's own attributes, in the three formats that spell one on the
8631 /// block itself: HTML's tag, djot's `{…}` line, and — the odd one — a
8632 /// Markdown `<div>` around it, which is where twig has to put a Markdown
8633 /// block's attributes because the format has nowhere else.
8634 #[test]
8635 fn a_block_carries_its_alignment_on_every_row_it_draws() {
8636 // HTML, on the paragraph. `lead` is somebody else's class and is
8637 // neither read nor in the way.
8638 let html = map_leaf("<p class=\"lead center\">hi</p>\n", Format::Html);
8639 assert_eq!(line_facts(&html), vec![(Some(Align::Center), None)]);
8640
8641 // djot's attribute line, on the block.
8642 let dj = map_leaf("{.right}\nhi\n", Format::Djot);
8643 assert_eq!(line_facts(&dj), vec![(Some(Align::Right), None)]);
8644
8645 // A heading carries it too, and on every row a wrapped one draws.
8646 let h = map_leaf("{.center}\n# a heading\n", Format::Djot);
8647 assert_eq!(line_facts(&h), vec![(Some(Align::Center), None)]);
8648 assert_eq!(h.rows[0].heading, Some(1));
8649
8650 // Both keys at once, and the line spacing is read the same way.
8651 let both = map_leaf("{.justify data-line-height=\"1.5\"}\nhi\n", Format::Djot);
8652 assert_eq!(
8653 line_facts(&both),
8654 vec![(Some(Align::Justify), Some(LineSpacing::OneHalf))]
8655 );
8656
8657 // An unknown token and an unknown ratio are somebody else's, and the
8658 // block draws at the theme's default rather than at a guess.
8659 let other = map_leaf("{.lead data-line-height=\"1.3\"}\nhi\n", Format::Djot);
8660 assert_eq!(line_facts(&other), vec![(None, None)]);
8661 }
8662
8663 /// `<div class="center">` around three paragraphs centres all three, which
8664 /// is what the author of that HTML meant — and around one is the sole-child
8665 /// shape twig's `set_block_attrs` writes in Markdown.
8666 #[test]
8667 fn a_div_lends_its_alignment_to_every_block_inside_it() {
8668 let m = map_leaf(
8669 "<div class=\"center\" data-line-height=\"2\">\n\none\n\ntwo\n\n</div>\n",
8670 Format::Markdown,
8671 );
8672 assert_eq!(
8673 line_facts(&m),
8674 vec![
8675 (Some(Align::Center), Some(LineSpacing::Double)),
8676 (Some(Align::Center), Some(LineSpacing::Double)),
8677 ]
8678 );
8679
8680 // The nearer node wins, and the block after the div is untouched — the
8681 // context is restored, not left running.
8682 let nested = map_leaf(
8683 "<div class=\"center\">\n\n<div class=\"right\">\n\ninner\n\n</div>\n\nouter\n\n</div>\n\nafter\n",
8684 Format::Markdown,
8685 );
8686 assert_eq!(
8687 line_facts(&nested),
8688 vec![
8689 (Some(Align::Right), None),
8690 (Some(Align::Center), None),
8691 (None, None),
8692 ]
8693 );
8694 }
8695
8696 /// Size, face and colour are the run's, and the block's when the whole
8697 /// block is meant — read at both levels with the nearer winning.
8698 #[test]
8699 fn a_span_s_size_beats_its_block_s_and_its_face_falls_through() {
8700 // `<div data-font>` over `<p data-size>` over `<span data-size>`: the
8701 // span wins on size, the block is still what says the face.
8702 // The span is not first on its line: a `<span …>` opening one is an
8703 // HTML *block* to CommonMark, which is a fact about Markdown and not
8704 // about this.
8705 let m = map_leaf(
8706 "<div data-font=\"serif\">\n\nc <span data-size=\"small\">a</span> b\n\n</div>\n",
8707 Format::Markdown,
8708 );
8709 let a = style_of(&m, 'a');
8710 assert_eq!(a.size, Some(SizeStep::Small));
8711 assert_eq!(a.font, Some(FontFamily::Serif));
8712 // The text outside the span keeps the div's face and no size at all.
8713 let b = style_of(&m, 'b');
8714 assert_eq!(b.size, None);
8715 assert_eq!(b.font, Some(FontFamily::Serif));
8716
8717 // djot spells the same span anonymously and it reads identically.
8718 let dj = map_leaf(
8719 "{data-size=\"large\"}\nx [y]{data-size=\"xx-large\" data-color=\"blue\"} z\n",
8720 Format::Djot,
8721 );
8722 assert_eq!(style_of(&dj, 'x').size, Some(SizeStep::Large));
8723 assert_eq!(style_of(&dj, 'y').size, Some(SizeStep::XxLarge));
8724 assert_eq!(style_of(&dj, 'y').color, Some(MarkColor::Blue));
8725 // The block's size is still the block's outside the span.
8726 assert_eq!(style_of(&dj, 'z').size, Some(SizeStep::Large));
8727 assert_eq!(style_of(&dj, 'z').color, None);
8728 }
8729
8730 /// The one key two nodes share. `data-color` on a `mark` is the highlight's
8731 /// *background* and reaches a glyph through [`Role::Mark`]; the same key on
8732 /// an attributed span is the text's foreground. Same vocabulary, same enum,
8733 /// no collision — and a mark inside a coloured span wears both.
8734 #[test]
8735 fn a_mark_keeps_its_highlight_colour_and_a_span_colours_the_text() {
8736 let m = map_leaf("a ==\u{1f534} red== b\n", Format::Markdown);
8737 let r = style_of(&m, 'r');
8738 assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)));
8739 assert_eq!(r.color, None, "a highlight is not a text colour");
8740
8741 let both = map_leaf(
8742 "<span data-color=\"blue\">a ==\u{1f534} red== b</span>\n",
8743 Format::Markdown,
8744 );
8745 let r = style_of(&both, 'r');
8746 assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)), "the highlight");
8747 assert_eq!(r.color, Some(MarkColor::Blue), "the letters");
8748 }
8749
8750 /// A page break is the `::page-break` leaf directive, and djot spells the
8751 /// same document as an empty `::: page-break` fence whose name comes back
8752 /// as a class. Both draw the placeholder row every leaf directive gets and
8753 /// both carry the same [`DirectiveMark`], because a frontend that opens a
8754 /// page at one must not be able to tell which format the file is in.
8755 #[test]
8756 fn a_page_break_reads_the_same_in_markdown_and_in_djot() {
8757 for (fmt, src) in [
8758 (Format::Markdown, "a\n\n::page-break\n\nb\n"),
8759 (Format::Djot, "a\n\n::: page-break\n:::\n\nb\n"),
8760 ] {
8761 let m = map_leaf(src, fmt);
8762 let marks: Vec<&DirectiveMark> = m
8763 .rows
8764 .iter()
8765 .filter_map(|r| r.leaf_directive.as_ref())
8766 .collect();
8767 assert_eq!(marks.len(), 1, "{fmt:?} draws one placeholder");
8768 assert_eq!(marks[0].name, "page-break", "{fmt:?}");
8769 assert!(marks[0].attrs.is_empty(), "{fmt:?}: {:?}", marks[0].attrs);
8770 assert!(
8771 m.rows
8772 .iter()
8773 .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>()
8774 == "\u{29c9} page-break"),
8775 "{fmt:?} draws the label, got {:?}",
8776 m.rows
8777 .iter()
8778 .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
8779 .collect::<Vec<_>>()
8780 );
8781 }
8782
8783 // A Markdown `:::note` with nothing in it is *not* this: its name is
8784 // its own, and nothing about it says "a block with no body" the way
8785 // djot's spelling of a leaf directive does.
8786 let empty_fence = map_leaf("::: note\n:::\n", Format::Markdown);
8787 assert!(
8788 empty_fence.rows.iter().all(|r| r.leaf_directive.is_none()),
8789 "a named empty fence keeps the reading it has"
8790 );
8791 }
8792
8793 /// A djot fence carrying more than its name keeps the rest as an attribute
8794 /// rather than folding it into the name: the *first* class token is the
8795 /// name, because that is where `insert_directive` puts it.
8796 #[test]
8797 fn a_djot_fence_s_first_class_is_the_directive_s_name_and_the_rest_is_attributes() {
8798 let m = map_leaf("{.page-break .wide}\n:::\n:::\n", Format::Djot);
8799 let mark = m
8800 .rows
8801 .iter()
8802 .find_map(|r| r.leaf_directive.as_ref())
8803 .expect("a placeholder");
8804 assert_eq!(mark.name, "page-break");
8805 assert_eq!(
8806 mark.attrs,
8807 vec![("class".to_string(), Some("wide".to_string()))]
8808 );
8809 }
8810}