Skip to main content

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::{Baseline, MarkColor, Role, Style, Token};
32
33/// One rendered character plus the source byte offset it originates from.
34/// Synthetic glyphs (a list bullet, a quote gutter) point at their block's
35/// start, so clicking one lands the caret at the start of that block.
36#[derive(Clone)]
37pub struct Glyph {
38    pub ch: char,
39    pub style: Style,
40    pub src: usize,
41    /// Whether the caret may *rest* on this glyph. Decoration — a table border
42    /// or a cell's alignment padding — is visible but isn't text, so the caret
43    /// steps over it instead of into it. It also can't be a stop even in
44    /// principle: a run of decoration shares one `src`, and a caret can only
45    /// move by changing offset, so resting on it would pin horizontal motion.
46    /// A click still maps through `src`, which is why decoration points at the
47    /// text it decorates.
48    ///
49    /// Real text is a stop once per *grapheme cluster*, on the glyph that opens
50    /// it: the continuation glyphs of an emoji or an accented letter are drawn,
51    /// but standing between them is standing inside a character.
52    pub stop: bool,
53}
54
55/// One visual line. `end_src` is the source offset a caret sits at when placed
56/// at the line's end (past its last glyph) — the anchor for end-of-line and
57/// click-past-content.
58///
59/// `Clone` so a block's rows can be cached and re-emitted at a shifted offset
60/// across an edit — see [`BlockCache`].
61#[derive(Clone)]
62pub struct VRow {
63    pub glyphs: Vec<Glyph>,
64    pub end_src: usize,
65    /// A row that is drawn but holds no caret: a table's `├───┼───┤` rules, and
66    /// the blank gap a block boundary is spelled with. Vertical motion steps
67    /// over it, `pos_of_offset` never resolves onto it, and its stops (it has
68    /// none) and `end_src` stay out of the map's stop table.
69    ///
70    /// Emptiness isn't the test — an empty paragraph is a blank row too, and a
71    /// real caret stop. The test is whether the row is somewhere text can go.
72    pub decoration: bool,
73    /// This row is one line of a fenced or indented code block. Set on every row
74    /// the `"code_block"` arm emits — including its blank lines, which carry no
75    /// glyph to tell them apart otherwise. A frontend draws its own chrome (a
76    /// border and a tinted background) around each maximal run of these, and
77    /// scrolls them horizontally instead of wrapping; see
78    /// [`VisualMap::code_blocks`]. Survives the row shuffling of [`BlockCache`]
79    /// reuse and [`build_spliced`] because it rides on the row, not on a
80    /// row-index span the way a table's picture does.
81    pub code: bool,
82    /// A fenced code block's info string (its language), carried on the *first*
83    /// row of the block so it survives row reuse the way [`code`](Self::code)
84    /// does. `None` on every other row, and on an indented block (which has no
85    /// fence to label). A frontend paints it as a small label on the block's box
86    /// and edits it through a prompt — see [`CodeBlockInfo::lang`]. It's a plain
87    /// display string, not a source slice, so it needs no offset shifting; the
88    /// label re-derives from twig on the next build.
89    pub code_lang: Option<String>,
90    /// This row belongs to a `:::name{.class}` directive container — twig's
91    /// generic fenced-div block, whose meaning is entirely up to the host app
92    /// (diaryx's `:::vis{.audience}` visibility blocks, say). Set on every row
93    /// the `"directive"` arm emits, the same way [`code`](Self::code) marks a
94    /// code block's rows, so a frontend can draw a tinted panel around each
95    /// maximal run of these.
96    pub directive: bool,
97    /// A directive container's space-joined attrs — dot-prefixed classes
98    /// (`.public .family` → `"public family"`) unioned with bare pandoc-style
99    /// words (`public family`, no leading dot — diaryx's other `:::vis{...}`
100    /// convention), carried on the block's *first* row only — the
101    /// [`code_lang`](Self::code_lang) pattern. `None` on every other row, and
102    /// when the directive carries no such attrs. A frontend paints it as a
103    /// small label on the block's panel; it's a plain display string, not a
104    /// source slice, so it rides row reuse untouched.
105    pub directive_label: Option<String>,
106    /// Set on the single placeholder row a block-level image renders to, carrying
107    /// the image's destination and alt text; `None` on every other row. The row's
108    /// glyphs are the default `🖼 alt` label (which a plain surface paints as-is);
109    /// an image-capable frontend reads this to paint the real picture instead,
110    /// skipping the row named by [`MediaInfo::rows_span`]. Like
111    /// [`code_lang`](Self::code_lang) it's plain display strings, not source
112    /// slices, so it rides row reuse and needs no offset shifting; the map's
113    /// [`media`](VisualMap::media) side-table is derived from it once the rows
114    /// are final, the same way [`code_blocks`](VisualMap::code_blocks) is.
115    pub media: Option<MediaMark>,
116    /// Set on the **first** row of a task list item, carrying whether its box is
117    /// ticked; `None` on every other row, including a plain `list_item`'s. The
118    /// row's glyphs already draw the box as `☐ `/`☑ ` in the marker's place, so a
119    /// plain surface needs nothing further; a GUI reads this to paint a real
120    /// checkbox widget and to know which way it is facing.
121    ///
122    /// A `bool` rather than a source span, for the reason
123    /// [`code_lang`](Self::code_lang) is a plain string: it rides [`BlockCache`]
124    /// reuse and [`build_spliced`] untouched, needing no offset shifting. To
125    /// *toggle* the box, a frontend maps its click to a source offset the way it
126    /// maps any other — the marker's glyphs carry the item's own `src` — and
127    /// hands that to [`crate::Doc::toggle_task_at`].
128    pub task: Option<bool>,
129    /// Set on the single placeholder row a **leaf** directive (`::name{…}`)
130    /// renders to, carrying its name and attributes; `None` on every other row.
131    /// The container form isn't this — it wraps real blocks and marks each of
132    /// them [`directive`](Self::directive) instead. Like [`media`](Self::media)
133    /// it's plain display strings, so it rides row reuse untouched, and the map's
134    /// [`directives`](VisualMap::directives) side-table is derived from it once
135    /// the rows are final.
136    pub leaf_directive: Option<DirectiveMark>,
137    /// The heading level (1–6) of the block this row belongs to, on every row a
138    /// `heading` emits (a long one wraps to several) and `None` everywhere else.
139    ///
140    /// A frontend that sizes a whole line — a proportional renderer giving the
141    /// row a bigger line box — needs the level *per row*, and the glyphs can't
142    /// always supply it: an empty heading (`# ` with nothing typed after it,
143    /// which is what the toolbar's H1 leaves on a blank line) has no glyph to
144    /// carry a [`Role::Heading`] at all, so a glyph scan called it body text and
145    /// the line drew at body height until the first character landed. Riding the
146    /// row says it once, for the empty case and the wrapped case alike.
147    ///
148    /// Per-*glyph* styling still comes from [`Role::Heading`] on the glyphs; this
149    /// is the row-level fact, and the two agree wherever a heading has content —
150    /// same `u8` level, clamped the same way [`heading_style`] clamps it.
151    pub heading: Option<u8>,
152    /// What this row divides, on the blank rows a block boundary is *drawn* with
153    /// and `None` on every other row — including the navigable blank lines of
154    /// preserve-soft flow, which are somewhere text can go rather than a gap
155    /// between blocks. So `boundary.is_some()` is exactly "this row is a drawn
156    /// block boundary", the [`decoration`](Self::decoration) rows that come from
157    /// [`Builder::emit_separators_before`].
158    ///
159    /// It exists because a boundary's *height* is a frontend decision but its
160    /// *kind* is not. Typography spaces a boundary by what it separates — the
161    /// margin above a heading is wider than the one between two paragraphs, so
162    /// the heading groups with the text it introduces — and a frontend that has
163    /// only rows to look at has to re-derive the structure by sniffing glyph
164    /// roles. Three frontends sniffing separately is three chances to disagree
165    /// about the same document. Core already knows, having just walked the AST
166    /// to emit this row, so it says so once here and each frontend multiplies by
167    /// its own spacing.
168    pub boundary: Option<Boundary>,
169    /// The offsets on this row where an inline mark's *content* ends under a
170    /// hidden closing delimiter — the end of the `d` in `**bold**`, one byte
171    /// before the `**` that draws nothing. Each is a caret stop with no glyph
172    /// of its own: the caret standing there is drawn where the next glyph is,
173    /// but typing there extends the mark, where typing past the delimiter
174    /// leaves it. See [`VisualMap::mark_ends`] for the rule.
175    ///
176    /// Source offsets, so [`shift_row`] moves them with the glyphs; empty on
177    /// decoration rows and on every row no mark closes on.
178    pub mark_ends: Vec<usize>,
179}
180
181/// What a drawn block boundary separates: the kinds of the blocks it falls
182/// between — the pair a frontend spaces by.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub struct Boundary {
185    pub above: BlockClass,
186    pub below: BlockClass,
187}
188
189/// The block kinds core tells apart when it walks a document — the vocabulary
190/// [`Boundary`] is spelled in. A statement about *structure*, not about how any
191/// of it should look: what a frontend does with "this gap sits above a heading"
192/// is entirely the frontend's.
193///
194/// `Class` rather than `Kind` because [`twig::BlockKind`] already means
195/// something else in this crate's public surface — the *command* vocabulary
196/// (`Paragraph | Heading(n)`) a toolbar passes to [`Doc::set_block`](crate::Doc::set_block).
197/// This is the reverse direction: what a block already *is*, read back off a
198/// rendered row.
199///
200/// [`BlockClass::Other`] is the honest answer for a node kind core doesn't
201/// separate out, so adding one here is additive for every frontend: nothing has
202/// to change until it wants to space that kind differently.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub enum BlockClass {
205    Paragraph,
206    Heading,
207    /// A whole list. Its *items* are [`BlockClass::ListItem`]; note that core
208    /// draws no boundary row between two items of one list, tight or loose, so
209    /// an item↔item pair never reaches a frontend.
210    List,
211    ListItem,
212    Quote,
213    Code,
214    Table,
215    /// A block-level image, video, or audio.
216    ///
217    /// Never reached through [`from_node_kind`](BlockClass::from_node_kind): a
218    /// block picture is not a node of its own — [`Builder::media_only`] promotes
219    /// the paragraph (or `<picture>`/`<video>` container) wrapping it — so the
220    /// walk only ever sees the wrapper's kind. [`label_media_boundaries`] reads
221    /// it back off the finished rows instead, after the fact.
222    Media,
223    /// A `:::name{.class}` directive container.
224    Directive,
225    Rule,
226    Footnote,
227    Other,
228}
229
230impl BlockClass {
231    /// Classify a twig node kind — the same vocabulary [`Builder::block`]
232    /// matches on, so the two can't drift about what a block is. Both the
233    /// whole-arena walk (which has [`FlatNode`]s) and the incremental top-level
234    /// walk (which has only a query match's kind) reach it by this one door.
235    pub fn from_node_kind(kind: &Kind) -> BlockClass {
236        match kind {
237            Kind::Para => BlockClass::Paragraph,
238            Kind::Heading => BlockClass::Heading,
239            Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
240            Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
241            Kind::BlockQuote => BlockClass::Quote,
242            Kind::CodeBlock => BlockClass::Code,
243            Kind::Table => BlockClass::Table,
244            Kind::Image => BlockClass::Media,
245            // twig 2.8 folded `div`/`span`/`directive`/`element` into one
246            // `container` kind, so a `:::note` panel and a promoted `<video>`
247            // arrive here indistinguishable — telling them apart needs the
248            // node's `origin`, and the incremental walk has only this kind.
249            // `Directive` is the right answer for the case that motivates the
250            // class (nothing else draws a tinted panel) and a harmless one for
251            // the rest: `BlockClass` is descriptive and core never branches on
252            // it. The one case where it was actively wrong — a promoted
253            // `<video>`, which would have been handed to a frontend as something
254            // to draw a fenced-div panel around — is corrected by
255            // [`label_media_boundaries`] once the rows are final, along the same
256            // door as a block image. Anything else that must be exact reads
257            // [`container_is_directive`] off a real node.
258            Kind::Container => BlockClass::Directive,
259            Kind::ThematicBreak => BlockClass::Rule,
260            Kind::Footnote => BlockClass::Footnote,
261            _ => BlockClass::Other,
262        }
263    }
264}
265
266/// The name and attributes a leaf directive's placeholder row carries, so a
267/// frontend that knows the host app's vocabulary can paint the real thing —
268/// an embedded page for diaryx's `::embed{src=…}`, a generated table of
269/// contents for a `::toc`, and the plain `⧉ name` label for one it doesn't
270/// know. The peer of [`MediaMark`], and plain strings for the same reason: they
271/// survive the row shuffling of [`BlockCache`] reuse and [`build_spliced`].
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct DirectiveMark {
274    /// The directive's type — `embed`, `toc`, `vis` — with no leading colons.
275    /// Core is agnostic of what it means: the vocabulary is the host app's.
276    pub name: String,
277    /// Its `{…}` attributes as `(key, value)` pairs in source order. A bare
278    /// attribute (`{public}`) has a `None` value, the way twig reports it.
279    pub attrs: Vec<(String, Option<String>)>,
280    /// The directive's `[label]` text, flattened from its inline children, or
281    /// empty when it has none. Also what the placeholder label shows.
282    pub label: String,
283    /// How many visual rows this directive reserves — the label row plus blank
284    /// filler rows below it, so a frontend painting something real has the
285    /// vertical room. `1` is the bare placeholder, and the only value core
286    /// produces today: unlike an image (whose height a terminal frontend
287    /// measures and reports back), nothing has told core how tall an embed is.
288    /// A pixel-laid-out GUI sets its own height regardless.
289    pub rows: usize,
290}
291
292/// What a block-level media placeholder actually is, so a frontend knows which
293/// widget to build over the reserved rows: a raster, a movie player, or a
294/// transport with no picture at all. Core classifies and stops there — it opens
295/// nothing, so this is a statement about the *markup*, not about a file it has
296/// verified exists or can decode.
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298pub enum MediaKind {
299    /// A `![](…)` / `<img>` / `<picture>` — a still picture.
300    Image,
301    /// An HTML `<video>`. Markdown and Djot spell no video of their own, so this
302    /// only ever arrives through `html_elements` promotion (or a `::video{…}`
303    /// directive a host app maps itself, which core reports as a directive).
304    Video,
305    /// An HTML `<audio>` — a transport with no picture, so a frontend gives it a
306    /// fixed control height rather than measuring an aspect ratio.
307    Audio,
308}
309
310/// Which of the two caret homes a block media has — see
311/// [`VisualMap::block_media_stop`].
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub enum MediaStop {
314    /// The stop in front of the picture. What is typed here belongs above it.
315    Before,
316    /// The stop just past it. What is typed here belongs below it.
317    After,
318}
319
320impl MediaKind {
321    /// The emoji a plain surface prefixes the placeholder label with — the
322    /// `🖼`/`🎬`/`🔊` that makes the row read as *a thing* rather than as text.
323    fn sigil(self) -> char {
324        match self {
325            MediaKind::Image => '🖼',
326            MediaKind::Video => '🎬',
327            MediaKind::Audio => '🔊',
328        }
329    }
330}
331
332/// The destination and label a block-level media placeholder row carries, so a
333/// capable frontend can resolve and paint the real thing. Plain strings (no
334/// source offsets), so they survive the row shuffling of [`BlockCache`] reuse
335/// and [`build_spliced`] untouched — see [`VRow::media`].
336#[derive(Clone, Debug, PartialEq, Eq)]
337pub struct MediaMark {
338    /// Whether this is a picture, a movie, or a sound — which widget the
339    /// frontend builds over the reserved rows.
340    pub kind: MediaKind,
341    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
342    /// the AST. A frontend resolves a relative path against the document's
343    /// directory itself; core holds no I/O.
344    ///
345    /// Empty is possible and legal for a `<video>`/`<audio>`, which may carry no
346    /// `src` of its own and name its candidates in child `<source>`s instead —
347    /// unlike an `<img>`, whose `src` *is* the picture. A frontend with an empty
348    /// destination takes its URL from [`sources`](MediaMark::sources).
349    pub destination: String,
350    /// A `<picture>`'s theme/media alternatives, in document order, when this
351    /// block image came from one; empty for a plain `![](…)` / bare `<img>`. Each
352    /// is a `<source>`'s media query + candidate URL(s); a frontend that knows its
353    /// theme picks the first whose media matches and falls back to [`destination`]
354    /// (the `<img>`). Core keeps them verbatim and picks nothing — it has no theme.
355    ///
356    /// [`destination`]: MediaMark::destination
357    pub sources: Vec<MediaSource>,
358    /// The media's alt text (its rendered inline children, flattened), or empty
359    /// when it has none. Also what the placeholder label shows. For a `<video>`/
360    /// `<audio>` this is the element's own text content — the "your browser does
361    /// not support…" fallback, which doubles as its accessible name.
362    pub alt: String,
363    /// A `<video poster="…">`'s still frame, verbatim, or empty when there is
364    /// none (and always empty for an image or audio). It is an *image*
365    /// destination, so a frontend already able to draw a picture can show it
366    /// before the movie loads — or in place of one it can't play at all.
367    pub poster: String,
368    /// How many visual rows this media reserves — the placeholder label row plus
369    /// the blank filler rows below it, so a frontend that paints a real raster has
370    /// the vertical room to draw it. `1` is the bare placeholder (a frontend that
371    /// can't draw pictures, or an image it couldn't resolve). A terminal frontend
372    /// asks for as many rows as the fitted picture is tall; the pixel-laid-out GUI
373    /// ignores this and sets its own row height, so it always leaves it `1`. The
374    /// count comes from the frontend (via [`crate::Doc::set_media_rows`]) because
375    /// core does no I/O and can't measure the image itself. See [`VRow::media`].
376    pub rows: usize,
377}
378
379/// One `<source>` under a `<picture>`, `<video>`, or `<audio>`: a candidate URL
380/// plus whichever of the two things HTML lets a `<source>` be chosen by — a
381/// media query (`<picture>`) or a MIME type (`<video>`/`<audio>`). Verbatim from
382/// the AST: core carries the alternatives and resolves none of them, having
383/// neither a theme nor a codec list to judge them by.
384///
385/// The two spellings are normalised onto one field. `<picture>` writes
386/// `srcset`, `<video>`/`<audio>` write `src`; both land in
387/// [`srcset`](MediaSource::srcset), since a frontend wants the URL either way
388/// and only `<picture>` ever uses the descriptor syntax.
389#[derive(Clone, Debug, PartialEq, Eq)]
390pub struct MediaSource {
391    /// The `<source media="…">` query, verbatim (`"(prefers-color-scheme: dark)"`),
392    /// or empty for a `<source>` with no `media` (an unconditional override, and
393    /// the norm for `<video>`/`<audio>`, which pick by codec rather than theme).
394    pub media: String,
395    /// The candidate URL(s): a `<picture>`'s `srcset` verbatim — one URL, or a
396    /// comma-separated candidate list with `1x`/`2x`/width descriptors — or a
397    /// `<video>`/`<audio>` `<source>`'s plain `src`. A frontend takes the first
398    /// URL token; the theme and codec cases both only ever need that.
399    pub srcset: String,
400    /// The `<source type="…">` MIME type (`"video/webm"`), verbatim, or empty
401    /// when the `<source>` declares none. How a `<video>`/`<audio>` frontend
402    /// picks a candidate it can actually decode; a `<picture>`'s sources
403    /// normally leave it empty and are chosen by [`media`](MediaSource::media).
404    pub mime: String,
405}
406
407/// The rendered document plus the offset⇄position mapping the caret rides on.
408#[derive(Clone, Default)]
409pub struct VisualMap {
410    /// The document's **default monospace rendering** — one [`VRow`] of glyphs
411    /// per visual line, tables spelled with box-drawing borders (`│ ─ ┌┬┐…`) and
412    /// cells padded to whole character-cell columns. Any monospace surface can
413    /// draw these verbatim, so a consumer gets a working view for free: the TUI
414    /// paints them as-is, and a five-line plain-text dump would too.
415    ///
416    /// It's a *default*, not the only truth. A frontend with its own geometry —
417    /// a proportional GUI — lays text out in its own units, and for a table
418    /// skips the box-drawn rows named by [`TableInfo::rows_span`] and draws from
419    /// the structural [`TableInfo`] instead. The box glyphs live here rather than
420    /// in a frontend precisely because they *are* a renderable default: unlike a
421    /// colour (a role each surface must map to its own palette — see
422    /// [`crate::style`]), `┌─┐` is finished text that needs no interpretation.
423    pub rows: Vec<VRow>,
424    /// The first source offset that is actually rendered — the caret floor for
425    /// the WYSIWYG view. Non-zero when a leading `metadata` block (YAML/TOML
426    /// frontmatter) is skipped: the frontmatter is preserved in the source and
427    /// editable in the source view, but hidden and unreachable here, so the
428    /// caret and selection can't wander into it (and copy won't grab it).
429    pub content_start: usize,
430    /// Every offset the caret may rest at, ascending and deduplicated: each
431    /// row's stop glyphs plus the row's own end (the "after the last character"
432    /// spot every line needs). Decoration contributes nothing.
433    ///
434    /// Left/Right read this instead of walking the grid, because the grid isn't
435    /// laid out in offset order: a table with wrapped cells puts column 1's
436    /// second line *below* column 2's first, so "the next stop rightward" and
437    /// "the next stop in the document" part ways. Following the document is what
438    /// a caret means — and on every row that *is* in order the two agree anyway,
439    /// so nothing else has to change.
440    stops: Vec<usize>,
441    /// The caret's second home at the end of every hidden inline mark: the
442    /// offset where the mark's content ends, one byte before its closing
443    /// delimiter — ascending and deduplicated, from every row's
444    /// [`VRow::mark_ends`].
445    ///
446    /// With delimiters hidden, `**bold** tail` draws one spot after the `d`
447    /// and the source has two offsets for it: the content end (inside the
448    /// mark, where typing extends the bold) and the byte past the `**` (where
449    /// typing leaves it). Only the second is a glyph's offset, so only it was
450    /// a stop, and a caret asked to rest at the first was snapped a whole
451    /// character back onto the `d` — a drag over `bold` came back one letter
452    /// short. The delete and backspace paths already settle the caret on the
453    /// content end as its natural home there
454    /// ([`crate::Doc::settle_inside_close_delims`]); this makes it one the
455    /// caret can be placed at and step onto too.
456    ///
457    /// Kept apart from [`stops`](Self::stops) rather than merged in, because
458    /// the two lists answer different questions. A stop with no glyph is
459    /// invisible to a walk that pairs stops with characters — a system text
460    /// input counting `position(from:offset:)` steps against the text it was
461    /// shown would drift a character at every mark — and to word motion, which
462    /// classifies a stop by the source byte under it (a `*`). So
463    /// [`stop_after`](Self::stop_after) and its kin walk the glyph stops alone,
464    /// and only the places a caret *rests* — snapping, resting checks, and
465    /// Left/Right — read both.
466    mark_ends: Vec<usize>,
467    /// Every table in the document, in order, described structurally rather than
468    /// drawn — see [`TableInfo`] for why both exist.
469    pub tables: Vec<TableInfo>,
470    /// Every fenced/indented code block, in order, as the range of [`rows`] it
471    /// occupies — a frontend draws one bordered, tinted box around each and
472    /// scrolls it horizontally rather than wrapping. Derived from the per-row
473    /// [`VRow::code`] flag once the rows are final (so it survives incremental
474    /// row reuse), the same way [`collect_stops`] derives the stop table.
475    ///
476    /// [`rows`]: VisualMap::rows
477    pub code_blocks: Vec<CodeBlockInfo>,
478    /// Every block-level image in the document, in order — one per placeholder
479    /// row a frontend replaces with a real picture. Derived from the per-row
480    /// [`VRow::media`] mark once the rows are final (so it survives incremental
481    /// row reuse), the same way [`code_blocks`](VisualMap::code_blocks) is
482    /// derived from [`VRow::code`].
483    pub media: Vec<MediaInfo>,
484    /// Every **leaf** directive in the document, in order — one per placeholder
485    /// row a frontend may replace with whatever the host app's vocabulary makes
486    /// of it. Derived from the per-row [`VRow::leaf_directive`] mark once the
487    /// rows are final, exactly as [`media`](VisualMap::media) is.
488    pub directives: Vec<DirectiveInfo>,
489}
490
491impl VisualMap {
492    pub fn num_rows(&self) -> usize {
493        self.rows.len()
494    }
495
496    /// The width of `row` in display columns — the rightmost column its caret
497    /// can occupy, and so what a goal column is clamped to on the way in.
498    pub fn row_width(&self, row: usize) -> usize {
499        self.rows.get(row).map_or(0, |r| r.width())
500    }
501
502    /// The screen `(row, col)` for a source offset — where to draw the caret:
503    /// the *nearest* stop at or past `off`. Snaps a hidden offset (inside a
504    /// delimiter) to the next visible glyph, and never resolves onto decoration
505    /// (a table border, a cell's padding), which is drawn but holds no caret.
506    ///
507    /// "Nearest" rather than "the first one found" because a table's wrapped
508    /// cells put rows slightly out of offset order: scanning top to bottom, the
509    /// second line of column 1 comes *after* the first line of column 2 but
510    /// holds smaller offsets. Where rows are in order the two rules agree.
511    ///
512    /// A soft wrap is the one place two rows want the same offset: the row above
513    /// ends where the row below opens, the space the wrap ate being drawn on the
514    /// row above and the offset past it being the row below's first character.
515    /// It resolves *downstream*, to the row that character is on — the row
516    /// above's last column is a phantom, a place the caret can be drawn but
517    /// never sent, and resolving upstream into it is what pinned Down at the
518    /// first wrap of a paragraph: it aimed at the row below's column 0, landed
519    /// on the offset it already had, and read that back as the row above's end.
520    pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
521        let mut best: Option<(usize, usize, usize)> = None; // (src, row, col)
522        for (r, row) in self.rows.iter().enumerate() {
523            if row.decoration {
524                continue;
525            }
526            // Offsets ascend *within* a row, so its first stop at or past `off`
527            // is the best this row has to offer.
528            let cand = row
529                .glyphs
530                .iter()
531                .enumerate()
532                .find(|(_, g)| g.stop && g.src >= off)
533                .map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
534                .or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
535            if let Some(c) = cand {
536                // `<=`, so a tie goes to the later row: the only offset two rows
537                // both hold is a wrap boundary, and it belongs to the row below.
538                if best.is_none_or(|b| c.0 <= b.0) {
539                    best = Some(c);
540                }
541            }
542            // A row's *first* stop never decreases from one row to the next —
543            // true even across a table's wrapped cells, since a cell's lines run
544            // downward. So once a row opens past the best found so far, no later
545            // row can beat it and the scan stays proportional to `off`.
546            if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
547                && first.src > b.0
548            {
549                break;
550            }
551        }
552        match best {
553            Some((_, r, c)) => (r, c),
554            None => {
555                let r = self.last_stop_row();
556                (r, self.row_width(r))
557            }
558        }
559    }
560
561    /// The rows a source range occupies, inclusive: `(first, last)`.
562    ///
563    /// A *different question* from [`pos_of_offset`](Self::pos_of_offset), which
564    /// is why it can't be spelled with two calls to it. That one answers "where
565    /// does the caret go", and for a caret its forward snap is right — an offset
566    /// inside a hidden delimiter has no column of its own, so the caret belongs
567    /// at the next visible glyph, wherever that turns out to be. This one asks
568    /// "which rows does this block cover", and there the snap is a trap: a
569    /// footnote whose body *ends* in a link (`[^2]: [title](url)`) has a last
570    /// byte inside the hidden destination, so `pos_of_offset(end - 1)` walked
571    /// clean off the note's row and landed on the next note's — and a peek
572    /// slicing `first..=last` out of the frame drew two notes where the reader
573    /// asked for one. Every block ending in a link, an image, or any trailing
574    /// hidden markup had the same fault; only a block ending in visible text
575    /// (which is what the tests happened to use) did not.
576    ///
577    /// `row.end_src` is no help either: it is where the *rendered* text of a row
578    /// ends, not how far into the source the block reaches, and redefining it
579    /// would move every end-of-line caret.
580    ///
581    /// So the last row is found by asking which rows *open* before the range
582    /// does, rather than by mapping its last byte: a row belongs to the range
583    /// when its first caret stop lies before `range.end`. Decoration is skipped
584    /// (a drawn gap between blocks is not part of either), and the answer is
585    /// never shorter than one row — a range whose every byte is hidden still
586    /// covers the row it started on.
587    pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
588        if self.rows.is_empty() {
589            return (0, 0);
590        }
591        let first = self.pos_of_offset(range.start).0;
592        let mut last = first;
593        for (r, row) in self.rows.iter().enumerate().skip(first) {
594            if row.decoration {
595                continue;
596            }
597            let open = row
598                .glyphs
599                .iter()
600                .find(|g| g.stop)
601                .map_or(row.end_src, |g| g.src);
602            if open >= range.end {
603                // A row's first stop never decreases from one row to the next —
604                // the invariant `pos_of_offset` breaks on, true even across a
605                // table's wrapped cells — so nothing below can be in range.
606                break;
607            }
608            last = r;
609        }
610        (first, last)
611    }
612
613    /// The source offset of the task checkbox drawn at `(row, col)`, or `None`
614    /// when that cell holds no box — the hit-test a frontend runs on a click
615    /// before treating it as a tick rather than a caret placement.
616    ///
617    /// Only the box's own cells answer. Clicking an item's *text* places the
618    /// caret like any other click, so the box is a target aimed at rather than
619    /// something tripped over while editing — which is also why this is a
620    /// separate question from [`offset_of_pos`](Self::offset_of_pos) instead of
621    /// a flag on the offset it returns.
622    pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
623        let r = self.rows.get(row)?;
624        self.task_box_at_glyph(row, r.glyph_at_col(col)?)
625    }
626
627    /// [`task_box_at`](Self::task_box_at) keyed by glyph index rather than
628    /// display column — for a frontend that shapes its own rows (the GUI) and so
629    /// resolves a click to a glyph before it ever has a column.
630    pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
631        let r = self.rows.get(row)?;
632        r.task?;
633        let g = r.glyphs.get(glyph)?;
634        (g.style.role == Role::ListMarker).then_some(g.src)
635    }
636
637    /// The source offset for a screen `(row, col)` — where a click or a
638    /// visual-space move lands the caret. Clicking decoration maps through its
639    /// `src`, which points at the text it decorates, so a click on a border or
640    /// on a cell's padding lands in that cell.
641    ///
642    /// The inverse of [`pos_of_offset`](Self::pos_of_offset), which it has to
643    /// agree with: `col` is a display column, and the one it names may be the
644    /// far cell of a wide glyph — [`VRow::glyph_at_col`] is where that lands.
645    pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
646        let Some(r) = self.rows.get(row) else {
647            // A click or drag below the last row — a short document with empty
648            // space under it, dragged into to extend a selection. Land on the
649            // document's last caret stop (its end), not offset 0: jumping the
650            // caret to the top is the wrong direction, and 0 isn't even a stop
651            // when the document opens on hidden frontmatter or a `# ` marker, so
652            // returning it would leave the caret where it draws in one place and
653            // types in another (`move_to` would then clamp it onto the unhomeable
654            // frontmatter floor). `None` only for a document with no stops at all
655            // (empty), where the caret has nowhere to be but 0.
656            return self.stops.last().copied().unwrap_or(0);
657        };
658        match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
659            // A glyph that holds no caret is clickable, but where it points
660            // isn't always somewhere the caret can be: the blank gap between two
661            // paragraphs stands at an offset that belongs to neither of them,
662            // and the tail of a grapheme cluster stands inside a character.
663            // Land on the nearest real stop instead of handing back an offset
664            // that looks like the gap but types into the paragraph above.
665            Some(g) if !g.stop => self.nearest_stop(g.src),
666            Some(g) => g.src,
667            // A row's end is a stop by construction — unless the row is
668            // decoration, which contributes none.
669            None if r.decoration => self.nearest_stop(r.end_src),
670            None => r.end_src,
671        }
672    }
673
674    /// Which of a block media's two caret homes `off` is, or `None` for every
675    /// other offset in the document.
676    ///
677    /// [`block_media`](Builder::block_media) gives a block-level image, video, or
678    /// audio exactly two stops — one in front of it and one just past it — and
679    /// nothing inside the markup. Both are ordinary offsets to everything else in
680    /// core, but they are the two places where inserting text would *dissolve the
681    /// picture*: `![](p.png)` with anything typed against it is no longer a block
682    /// image but a paragraph with an inline one, and the frontend that was
683    /// painting a photo there paints a text run instead. A caller that is about to
684    /// insert asks this so it can open a paragraph first — see
685    /// [`Doc::insert`](crate::Doc::insert).
686    ///
687    /// An *inline* image reports `None`: it has no placeholder row and no stops of
688    /// its own, and typing beside one is ordinary editing.
689    ///
690    /// Answers with the media's own source span as well, since a caller that has
691    /// to keep the picture whole usually has to address it — [`Doc::backspace`]
692    /// takes the picture out in one piece rather than nibbling a byte off its
693    /// markup, which is the same dissolution from the other side.
694    ///
695    /// [`Doc::backspace`]: crate::Doc::backspace
696    pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
697        for m in &self.media {
698            let Some(row) = self.rows.get(m.rows_span.start) else {
699                continue;
700            };
701            // Every glyph of the `🖼 alt` label maps to the media's start offset;
702            // the row's end is past its markup. Read the start off the label
703            // rather than the first glyph, which on a quoted or listed picture is
704            // the block prefix and points at the gutter.
705            let Some(start) = row
706                .glyphs
707                .iter()
708                .find(|g| g.style.role == Role::Image)
709                .map(|g| g.src)
710            else {
711                continue;
712            };
713            if off == start {
714                return Some((MediaStop::Before, start..row.end_src));
715            }
716            if off == row.end_src {
717                return Some((MediaStop::After, start..row.end_src));
718            }
719        }
720        None
721    }
722
723    /// Snap `off` to the nearest caret stop — the funnel a frontend that
724    /// hit-tests pixels straight to a source offset must run its result through.
725    /// A click or drag can land in the blank gap a paragraph break is drawn with,
726    /// or inside a hidden delimiter; both are offsets the caret can't rest at, so
727    /// resting there would draw the caret in one place and type in another. This
728    /// settles it on a real caret home instead. Idempotent on an offset that is
729    /// already a stop — the `(row, col)` click path already snaps this way inside
730    /// [`offset_of_pos`](Self::offset_of_pos), and this gives the pixel path the
731    /// same guarantee. Returns `off` unchanged only for an empty document (no
732    /// stops at all).
733    pub fn snap_to_stop(&self, off: usize) -> usize {
734        self.nearest_stop(off)
735    }
736
737    /// The caret stop nearest `off`, preferring the one before it when `off`
738    /// falls exactly between two. Returns `off` unchanged if there are no stops
739    /// at all (an empty document). A mark's content end counts: it is a place
740    /// the caret rests, and the one a drag ending on a marked word means.
741    fn nearest_stop(&self, off: usize) -> usize {
742        let before = Self::last_at_or_before(&self.stops, off)
743            .max(Self::last_at_or_before(&self.mark_ends, off));
744        let after = match (
745            Self::first_at_or_after(&self.stops, off),
746            Self::first_at_or_after(&self.mark_ends, off),
747        ) {
748            (Some(a), Some(b)) => Some(a.min(b)),
749            (a, b) => a.or(b),
750        };
751        match (before, after) {
752            (Some(b), Some(a)) if off - b <= a - off => b,
753            (_, Some(a)) => a,
754            (Some(b), None) => b,
755            (None, None) => off,
756        }
757    }
758
759    /// The glyph stop nearest `off` — [`nearest_stop`](Self::nearest_stop)
760    /// for a walk that pairs stops with characters, which a mark's content
761    /// end has none of. A caret resting on one resolves to the glyph stop
762    /// drawn at the same spot, the one just past the hidden delimiter, so the
763    /// text a system input is shown from there and the steps it counts agree.
764    pub fn snap_to_glyph_stop(&self, off: usize) -> usize {
765        if self.mark_ends.binary_search(&off).is_ok()
766            && let Some(next) = Self::first_at_or_after(&self.stops, off)
767        {
768            return next;
769        }
770        let before = Self::last_at_or_before(&self.stops, off);
771        let after = Self::first_at_or_after(&self.stops, off);
772        match (before, after) {
773            (Some(b), Some(a)) if off - b <= a - off => b,
774            (_, Some(a)) => a,
775            (Some(b), None) => b,
776            (None, None) => off,
777        }
778    }
779
780    /// The last of `sorted` at or before `off`, if any.
781    fn last_at_or_before(sorted: &[usize], off: usize) -> Option<usize> {
782        let i = sorted.partition_point(|&s| s <= off);
783        i.checked_sub(1).map(|i| sorted[i])
784    }
785
786    /// The first of `sorted` at or after `off`, if any.
787    fn first_at_or_after(sorted: &[usize], off: usize) -> Option<usize> {
788        let i = sorted.partition_point(|&s| s < off);
789        sorted.get(i).copied()
790    }
791
792    /// The next place the caret rests past `off` — the next glyph stop or the
793    /// next mark's content end, whichever comes first. What Right walks:
794    /// leaving `**bold**` from the `d` is two presses, one onto the end of the
795    /// bold (still bold, the toolbar lit) and one past its delimiter, at the
796    /// same spot on screen. [`stop_after`](Self::stop_after) is the walk that
797    /// skips the first, for every caller that pairs stops with characters.
798    pub fn caret_stop_after(&self, off: usize) -> Option<usize> {
799        match (
800            self.stop_after(off),
801            Self::first_at_or_after(&self.mark_ends, off + 1),
802        ) {
803            (Some(a), Some(b)) => Some(a.min(b)),
804            (a, b) => a.or(b),
805        }
806    }
807
808    /// The previous place the caret rests before `off` — the mirror of
809    /// [`caret_stop_after`](Self::caret_stop_after), what Left walks.
810    pub fn caret_stop_before(&self, off: usize) -> Option<usize> {
811        self.stop_before(off).max(
812            off.checked_sub(1)
813                .and_then(|o| Self::last_at_or_before(&self.mark_ends, o)),
814        )
815    }
816
817    /// Whether the caret can occupy `row` at all: decoration rows (a table's
818    /// border rules) are stepped over by vertical motion.
819    pub fn row_is_navigable(&self, row: usize) -> bool {
820        self.rows.get(row).is_some_and(|r| !r.decoration)
821    }
822
823    /// The first offset the caret can rest at on `row` — its first stop, or the
824    /// row's own end when it holds no text (an empty paragraph). `None` for a
825    /// decoration row, which holds no caret at all.
826    ///
827    /// Not `offset_of_pos(row, 0)`: column 0 of a quoted or listed row is the
828    /// gutter, and a gutter's `src` points at the *block* it opens, so the stop
829    /// nearest it is the one on the block's first row rather than on this one.
830    /// Which is right for a click — the gutter decorates the whole block — and
831    /// wrong for Home, whose whole question is where *this* row starts.
832    pub fn row_start(&self, row: usize) -> Option<usize> {
833        let r = self.rows.get(row).filter(|r| !r.decoration)?;
834        Some(
835            r.glyphs
836                .iter()
837                .find(|g| g.stop)
838                .map_or(r.end_src, |g| g.src),
839        )
840    }
841
842    /// The last row the caret can rest on — the fallback when an offset is past
843    /// everything rendered (a table's bottom border must not swallow the caret).
844    fn last_stop_row(&self) -> usize {
845        (0..self.rows.len())
846            .rev()
847            .find(|&r| self.row_is_navigable(r))
848            .unwrap_or(0)
849    }
850
851    /// The nearest row above `row` the caret can occupy, skipping decoration.
852    pub fn navigable_above(&self, row: usize) -> Option<usize> {
853        (0..row.min(self.rows.len()))
854            .rev()
855            .find(|&r| self.row_is_navigable(r))
856    }
857
858    /// The nearest row below `row` the caret can occupy, skipping decoration.
859    pub fn navigable_below(&self, row: usize) -> Option<usize> {
860        ((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
861    }
862
863    /// The caret stop just before `off` — one press of Left. `None` at the
864    /// first stop in the document.
865    ///
866    /// Runs of decoration (a table border, a cell's alignment padding) are
867    /// stepped over in a single press: they hold no stop, so they aren't in the
868    /// table to land on.
869    pub fn stop_before(&self, off: usize) -> Option<usize> {
870        let i = self.stops.partition_point(|&s| s < off);
871        i.checked_sub(1).map(|i| self.stops[i])
872    }
873
874    /// The caret stop just after `off` — one press of Right. `None` at the last
875    /// stop in the document.
876    pub fn stop_after(&self, off: usize) -> Option<usize> {
877        let i = self.stops.partition_point(|&s| s <= off);
878        self.stops.get(i).copied()
879    }
880
881    /// The first caret stop at or past `off` — where the caret at a hidden
882    /// offset is *drawn*, and so where a rightward walk over the rendered text
883    /// starts from.
884    pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
885        let i = self.stops.partition_point(|&s| s < off);
886        self.stops.get(i).copied()
887    }
888
889    /// The last caret stop at or before `off` — where a leftward walk starts
890    /// from. Snapping the way the walk is headed, rather than always forward,
891    /// is what keeps a leftward motion from ever moving the caret right.
892    pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
893        let i = self.stops.partition_point(|&s| s <= off);
894        i.checked_sub(1).map(|i| self.stops[i])
895    }
896
897    /// Whether the caret may rest at `off` — the invariant every motion in this
898    /// view has to leave standing. A glyph stop, a row's end, or a hidden
899    /// mark's content end ([`mark_ends`](Self::mark_ends)).
900    pub fn is_stop(&self, off: usize) -> bool {
901        self.stops.binary_search(&off).is_ok() || self.mark_ends.binary_search(&off).is_ok()
902    }
903
904    /// The visible text a caret crosses walking rightward from `from` up to
905    /// (but not including) `to` — `UITextInput.text(in:)`'s `[from, to)` in
906    /// *this* view. A hidden inline-mark delimiter (`**`, `` ` ``, `_`, an
907    /// escape backslash) never got a glyph in the first place — see
908    /// [`push_text`]/[`synth`] — so it contributes nothing; what's left is
909    /// what's drawn on screen for that span, one character per caret stop.
910    ///
911    /// **Exactly one character per stop** is the contract, and it is the
912    /// system text input's, not a nicety: `UITextInput`'s tokenizer reads a
913    /// window of this text around a tap, indexes into it by the integer
914    /// `offset(from:to:)` reports (`distance_offset` in `leaf-ffi`, a count of
915    /// [`stop_after`](Self::stop_after) hops), finds a word boundary at some
916    /// character index, and hands the delta back through
917    /// `position(from:offset:)`, which hops stops again. If the text ever
918    /// spends a character on something that is not a stop, or a stop on
919    /// nothing, every index past that point is off by one and the word the
920    /// reader double-tapped comes back shifted — into the header row of a
921    /// table, or one letter short. So a stop that draws a glyph is spelled
922    /// as that glyph, and a stop that draws none is spelled `'\n'`:
923    ///
924    /// - a row's own end stop ([`VRow::end_src`]) — the caret home past a
925    ///   paragraph's, heading's, list item's, or code line's last glyph. This
926    ///   is also what keeps two blocks' words apart: without it the last word
927    ///   of one paragraph and the first of the next read as one run of
928    ///   letters (`"…edb\n\nhello\n"` came back as `"edbhello"`), and the
929    ///   tokenizer selected across the boundary. A list item's end is a
930    ///   row end like any other, though no blank gap row follows it.
931    /// - a table cell's end, which [`push_table_row`] draws as the gutter
932    ///   space before the next `│` so the caret has somewhere to stand past
933    ///   the cell's last character. To a reader of *this* text a cell ends a
934    ///   line: spelled as a space, a touch surface that lands a tap at a
935    ///   word's end past the space that follows it stepped into the next
936    ///   cell — or the next row, from the last column.
937    ///
938    /// A hidden mark's content end ([`mark_ends`](Self::mark_ends)) is a place
939    /// the caret rests but not a stop the walks above count, so it has no
940    /// character here either; `from` is snapped to the glyph stop drawn at
941    /// the same spot first, exactly as [`snap_to_glyph_stop`] does for those
942    /// walks. `to` is left as given, so a stop landing exactly on it is still
943    /// excluded — the same half-open range `distance_offset`'s loop counts.
944    ///
945    /// [`push_table_row`]: Builder::push_table_row
946    /// [`snap_to_glyph_stop`]: Self::snap_to_glyph_stop
947    pub fn visible_text(&self, from: usize, to: usize) -> String {
948        self.visible_items(from, to)
949            .into_iter()
950            .map(|(_, ch)| ch.unwrap_or('\n'))
951            .collect()
952    }
953
954    /// The UTF-16 length of `visible_text(from, to)` — what an `NSRange`
955    /// location into that text is, without building the string.
956    ///
957    /// AppKit's `NSTextInputClient` and `NSAccessibility` speak in UTF-16
958    /// units of *the text as the system sees it*, which for leaf is the visible
959    /// text — delimiters hidden. A frontend reporting its selection to the
960    /// system converts each end with this and gets back an index into the
961    /// string `visible_text(0, end)` returns, which is exactly what the system
962    /// will index into.
963    pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
964        self.visible_items(from, to)
965            .into_iter()
966            .map(|(_, ch)| ch.map_or(1, char::len_utf16))
967            .sum()
968    }
969
970    /// The inverse of `visible_utf16_len(0, ·)`: the source offset of the
971    /// visible character a UTF-16 index into `visible_text(0, to)` lands on.
972    ///
973    /// An index inside a surrogate pair resolves to the character that owns
974    /// it; one at or past the end of the text returns `None`, so a caller can
975    /// substitute the document's end stop. The `\n` a row's or a cell's end
976    /// is spelled with resolves to that end stop — a caret home, so a caller
977    /// placing a caret there needs no snap.
978    pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
979        let mut seen = 0usize;
980        for (src, ch) in self.visible_items(0, to) {
981            let len = ch.map_or(1, char::len_utf16);
982            if index < seen + len {
983                return Some(src);
984            }
985            seen += len;
986        }
987        None
988    }
989
990    /// The items `visible_text` spells, in order — one per caret stop in
991    /// `[from, to)`, keyed by the stop's source offset: the glyph it draws
992    /// (`Some`), or `None` for a stop with no character of its own, which the
993    /// text spells `'\n'`. See [`visible_text`](Self::visible_text) for which
994    /// stops those are and why.
995    fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
996        let from = self.snap_to_glyph_stop(from);
997        let lo = self.stops.partition_point(|&s| s < from);
998        // The document's last stop is the end of the text, not a character in
999        // it: `distance_offset` has no hop past it to pair one with.
1000        let last = self.stops.len().saturating_sub(1);
1001        let hi = self.stops.partition_point(|&s| s < to).min(last).max(lo);
1002        let stops = &self.stops[lo..hi];
1003
1004        // The glyph each stop draws — the first at its offset in row order,
1005        // since a media row's label glyphs all share the media's offset and a
1006        // wrapped line's end is the next line's first glyph. Sorted because
1007        // row order only follows source order outside a table's wrapped
1008        // cells (see `pos_of_offset`); the sort is stable, so "first" holds.
1009        let mut glyphs: Vec<(usize, char)> = self
1010            .rows
1011            .iter()
1012            .filter(|r| !r.decoration)
1013            .flat_map(|r| r.glyphs.iter())
1014            .filter(|g| g.stop && g.src >= from && g.src < to)
1015            .map(|g| (g.src, g.ch))
1016            .collect();
1017        glyphs.sort_by_key(|&(src, _)| src);
1018        glyphs.dedup_by_key(|&mut (src, _)| src);
1019
1020        // A cell's end stop has a glyph (the gutter space) but is spelled as
1021        // a line end; the structural grid is where the cells' offsets live.
1022        let mut cell_ends: Vec<usize> = self
1023            .tables
1024            .iter()
1025            .flat_map(|t| t.grid.iter())
1026            .flat_map(|r| r.cells.iter())
1027            .map(|c| c.end)
1028            .filter(|&e| e >= from && e < to)
1029            .collect();
1030        cell_ends.sort_unstable();
1031        cell_ends.dedup();
1032
1033        let mut gi = 0;
1034        stops
1035            .iter()
1036            .map(|&s| {
1037                while gi < glyphs.len() && glyphs[gi].0 < s {
1038                    gi += 1;
1039                }
1040                let ch = match glyphs.get(gi) {
1041                    Some(&(src, ch)) if src == s && cell_ends.binary_search(&s).is_err() => {
1042                        Some(ch)
1043                    }
1044                    _ => None,
1045                };
1046                (s, ch)
1047            })
1048            .collect()
1049    }
1050}
1051
1052/// Collect the caret stops of a laid-out grid: every stop glyph's offset plus
1053/// every row's end, ascending and deduplicated. Duplicates are the norm rather
1054/// than the exception — a wrapped line's end is the same offset as the next
1055/// line's first glyph — and collapsing them is what makes one press of Left or
1056/// Right cross exactly one stop.
1057fn collect_stops(rows: &[VRow]) -> Vec<usize> {
1058    let mut stops: Vec<usize> = rows
1059        .iter()
1060        .filter(|r| !r.decoration)
1061        .flat_map(|r| {
1062            r.glyphs
1063                .iter()
1064                .filter(|g| g.stop)
1065                .map(|g| g.src)
1066                .chain(std::iter::once(r.end_src))
1067        })
1068        .collect();
1069    stops.sort_unstable();
1070    stops.dedup();
1071    stops
1072}
1073
1074/// Collect every row's [`VRow::mark_ends`] into one ascending, deduplicated
1075/// table — the peer of [`collect_stops`] for the caret's second home at the
1076/// end of a hidden mark. A mark that closes at a row's end coincides with the
1077/// row's own end stop; that offset is in both tables, and harmlessly so.
1078fn collect_mark_ends(rows: &[VRow]) -> Vec<usize> {
1079    let mut ends: Vec<usize> = rows
1080        .iter()
1081        .filter(|r| !r.decoration)
1082        .flat_map(|r| r.mark_ends.iter().copied())
1083        .collect();
1084    ends.sort_unstable();
1085    ends.dedup();
1086    ends
1087}
1088
1089/// Group the rows tagged [`VRow::code`] into one [`CodeBlockInfo`] per maximal
1090/// run — the block-level view a frontend needs to box and scroll each code
1091/// block. Two code blocks are always parted by the blank separator row a block
1092/// boundary is spelled with (never itself a code row), so a contiguous run is
1093/// exactly one block. Derived from the final rows rather than tracked through
1094/// the builder so it comes out right no matter how [`build_cached`] and
1095/// [`build_spliced`] shuffle rows around.
1096fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
1097    let mut blocks = Vec::new();
1098    let mut start: Option<usize> = None;
1099    for (i, row) in rows.iter().enumerate() {
1100        match (row.code, start) {
1101            (true, None) => start = Some(i),
1102            (false, Some(s)) => {
1103                blocks.push(CodeBlockInfo {
1104                    rows_span: s..i,
1105                    lang: rows[s].code_lang.clone(),
1106                });
1107                start = None;
1108            }
1109            _ => {}
1110        }
1111    }
1112    if let Some(s) = start {
1113        blocks.push(CodeBlockInfo {
1114            rows_span: s..rows.len(),
1115            lang: rows[s].code_lang.clone(),
1116        });
1117    }
1118    blocks
1119}
1120
1121/// The value of `node`'s `key` attribute, if it carries one *with* a value. A
1122/// bare attribute (`controls`, `muted`) has a `None` value and so reads as
1123/// absent here — a caller wanting presence-not-value tests the list directly.
1124/// Shared by the media element and `<source>` readers.
1125fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
1126    node.attrs
1127        .iter()
1128        .find(|(k, _)| k == key)
1129        .and_then(|(_, v)| v.clone())
1130}
1131
1132/// Collect one [`MediaInfo`] per row carrying a [`VRow::media`] mark — the
1133/// block-level view a frontend needs to replace each placeholder row with a real
1134/// picture. The mark rides the block's *first* row and names how many rows the
1135/// media reserves ([`MediaMark::rows`]); the rows below it are blank
1136/// [`decoration`](VRow::decoration) fillers that hold the vertical space and no
1137/// caret. So the span runs from the marked row across those fillers. Derived from
1138/// the final rows rather than tracked through the builder so it survives however
1139/// [`build_cached`] and [`build_spliced`] shuffle rows around.
1140fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
1141    rows.iter()
1142        .enumerate()
1143        .filter_map(|(i, row)| {
1144            row.media.as_ref().map(|m| MediaInfo {
1145                rows_span: i..i + m.rows.max(1),
1146                kind: m.kind,
1147                destination: m.destination.clone(),
1148                sources: m.sources.clone(),
1149                alt: m.alt.clone(),
1150                poster: m.poster.clone(),
1151            })
1152        })
1153        .collect()
1154}
1155
1156/// Re-label the drawn block boundaries either side of a block-level media
1157/// placeholder, so the pair a frontend spaces by names the picture.
1158///
1159/// [`BlockClass::from_node_kind`] classifies the node the walk is standing on,
1160/// and a block image is never a node of its own: [`Builder::media_only`] promotes
1161/// its *wrapper* — a `paragraph`, or the `container` a `<video>`/`<audio>`
1162/// arrives as — so the gap above a picture reported [`BlockClass::Paragraph`] and
1163/// the gap above a movie reported [`BlockClass::Directive`], the class a frontend
1164/// paints a tinted panel for. [`BlockClass::Media`] was unreachable in
1165/// consequence: the vocabulary named a kind no frontend could ever be told about.
1166///
1167/// Done as a pass over the finished rows rather than inside the walk because
1168/// only the rows know. The incremental top-level walk carries no node arena at
1169/// all (`nodes: &[]`) and can classify by kind alone, so teaching the wrapper
1170/// promotion to the whole-arena walk would label the full and incremental builds
1171/// differently — the exact drift that walk's own comment forbids. Both builds
1172/// emit the same [`VRow::media`] marks, so both reach the same answer here. The
1173/// [`media_spans`] / [`code_block_spans`] pattern.
1174///
1175/// One gap can be spelled with several rows — [`Builder::emit_separators_before`]
1176/// draws the row that closes the block above and the row that opens the block
1177/// below, with any extra blank source lines navigable between them — and gives
1178/// every one of them the same [`Boundary`]. So the walk crosses those navigable
1179/// blanks and relabels the whole run, stopping at the first row that is neither.
1180fn label_media_boundaries(rows: &mut [VRow]) {
1181    let spans: Vec<Range<usize>> = rows
1182        .iter()
1183        .enumerate()
1184        .filter_map(|(i, row)| row.media.as_ref().map(|m| i..i + m.rows.max(1)))
1185        .collect();
1186    // A row inside one gap: a drawn boundary to relabel, or one of the navigable
1187    // blank lines sitting between two drawn ones. Anything else ends the run.
1188    fn in_gap(row: &VRow) -> bool {
1189        row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
1190    }
1191    for span in spans {
1192        for i in (0..span.start).rev() {
1193            if !in_gap(&rows[i]) {
1194                break;
1195            }
1196            if let Some(b) = rows[i].boundary.as_mut() {
1197                b.below = BlockClass::Media;
1198            }
1199        }
1200        for row in rows.iter_mut().skip(span.end) {
1201            if !in_gap(row) {
1202                break;
1203            }
1204            if let Some(b) = row.boundary.as_mut() {
1205                b.above = BlockClass::Media;
1206            }
1207        }
1208    }
1209}
1210
1211/// Collect one [`DirectiveInfo`] per row carrying a [`VRow::leaf_directive`]
1212/// mark — the block-level view a frontend needs to replace each placeholder row
1213/// with whatever the directive means to it. The peer of [`media_spans`], derived
1214/// from the final rows for the same reason: it survives however [`build_cached`]
1215/// and [`build_spliced`] shuffle rows around.
1216fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
1217    rows.iter()
1218        .enumerate()
1219        .filter_map(|(i, row)| {
1220            row.leaf_directive.as_ref().map(|m| DirectiveInfo {
1221                rows_span: i..i + m.rows.max(1),
1222                name: m.name.clone(),
1223                attrs: m.attrs.clone(),
1224                label: m.label.clone(),
1225            })
1226        })
1227        .collect()
1228}
1229
1230/// The source range of a fenced code block's info string — everything on the
1231/// opening line past the fence (`` ```rust `` → the `rust`). `block_start` is the
1232/// code block node's `span.start`. `None` for an indented code block, which
1233/// opens with no fence to carry one. The range is empty for a fence written
1234/// bare (`` ``` `` alone), which is exactly where a language would be inserted.
1235///
1236/// Shared by the WYSIWYG builder (to label the box) and [`crate::Doc`] (to edit
1237/// the label through a prompt), so the two agree on where the language lives.
1238pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
1239    let rest = source.get(block_start..)?;
1240    let line_len = rest.find('\n').unwrap_or(rest.len());
1241    let line = &rest[..line_len];
1242    // A fence may be indented up to three spaces; past that it opens with a run
1243    // of the same fence character.
1244    let indent = line.len() - line.trim_start().len();
1245    if indent > 3 {
1246        return None;
1247    }
1248    let fence = line[indent..].chars().next()?;
1249    if fence != '`' && fence != '~' {
1250        return None; // an indented block, not a fenced one
1251    }
1252    let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
1253    let info_start = block_start + indent + fence_len;
1254    Some(info_start..block_start + line_len)
1255}
1256
1257/// A fenced code block's language for display: its info string, trimmed, or
1258/// `None` when there's no fence or the fence carries no language. The trimmed
1259/// text is what a frontend labels the box with; [`code_info_span`] is what an
1260/// edit replaces.
1261pub fn code_language(source: &str, block_start: usize) -> Option<String> {
1262    let span = code_info_span(source, block_start)?;
1263    let text = source.get(span)?.trim();
1264    (!text.is_empty()).then(|| text.to_string())
1265}
1266
1267/// A horizontal rule's dash count when the map isn't wrapping to a column grid
1268/// (the GUI, which wraps at pixel width): a fixed, sane width the frontend can
1269/// paint or re-wrap, instead of a runaway count from an unbounded wrap width.
1270const UNWRAPPED_RULE_WIDTH: usize = 40;
1271
1272/// Render the document to a [`VisualMap`]. `wrap` is the column budget for
1273/// word-wrapping (`Some` for the monospace TUI), or `None` to emit one row per
1274/// block — the GUI does its own proportional pixel wrapping over these rows.
1275/// Text and offsets come from the AST (`str` nodes carry the verbatim source
1276/// slice and an exact span), so the original source string isn't needed here.
1277pub fn build(
1278    nodes: &[FlatNode],
1279    source: &str,
1280    wrap: Option<usize>,
1281    preserve_soft: bool,
1282    media_rows: &HashMap<String, usize>,
1283    reveal: Option<Range<usize>>,
1284) -> VisualMap {
1285    let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
1286        return VisualMap::default();
1287    };
1288    let top = top_level(nodes, doc);
1289    let mut b = Builder {
1290        nodes,
1291        source,
1292        wrap: wrap.map(|w| w.max(8)),
1293        rows: Vec::new(),
1294        tables: Vec::new(),
1295        last_off: 0,
1296        stepped_over: 0,
1297        media_rows,
1298        break_glyph: Cell::new(' '),
1299        preserve_soft,
1300        reveal: reveal.clone(),
1301        pending_mark_ends: RefCell::new(Vec::new()),
1302    };
1303    let last_drawn = b.top_blocks(&top);
1304    // The hidden frontmatter's end is the baseline for both the trailing blank
1305    // rows and the caret floor — see [`hidden_prefix_end`]. `top_level` has
1306    // already dropped every `metadata` child, so read it off the arena.
1307    let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
1308    b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
1309    let content_start = top.first().map_or(hidden_end, |&i| nodes[i].span.start);
1310    let stops = collect_stops(&b.rows);
1311    let mark_ends = collect_mark_ends(&b.rows);
1312    label_media_boundaries(&mut b.rows);
1313    let code_blocks = code_block_spans(&b.rows);
1314    let media = media_spans(&b.rows);
1315    let directives = directive_spans(&b.rows);
1316    VisualMap {
1317        rows: b.rows,
1318        content_start,
1319        stops,
1320        mark_ends,
1321        tables: b.tables,
1322        code_blocks,
1323        media,
1324        directives,
1325    }
1326}
1327
1328/// Like [`build`], but reuses a persistent [`BlockCache`] so an edit re-renders
1329/// only the top-level blocks whose source bytes changed *and* marshals only
1330/// those blocks from twig instead of the whole arena.
1331///
1332/// `top` is the document's top-level blocks — twig's `child_spans` of the doc
1333/// root: `(node_id, kind, span)` for each, in order. `fetch_subtree(node_id)`
1334/// marshals one block's subtree (local-indexed, root at 0) and is called *only*
1335/// for a block that missed the cache, i.e. one that actually changed. So a
1336/// keystroke marshals one small subtree, not ~20k nodes. The result is
1337/// byte-for-byte identical to [`build`] on the same document (the
1338/// `build_cached_matches_build` test pins this); [`build`] stays the cache-free,
1339/// whole-arena reference. This is the entry point [`crate::Doc`] uses.
1340// One builder, and every one of these is a distinct input to the same layout
1341// pass — a struct of them would be built at the one call site and unpacked
1342// here, which is the same arguments with an extra name in the way.
1343#[allow(clippy::too_many_arguments)]
1344pub fn build_cached(
1345    top: &[QueryMatch],
1346    source: &str,
1347    wrap: Option<usize>,
1348    preserve_soft: bool,
1349    media_rows: &HashMap<String, usize>,
1350    reveal: Option<Range<usize>>,
1351    cache: &mut BlockCache,
1352    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1353) -> VisualMap {
1354    let wrap = wrap.map(|w| w.max(8));
1355
1356    // Wrapping is a function of the width, so a width change makes every cached
1357    // row's wrap wrong: start the cache over.
1358    if cache.wrap != Some(wrap) {
1359        cache.entries.clear();
1360        cache.wrap = Some(wrap);
1361    }
1362    cache.generation = cache.generation.wrapping_add(1);
1363
1364    // Frontmatter (a leading `metadata` block) is document metadata, not prose:
1365    // hidden in the rich view exactly as [`Builder::blocks`] skips it.
1366    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1367
1368    // The outer builder only accumulates rows/tables and spells block boundaries
1369    // — both a function of the source and `last_off`, never of a node array — so
1370    // it carries an empty `nodes`. Each changed block is rendered by a *fresh*
1371    // builder over that block's subtree.
1372    let mut b = Builder {
1373        nodes: &[],
1374        source,
1375        wrap,
1376        rows: Vec::new(),
1377        tables: Vec::new(),
1378        last_off: 0,
1379        stepped_over: 0,
1380        media_rows,
1381        break_glyph: Cell::new(' '),
1382        preserve_soft,
1383        reveal: reveal.clone(),
1384        pending_mark_ends: RefCell::new(Vec::new()),
1385    };
1386
1387    // Record the per-block row decomposition as we go, so a later
1388    // [`build_spliced`] can patch one block without rebuilding the map.
1389    let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
1390    let mut all_shift_safe = true;
1391    // The class of the last block that drew anything: what the next separator
1392    // closes, and what the trailing blank lines close at the end. A hidden block
1393    // (a comment) never becomes it — see [`Builder::block_or_hidden`], whose
1394    // step-over this loop repeats for the incremental walk.
1395    let mut above: Option<BlockClass> = None;
1396    for block in &blocks {
1397        let start = block.span.start;
1398        let before_sep = b.rows.len();
1399        if let Some(above) = above {
1400            // This walker has no node arena at all (see the `nodes: &[]` above),
1401            // but a top-level query match carries its kind — the same string
1402            // `BlockClass::from_node_kind` classifies for the whole-arena walk, so
1403            // the incremental and full builds label a boundary identically.
1404            b.emit_separators_before(
1405                start,
1406                &[],
1407                true,
1408                Boundary {
1409                    above,
1410                    below: BlockClass::from_node_kind(&block.kind),
1411                },
1412            );
1413        }
1414        let after_sep = b.rows.len();
1415        let bytes = block_bytes(source, &block.span);
1416        let hash = block_hash(bytes);
1417        // How this block meets the reveal line, if at all — part of its cache
1418        // key, since the same bytes render differently on the caret's line.
1419        let rkey = reveal_key(&reveal, &block.span);
1420
1421        // Hit: clone the block's rows shifted to its current offset and restore
1422        // the (shifted) `last_off` so the next separator lands right — no marshal.
1423        // Only shift-safe blocks are ever cached, so a hit is safe by construction.
1424        if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
1425            let delta = start as isize - hit.built_start as isize;
1426            for row in &hit.rows {
1427                b.rows.push(shift_row(row, delta));
1428            }
1429            b.last_off = (hit.last_off as isize + delta) as usize;
1430        } else {
1431            // Miss: marshal just this block's subtree and render it. A subtree is
1432            // self-contained with local ids (root at 0) and absolute spans, so a
1433            // fresh builder over it produces the same rows the whole-arena path
1434            // would. An empty subtree (twig couldn't hand it back) renders nothing.
1435            let subtree = fetch_subtree(block.node_id);
1436            if !subtree.is_empty() {
1437                let mut sub = Builder {
1438                    nodes: &subtree,
1439                    source,
1440                    wrap,
1441                    rows: Vec::new(),
1442                    tables: Vec::new(),
1443                    last_off: 0,
1444                    stepped_over: 0,
1445                    media_rows,
1446                    break_glyph: Cell::new(' '),
1447                    preserve_soft,
1448                    reveal: reveal.clone(),
1449                    pending_mark_ends: RefCell::new(Vec::new()),
1450                };
1451                sub.block(0, &[], &[]);
1452                // A block that drew nothing is stepped over, not stood on: its
1453                // `last_off` is its own end, so the separator after it counts
1454                // from there. The sub-builder started at 0 and never moved, and
1455                // 0 is where the next separator would otherwise count from —
1456                // every line of the document, as a blank row each.
1457                let last_off = if sub.rows.is_empty() {
1458                    block.span.end
1459                } else {
1460                    sub.last_off
1461                };
1462                // Cache only a block that is table-free AND renders inside its own
1463                // span: those two are the conditions for reuse-by-shift to be
1464                // correct. A block failing either is re-rendered every build (a
1465                // fresh render always matches a fresh whole-document build).
1466                if sub.tables.is_empty() {
1467                    if rows_within(&sub.rows, &block.span) {
1468                        cache.store(hash, bytes, start, sub.rows.clone(), last_off, rkey);
1469                    }
1470                    b.rows.extend(sub.rows);
1471                } else {
1472                    // A table block is never cached; rebase its row-index
1473                    // bookkeeping onto the combined row vector and append.
1474                    let base = b.rows.len();
1475                    for t in &mut sub.tables {
1476                        t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
1477                    }
1478                    b.rows.extend(sub.rows);
1479                    b.tables.extend(sub.tables);
1480                }
1481                b.last_off = last_off;
1482            }
1483        }
1484        let content_rows = b.rows.len() - after_sep;
1485        let sep_rows = if content_rows == 0 {
1486            // Hidden: take back the separator drawn for it, so what stands
1487            // either side meets across one boundary. Its layout entry stays, at
1488            // no rows, so the splice arithmetic still counts one entry per block.
1489            b.rows.truncate(before_sep);
1490            // A cache hit restored the stored `last_off` above; an empty subtree
1491            // (twig couldn't hand it back) restored nothing. Either way the walk
1492            // stands past the block.
1493            b.last_off = b.last_off.max(block.span.end);
1494            b.stepped_over = b.stepped_over.max(block.span.end);
1495            0
1496        } else {
1497            above = Some(BlockClass::from_node_kind(&block.kind));
1498            all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
1499            after_sep - before_sep
1500        };
1501        layout_blocks.push(BlockLayout {
1502            span: block.span.clone(),
1503            kind: block.kind.clone(),
1504            sep_rows,
1505            content_rows,
1506        });
1507    }
1508
1509    let before_trailing = b.rows.len();
1510    let hidden_end = hidden_prefix_end(
1511        source,
1512        top.iter()
1513            .filter(|m| m.kind == Kind::Metadata)
1514            .map(|m| m.span.end)
1515            .next_back(),
1516    );
1517    b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
1518    let trailing_rows = b.rows.len() - before_trailing;
1519
1520    // Evict every entry no block reused this build, so the cache tracks the
1521    // current document instead of growing without bound over a session.
1522    let g = cache.generation;
1523    cache.entries.retain(|_, bucket| {
1524        bucket.retain(|e| e.generation == g);
1525        !bucket.is_empty()
1526    });
1527
1528    cache.layout = Layout {
1529        blocks: layout_blocks,
1530        trailing_rows,
1531        built_len: source.len(),
1532        has_tables: !b.tables.is_empty(),
1533        all_shift_safe,
1534        reveal: reveal.clone(),
1535    };
1536
1537    // The first rendered offset is the first non-metadata block's start — the
1538    // analogue of [`first_content_offset`] for the top-level list. With nothing
1539    // but frontmatter it's the end of that frontmatter, and 0 for an empty
1540    // document ([`hidden_prefix_end`]).
1541    let content_start = blocks.first().map_or(hidden_end, |m| m.span.start);
1542    let stops = collect_stops(&b.rows);
1543    let mark_ends = collect_mark_ends(&b.rows);
1544    label_media_boundaries(&mut b.rows);
1545    let code_blocks = code_block_spans(&b.rows);
1546    let media = media_spans(&b.rows);
1547    let directives = directive_spans(&b.rows);
1548    VisualMap {
1549        rows: b.rows,
1550        content_start,
1551        stops,
1552        mark_ends,
1553        tables: b.tables,
1554        code_blocks,
1555        media,
1556        directives,
1557    }
1558}
1559
1560/// The fast path for a single-block edit: patch the previous [`VisualMap`] in
1561/// place rather than reassembling it. Returns `Some(new_map)` when it applies,
1562/// or `None` to tell the caller to fall back to [`build_cached`] (always
1563/// correct). Consumes `prev` either way — on `None` the caller rebuilds from
1564/// scratch and doesn't need it.
1565///
1566/// It applies only when `dirty` (twig's dirty byte range) falls inside exactly
1567/// one top-level block AND the block structure around it is unchanged — verified
1568/// by matching the new `top` list against the previous [`Layout`] block for
1569/// block: kinds unchanged, spans before the edit identical, spans after it
1570/// shifted by the byte delta, count unchanged. Any deviation — a block split or
1571/// merged, a fence opened to swallow later blocks, a table anywhere, a
1572/// multi-block edit — fails the match and returns `None`. That check is what
1573/// makes the byte-range trustworthy: twig's dirty range is exact about *bytes*
1574/// but silent about *reparse*, and the structural match catches the reparse
1575/// effects it can't see.
1576///
1577/// When it applies, the unchanged prefix rows move verbatim, the suffix rows
1578/// shift by the delta *in place* (integer adds, no glyph copy), and only the one
1579/// dirty block is re-marshalled and re-rendered; stops splice the same way by
1580/// offset. So the cost is O(rows after the edit), and nothing before the edit is
1581/// touched. The hash-keyed entry cache is left alone — a later [`build_cached`]
1582/// will miss on the changed block, re-render it, and evict the stale entry, so
1583/// chained splices neither corrupt nor grow it.
1584// One builder, and every one of these is a distinct input to the same layout
1585// pass — a struct of them would be built at the one call site and unpacked
1586// here, which is the same arguments with an extra name in the way.
1587#[allow(clippy::too_many_arguments)]
1588pub fn build_spliced(
1589    prev: VisualMap,
1590    source: &str,
1591    wrap: Option<usize>,
1592    preserve_soft: bool,
1593    top: &[QueryMatch],
1594    dirty: Range<usize>,
1595    media_rows: &HashMap<String, usize>,
1596    reveal: Option<Range<usize>>,
1597    cache: &mut BlockCache,
1598    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1599) -> Option<VisualMap> {
1600    let wrap = wrap.map(|w| w.max(8));
1601    // A width change invalidates every cached row — a full rebuild's job.
1602    if cache.wrap != Some(wrap) {
1603        return None;
1604    }
1605    // So does a moved reveal line, and for the same reason: this path reuses
1606    // every row outside the dirty block, and those rows encode which line was
1607    // showing its raw markup when they were built. Typing almost always moves
1608    // the caret, so under `MarkupMode::Full` this bails to `build_cached` on
1609    // most keystrokes — still block-cached, so only the edited block and the
1610    // revealed one actually re-render.
1611    if cache.layout.reveal != reveal {
1612        return None;
1613    }
1614    // Take the previous layout; on any bail below the caller rebuilds it (and the
1615    // map) via `build_cached`, so leaving it empty is fine. A table or a block
1616    // that renders outside its span (a degenerate inline span) makes shifting
1617    // unsound, so those force the full-rebuild path.
1618    let prev_layout = std::mem::take(&mut cache.layout);
1619    if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
1620        return None;
1621    }
1622    // The layout addresses `prev` by row index, so it is only usable against the
1623    // map it was built from. A frontend is free to hold the map it was handed and
1624    // present it differently — leaf-ratatui splices blank filler rows under an
1625    // oversized heading so the raster has somewhere to stand — and if one of those
1626    // comes back here the row arithmetic below lands on the wrong rows: the
1627    // re-rendered block is laid over a filler and the rows it really occupied
1628    // survive into the suffix, stranding a stale copy of the edited line and
1629    // pushing everything after it one row down, once per keystroke. A row count
1630    // that doesn't match what this layout describes is the tell, and the honest
1631    // answer is the full rebuild.
1632    let described_rows = prev_layout
1633        .blocks
1634        .iter()
1635        .map(|pl| pl.sep_rows + pl.content_rows)
1636        .sum::<usize>()
1637        + prev_layout.trailing_rows;
1638    if described_rows != prev.rows.len() {
1639        return None;
1640    }
1641
1642    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1643    if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
1644        return None;
1645    }
1646    let delta = source.len() as isize - prev_layout.built_len as isize;
1647
1648    // The single block whose NEW span contains the whole dirty range. A dirty
1649    // range straddling a block boundary (or a separator) finds none → bail.
1650    let k = blocks
1651        .iter()
1652        .position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
1653
1654    // Structural match: every OTHER block is unchanged — same kind throughout,
1655    // span identical before the edit and shifted by `delta` after it. A mismatch
1656    // means the reparse reshaped the block structure, which only a full rebuild
1657    // renders correctly.
1658    for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
1659        if m.kind != pl.kind {
1660            return None;
1661        }
1662        if i == k {
1663            continue;
1664        }
1665        let want = if i < k {
1666            pl.span.clone()
1667        } else {
1668            (pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
1669        };
1670        if m.span != want {
1671            return None;
1672        }
1673    }
1674    // The dirty block itself: start unchanged (the edit is inside it, past its
1675    // start), end moved by exactly the delta.
1676    let pk_start = prev_layout.blocks[k].span.start;
1677    let pk_end = prev_layout.blocks[k].span.end;
1678    let pk_sep = prev_layout.blocks[k].sep_rows;
1679    let pk_content = prev_layout.blocks[k].content_rows;
1680    if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
1681    {
1682        return None;
1683    }
1684
1685    // Re-render the dirty block from its subtree. A table makes the splice
1686    // bookkeeping unsafe, so bail if one appears.
1687    let subtree = fetch_subtree(blocks[k].node_id);
1688    if subtree.is_empty() {
1689        return None;
1690    }
1691    let mut sub = Builder {
1692        nodes: &subtree,
1693        source,
1694        wrap,
1695        rows: Vec::new(),
1696        tables: Vec::new(),
1697        last_off: 0,
1698        stepped_over: 0,
1699        media_rows,
1700        break_glyph: Cell::new(' '),
1701        preserve_soft,
1702        reveal: reveal.clone(),
1703        pending_mark_ends: RefCell::new(Vec::new()),
1704    };
1705    sub.block(0, &[], &[]);
1706    // A table, or content that renders outside the block's span (a degenerate
1707    // inline span), makes the shift bookkeeping unsound — fall back.
1708    if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
1709        return None;
1710    }
1711    let new_content = sub.rows;
1712    let new_content_len = new_content.len();
1713    let new_stops = collect_stops(&new_content);
1714    let new_mark_ends = collect_mark_ends(&new_content);
1715
1716    // Row span of the dirty block's CONTENT. Its leading separator stays in the
1717    // prefix: the gap before block k is unchanged, since k's start didn't move.
1718    let content_start_row: usize = prev_layout.blocks[..k]
1719        .iter()
1720        .map(|pl| pl.sep_rows + pl.content_rows)
1721        .sum::<usize>()
1722        + pk_sep;
1723    let content_end_row = content_start_row + pk_content;
1724
1725    // Splice rows: [prefix | new content | suffix + delta]. The prefix moves
1726    // untouched; the suffix shifts in place — integer adds, no glyph copy.
1727    let mut rows = prev.rows;
1728    let mut suffix = rows.split_off(content_end_row);
1729    rows.truncate(content_start_row);
1730    for row in &mut suffix {
1731        shift_row_in_place(row, delta);
1732    }
1733    rows.reserve(new_content_len + suffix.len());
1734    rows.extend(new_content);
1735    rows.extend(suffix);
1736
1737    // Splice stops by offset. The old dirty block covered `[pk_start, pk_end]`:
1738    // prefix stops fall below it, suffix stops above it (shift by delta), the new
1739    // content supplies the middle. The three ranges stay disjoint and ascending,
1740    // so the result needs no re-sort.
1741    let p1 = prev.stops.partition_point(|&s| s < pk_start);
1742    let p2 = prev.stops.partition_point(|&s| s <= pk_end);
1743    let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
1744    stops.extend_from_slice(&prev.stops[..p1]);
1745    stops.extend(new_stops);
1746    for &s in &prev.stops[p2..] {
1747        stops.push((s as isize + delta) as usize);
1748    }
1749    // The mark ends splice the same way: they are offsets in the same
1750    // coordinates, cut at the same block.
1751    let m1 = prev.mark_ends.partition_point(|&s| s < pk_start);
1752    let m2 = prev.mark_ends.partition_point(|&s| s <= pk_end);
1753    let mut mark_ends = Vec::with_capacity(m1 + new_mark_ends.len() + (prev.mark_ends.len() - m2));
1754    mark_ends.extend_from_slice(&prev.mark_ends[..m1]);
1755    mark_ends.extend(new_mark_ends);
1756    for &s in &prev.mark_ends[m2..] {
1757        mark_ends.push((s as isize + delta) as usize);
1758    }
1759
1760    // Record the patched layout for the next splice: spans move to the new
1761    // coordinates, and the dirty block takes its new content-row count.
1762    let mut new_blocks = prev_layout.blocks;
1763    for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
1764        pl.span = m.span.clone();
1765    }
1766    new_blocks[k].content_rows = new_content_len;
1767    cache.layout = Layout {
1768        blocks: new_blocks,
1769        trailing_rows: prev_layout.trailing_rows,
1770        built_len: source.len(),
1771        has_tables: false,
1772        // Every prefix/suffix block was shift-safe last build (we bailed
1773        // otherwise) and the re-rendered block was just checked, so the patched
1774        // document is still entirely shift-safe.
1775        all_shift_safe: true,
1776        reveal,
1777    };
1778
1779    label_media_boundaries(&mut rows);
1780    let code_blocks = code_block_spans(&rows);
1781    let media = media_spans(&rows);
1782    let directives = directive_spans(&rows);
1783    Some(VisualMap {
1784        rows,
1785        content_start: blocks[0].span.start,
1786        stops,
1787        mark_ends,
1788        tables: Vec::new(),
1789        code_blocks,
1790        media,
1791        directives,
1792    })
1793}
1794
1795/// A persistent, content-keyed cache of the rows each top-level block renders
1796/// to — the [`VisualMap`] analogue of the GUI's ShapedLine cache, one level
1797/// down. Held by a [`crate::Doc`] and threaded into [`build_cached`], it is what
1798/// makes a rebuild after a keystroke cost "re-render the edited block + shift
1799/// the rest" instead of re-rendering the whole document.
1800///
1801/// A top-level block's rows are a pure function of its source bytes and the wrap
1802/// width, so an unchanged block's rows are cloned and their source offsets
1803/// shifted by the edit's byte delta rather than rebuilt glyph by glyph. Two
1804/// things make that purity hold: at the top level the render prefix is always
1805/// empty (nesting prefixes — a quote gutter, a list indent — exist only *inside*
1806/// a top-level block, within its cached unit), and a block's output never reads
1807/// the incoming `last_off` (it writes `last_off` from its own content before any
1808/// nested separator reads it). So the only thing that differs between two
1809/// positions of an unchanged block is a uniform offset shift. Keyed by a fast
1810/// hash of the block's bytes with the bytes kept for a verify-on-hit — exactly
1811/// the shape cache's weak-hash-then-compare, so a collision costs a re-render,
1812/// never a wrong row.
1813///
1814/// Tables are never cached (a block that emits any table row is always rebuilt):
1815/// their rows are cross-referenced from the map's `tables` side-table by row
1816/// index, which a blind offset-shift wouldn't fix up, and they are rare enough
1817/// that the simplicity beats the reuse.
1818#[derive(Default)]
1819pub struct BlockCache {
1820    /// The wrap width every entry was built at; a change invalidates all of
1821    /// them. `None` before the first build (distinct from `Some(None)`, the
1822    /// unwrapped GUI width).
1823    wrap: Option<Option<usize>>,
1824    /// Bumped once per [`build_cached`]. An entry reused or inserted this build
1825    /// carries the current value; stale entries are dropped at the end of it.
1826    generation: u64,
1827    /// `hash(bytes)` → the block(s) sharing that hash — a bucket because
1828    /// distinct blocks can collide, while two *identical* blocks share one entry
1829    /// (free dedup).
1830    entries: HashMap<u64, Vec<CachedBlock>>,
1831    /// The row/stop decomposition of the last build, which [`build_spliced`]
1832    /// patches in place for a single-block edit. Kept in step with whatever
1833    /// [`VisualMap`] was last produced; empty before the first build.
1834    layout: Layout,
1835}
1836
1837/// How the last build's [`VisualMap`] decomposes into top-level blocks — the
1838/// bookkeeping [`build_spliced`] needs to splice one block's rows and stops
1839/// without rebuilding the whole map. Every field describes the *previous* build,
1840/// in that build's coordinates.
1841#[derive(Default)]
1842struct Layout {
1843    /// One entry per rendered (metadata-filtered) top-level block, in order.
1844    blocks: Vec<BlockLayout>,
1845    /// Trailing blank rows past the last block (from `emit_trailing_blank_lines`).
1846    trailing_rows: usize,
1847    /// The source length this layout was built at — the reference for the edit's
1848    /// byte delta.
1849    built_len: usize,
1850    /// Whether the last build drew any table. A table's cross-referenced row
1851    /// indices don't survive a blind splice, so their presence makes
1852    /// [`build_spliced`] bail to a full rebuild.
1853    has_tables: bool,
1854    /// Whether every block rendered strictly inside its own span (see
1855    /// [`rows_within`]). A block that doesn't — a malformed Markdown inline node
1856    /// that twig leaves with a degenerate `0..0` span renders at a fixed offset
1857    /// outside its block — can't be shifted correctly, so its presence makes
1858    /// [`build_spliced`] bail to a full rebuild.
1859    all_shift_safe: bool,
1860    /// The reveal line this layout was built under (see [`Builder::reveal`]).
1861    /// A splice reuses every row it isn't re-rendering, so a reveal line that
1862    /// has moved would leave the old line still showing its delimiters and the
1863    /// new one still hiding them — [`build_spliced`] bails when this changes.
1864    reveal: Option<Range<usize>>,
1865}
1866
1867/// One top-level block's contribution to the last build: its span and kind (for
1868/// the structural match that proves only one block changed) and how many
1869/// separator and content rows it emitted (to locate its slice of the row
1870/// vector).
1871struct BlockLayout {
1872    span: Range<usize>,
1873    kind: Kind,
1874    sep_rows: usize,
1875    content_rows: usize,
1876}
1877
1878/// One cached block: the rows it rendered to, plus what a reuse at a new
1879/// position needs to shift them. Offsets are stored absolute (as built) and
1880/// shifted by `new_start - built_start` on reuse.
1881struct CachedBlock {
1882    /// The block's exact source bytes, compared on a hash hit so a collision
1883    /// can never hand back another block's rows.
1884    bytes: Box<[u8]>,
1885    /// The offset the rows were built at (the block's `span.start`).
1886    built_start: usize,
1887    /// The block's rows, offsets absolute as built.
1888    rows: Vec<VRow>,
1889    /// `last_off` after this block was emitted, absolute as built — restored
1890    /// (shifted) on reuse so the following separator lands correctly.
1891    last_off: usize,
1892    /// Where the reveal line fell *within this block* when the rows were built,
1893    /// as a block-relative byte range — see [`reveal_key`]. Compared alongside
1894    /// `bytes` on a hit, because identical source renders to different rows
1895    /// depending on whether the caret's line is inside it: the same `*em*`
1896    /// shows its asterisks on the revealed line and hides them everywhere else.
1897    ///
1898    /// Block-relative rather than absolute so an unaffected block still hits
1899    /// after an edit shifts it, and `None` for the overwhelmingly common
1900    /// no-reveal case — which is why an entry stored under `MarkupMode::None`
1901    /// keeps hitting for every block that isn't the caret's.
1902    reveal: Option<Range<usize>>,
1903    /// The build that last reused or inserted this entry (see `generation`).
1904    generation: u64,
1905}
1906
1907/// Where `reveal` falls inside a block, in block-relative bytes — the extra key
1908/// a cached block is stored and matched under.
1909///
1910/// `None` when the block doesn't meet the reveal line at all, which is every
1911/// block on every build in the two hidden modes, and all but one of them under
1912/// [`crate::MarkupMode::Full`]. So the cache keeps its hit rate as the caret
1913/// moves: only the line the caret leaves and the line it arrives at re-render.
1914fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
1915    let r = reveal.as_ref()?;
1916    // The same generous intersection test `Builder::revealed` uses, so a block
1917    // is keyed as revealed exactly when its glyphs will be built that way.
1918    (span.start <= r.end && r.start <= span.end).then(|| {
1919        let start = r.start.max(span.start) - span.start;
1920        let end = r.end.min(span.end) - span.start;
1921        start..end
1922    })
1923}
1924
1925impl BlockCache {
1926    /// Look up a block by hash, verify its bytes and reveal key, and on a hit
1927    /// stamp it used this build and hand back a borrow to shift-and-clone from.
1928    /// `None` on a miss (unknown hash, a collision whose bytes differ, or the
1929    /// same bytes built under a different reveal).
1930    fn reuse(
1931        &mut self,
1932        hash: u64,
1933        bytes: &[u8],
1934        reveal: &Option<Range<usize>>,
1935    ) -> Option<&CachedBlock> {
1936        let g = self.generation;
1937        let bucket = self.entries.get_mut(&hash)?;
1938        let e = bucket
1939            .iter_mut()
1940            .find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
1941        e.generation = g;
1942        Some(&*e)
1943    }
1944
1945    /// Cache the rows a freshly-rendered block produced (or refresh an existing
1946    /// entry for the same bytes and reveal — an identical block elsewhere, or a
1947    /// re-render).
1948    fn store(
1949        &mut self,
1950        hash: u64,
1951        bytes: &[u8],
1952        built_start: usize,
1953        rows: Vec<VRow>,
1954        last_off: usize,
1955        reveal: Option<Range<usize>>,
1956    ) {
1957        let g = self.generation;
1958        let bucket = self.entries.entry(hash).or_default();
1959        if let Some(e) = bucket
1960            .iter_mut()
1961            .find(|e| &*e.bytes == bytes && e.reveal == reveal)
1962        {
1963            e.built_start = built_start;
1964            e.rows = rows;
1965            e.last_off = last_off;
1966            e.generation = g;
1967        } else {
1968            bucket.push(CachedBlock {
1969                bytes: bytes.into(),
1970                built_start,
1971                rows,
1972                last_off,
1973                reveal,
1974                generation: g,
1975            });
1976        }
1977    }
1978}
1979
1980/// The source bytes a top-level block covers — the block cache's key material.
1981///
1982/// Clamped to the source rather than sliced by the span as twig gives it,
1983/// because that span can end *past* the last byte: the final block of a document
1984/// with no trailing newline is closed on the virtual newline the parser supplies
1985/// at EOF, so its `span.end` is `source.len() + 1`. Slicing by such a range
1986/// yields `None`, and the obvious `unwrap_or(&[])` reads that as *this block has
1987/// no bytes* — the wrong answer twice over.
1988///
1989/// Two blocks whose spans both overrun then key alike, and the second is served
1990/// the first one's rows. That is not hypothetical: a footnote definition is a
1991/// root beside `doc` merged back into the top level by [`top_blocks`], while the
1992/// `section` above it spans the definition's bytes too, so both end at EOF —
1993/// and a document ending in `[^note]: …` renders that definition as a second
1994/// copy of the heading. Even alone, a block that keeps hashing empty as the user
1995/// types in it is served the stale rows built before the edit.
1996///
1997/// Clamping hands back the bytes the block really covers, which tells both cases
1998/// apart, and costs nothing for a span that was in range to begin with.
1999fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
2000    let bytes = source.as_bytes();
2001    let start = span.start.min(bytes.len());
2002    &bytes[start..span.end.clamp(start, bytes.len())]
2003}
2004
2005/// A fast, allocation-free content hash (FNV-1a) for a block's bytes. Weak by
2006/// design — the bytes are compared on a hit — so its only job is to spread
2007/// blocks across buckets cheaply. SipHash over every block's bytes on every
2008/// keystroke would cost more than it saves, the same lesson the shape cache
2009/// learned when it stopped hashing through the standard hasher.
2010fn block_hash(bytes: &[u8]) -> u64 {
2011    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2012    for &x in bytes {
2013        h ^= x as u64;
2014        h = h.wrapping_mul(0x0000_0100_0000_01b3);
2015    }
2016    h
2017}
2018
2019/// Clone a cached row with every source offset advanced by `delta` — the whole
2020/// cost of reusing an unchanged block: integer adds where a rebuild would
2021/// re-shape every glyph.
2022fn shift_row(row: &VRow, delta: isize) -> VRow {
2023    let shift = |off: usize| (off as isize + delta) as usize;
2024    VRow {
2025        glyphs: row
2026            .glyphs
2027            .iter()
2028            .map(|g| Glyph {
2029                ch: g.ch,
2030                style: g.style,
2031                src: shift(g.src),
2032                stop: g.stop,
2033            })
2034            .collect(),
2035        end_src: shift(row.end_src),
2036        decoration: row.decoration,
2037        code: row.code,
2038        code_lang: row.code_lang.clone(),
2039        directive: row.directive,
2040        directive_label: row.directive_label.clone(),
2041        media: row.media.clone(),
2042        // A tick, not an offset — reuse carries it as-is, like `code_lang`.
2043        task: row.task,
2044        leaf_directive: row.leaf_directive.clone(),
2045        heading: row.heading,
2046        // Structure, not offsets: a reused block's rows divide the same blocks
2047        // wherever the edit above moved them to.
2048        boundary: row.boundary,
2049        mark_ends: row.mark_ends.iter().map(|&o| shift(o)).collect(),
2050    }
2051}
2052
2053/// Advance a row's source offsets by `delta` in place — the suffix half of
2054/// [`build_spliced`], where the rows are already owned and only need shifting,
2055/// not copying.
2056fn shift_row_in_place(row: &mut VRow, delta: isize) {
2057    for g in &mut row.glyphs {
2058        g.src = (g.src as isize + delta) as usize;
2059    }
2060    row.end_src = (row.end_src as isize + delta) as usize;
2061    for o in &mut row.mark_ends {
2062        *o = (*o as isize + delta) as usize;
2063    }
2064}
2065
2066/// Whether every source offset a block's rows carry falls inside the block's own
2067/// span — the precondition for reusing the block by a uniform offset shift. It
2068/// holds for well-formed blocks (their glyphs and row ends address bytes within
2069/// the block, synthetic glyphs point at the block start). It fails when a node
2070/// renders *outside* its block, which today means a malformed Markdown inline
2071/// node twig leaves with a degenerate `0..0` span: that content lands at a fixed
2072/// offset that doesn't move with the block. Such a block is re-rendered every
2073/// build instead of shifted, so the incremental map still matches a fresh one —
2074/// see [`build_cached`] and [`build_spliced`].
2075fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
2076    rows.iter().all(|r| {
2077        r.end_src >= span.start
2078            && r.end_src <= span.end
2079            && r.glyphs
2080                .iter()
2081                .all(|g| g.src >= span.start && g.src <= span.end)
2082    })
2083}
2084
2085/// Where the rendered document begins when a leading `metadata` block is all
2086/// there is — the end of that hidden frontmatter, past the newline that closes
2087/// its last line so the floor sits at the start of the (empty) body rather than
2088/// on the closing `---`.
2089///
2090/// With a real block after it the frontmatter's end is never needed: the floor
2091/// is that block's start, and the rows begin there. With nothing after it, both
2092/// the caret floor and the trailing-blank-line count would otherwise fall back
2093/// to offset 0 — inside the hidden frontmatter — which put the caret *before*
2094/// the metadata and made typing land ahead of the opening `---`.
2095fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
2096    let Some(end) = meta_end else { return 0 };
2097    let end = end.min(source.len());
2098    let rest = &source[end..];
2099    if rest.starts_with("\r\n") {
2100        end + 2
2101    } else if rest.starts_with('\n') {
2102        end + 1
2103    } else {
2104        end
2105    }
2106}
2107
2108/// The end of the document's hidden frontmatter: the last `metadata` child of
2109/// `doc`, which is what [`top_level`] and [`Builder::blocks`] drop. `None` when
2110/// there is none.
2111fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
2112    let mut end = None;
2113    let mut child = nodes[doc].first_child;
2114    while let Some(cid) = child {
2115        let n = &nodes[cid.0 as usize];
2116        if n.kind == Kind::Metadata {
2117            end = Some(n.span.end);
2118        }
2119        child = n.next_sibling;
2120    }
2121    end
2122}
2123
2124/// The document's rendered top-level blocks, as node indices in source order.
2125///
2126/// Not simply `doc`'s children, for two reasons. Frontmatter (a leading
2127/// `metadata` block) is document metadata rather than prose and is dropped, the
2128/// way [`Builder::blocks`] drops it. And a **footnote definition** (`[^1]: …`)
2129/// is not a child of `doc` at all: twig parses it as a root of its own, a
2130/// *sibling* of the document node with `parent == None`. A walk that starts at
2131/// `doc` therefore never reaches one, which is why a definition — and every
2132/// byte of its body — used to render as nothing at all. Merging the roots back
2133/// in by `span.start` puts each definition on screen exactly where it was
2134/// written, which is what keeps rows, stops, and offsets monotonic.
2135///
2136/// A **link reference definition** (`[foo]: /url`) is a root of the same kind,
2137/// and is merged for the opposite reason: it draws *nothing*, and the walk has
2138/// to know where it stands to step over it. A definition closing a README —
2139/// the `[links]: …` block under the prose — left no block over its lines, so
2140/// the separator logic read them as blank lines and drew an empty paragraph
2141/// per definition. Merged in, it is a hidden block like a comment, and
2142/// [`Builder::block_or_hidden`] moves the walk past it. One with no span
2143/// (`0..0`, what twig before 3.3.3 reported for every one) has nowhere to be
2144/// merged, and is left out as before.
2145///
2146/// Only those roots are merged. twig also leaves stray orphan `str` nodes
2147/// parented to nothing (the `*` of an emphasis run, for one); those are already
2148/// rendered as part of the subtree that owns their bytes, and re-emitting them
2149/// here would double them.
2150fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
2151    let mut out = Vec::new();
2152    let mut child = nodes[doc].first_child;
2153    while let Some(cid) = child {
2154        let n = &nodes[cid.0 as usize];
2155        if n.kind != Kind::Metadata {
2156            out.push(cid.0 as usize);
2157        }
2158        child = n.next_sibling;
2159    }
2160    out.extend(
2161        nodes
2162            .iter()
2163            .enumerate()
2164            .filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
2165            .map(|(i, _)| i),
2166    );
2167    out.sort_by_key(|&i| nodes[i].span.start);
2168    out
2169}
2170
2171/// Is a parentless node of `kind` at `span` a definition the top-level walk
2172/// merges in — a footnote definition, or a link reference definition that
2173/// knows where it stands? Shared by [`top_level`] and [`top_blocks`] so the
2174/// two walks cannot disagree about what the top-level blocks are.
2175fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
2176    match *kind {
2177        Kind::Footnote => true,
2178        Kind::Reference => span.end > span.start,
2179        _ => false,
2180    }
2181}
2182
2183/// The top-level blocks to hand [`build_cached`] / [`build_spliced`] — the
2184/// incremental path's twin of [`top_level`], which the two must agree with block
2185/// for block or the render paths diverge.
2186///
2187/// `child_spans(None)` gives `doc`'s children, which is all of them for an
2188/// ordinary document. A **footnote definition** is not one: twig parses `[^1]: …`
2189/// as a root beside `doc` with no parent, and indexes it at no offset either —
2190/// `node_at` inside its bytes answers `doc`, and a `query("footnote")` selector
2191/// finds nothing. Leaf used to discover them by marshalling the whole arena with
2192/// `nodes()` — the very cost the incremental path exists to avoid — behind a
2193/// byte-scan gate that gave documents with no `[^…]:` line a substring search
2194/// instead. twig 3.0's `definitions()` asks the library the question directly,
2195/// so both the marshal and the gate are gone.
2196///
2197/// The link reference definitions `definitions()` also reports are merged on
2198/// the same terms as [`top_level`] merges them — see [`is_placed_definition`].
2199///
2200/// This is the one part of the render that needs an [`Editor`] rather than a
2201/// marshalled node array. The builders themselves stay editor-free; this only
2202/// prepares their input.
2203pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
2204    let mut top = editor.child_spans(None).unwrap_or_default();
2205    let defs: Vec<QueryMatch> = definitions(editor)
2206        .into_iter()
2207        .filter(|m| is_placed_definition(&m.kind, &m.span))
2208        .collect();
2209    if defs.is_empty() {
2210        return top;
2211    }
2212    top.extend(defs);
2213    // Source order — what every offset-keyed thing downstream (rows, stops, the
2214    // splice path's block-for-block match) is built to assume.
2215    top.sort_by_key(|m| m.span.start);
2216    top
2217}
2218
2219/// Every `[^label]: …` definition in the document, in whatever order twig
2220/// reports them.
2221///
2222/// Filtered to [`Kind::Footnote`]: `definitions()` also reports the *link*
2223/// reference definitions (`[foo]: /url`), which are [`top_blocks`]'s business
2224/// and not [`crate::Doc::footnote_at_caret`]'s.
2225///
2226/// Empty when the document can't be walked, which leaves [`top_blocks`] with
2227/// the ordinary top-level children and [`crate::Doc::footnote_at_caret`] with an
2228/// undefined reference — in both cases the same answer as a document that has
2229/// no definitions, which is the right way to degrade.
2230pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2231    definitions(editor)
2232        .into_iter()
2233        .filter(|m| m.kind == Kind::Footnote)
2234        .collect()
2235}
2236
2237/// Every definition twig resolves by label rather than by position — footnote
2238/// and link reference definitions both — or nothing when the document can't be
2239/// walked.
2240fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2241    let Ok(mut doc) = editor.document() else {
2242        return Vec::new();
2243    };
2244    doc.definitions().unwrap_or_default()
2245}
2246
2247/// The label of the footnote definition starting at `start` — the `1` in
2248/// `[^1]: …`. twig gives the `footnote` node no label of its own (no `text`, no
2249/// `name`), and the bytes that spell it belong to no child node either — the
2250/// body `para` starts its *content* past them — so the source is the only place
2251/// to read it from. `None` when what's there isn't a definition after all.
2252pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
2253    let rest = source.get(start..)?.strip_prefix("[^")?;
2254    let end = rest.find("]:")?;
2255    Some(&rest[..end])
2256}
2257
2258/// Where the body of the footnote definition spanning `span` sits in `source` —
2259/// everything past the `[^1]:` marker, which is the part a reader actually wants
2260/// when they follow a reference.
2261///
2262/// Source bytes, verbatim but for the whitespace trimmed off each end: a note
2263/// that says `see *later*` answers with the asterisks in. Rendering that body is
2264/// a frontend's business the same way painting a [`Role`] is, and a caller that
2265/// wants it laid out already has the definition on screen where it was written.
2266///
2267/// The trim is what makes the common case read right — `[^1]: text` has a space
2268/// after the colon that belongs to the marker, not the note, and a definition's
2269/// span runs to the newline ending it.
2270///
2271/// The span is taken at its word, which it has only been safe to do since twig
2272/// 3.1: a djot definition's span used to run *past* its own last line, through
2273/// the blank line separating it from the next block and into that block's first
2274/// byte, so `[^2a]: a note.` came back as `"a note.\n\n["` and the offsets named
2275/// the following note's rows as well as this one's — a reader asking about one
2276/// footnote was shown two. leaf measured the body itself to get around that, and
2277/// paid for it: the scan stopped at the first blank line, so a note with a second
2278/// indented paragraph lost it. Both halves go away with the fix, since a blank
2279/// line *inside* a definition was always interior to the span and still is.
2280///
2281/// A range rather than a slice because "go to note" needs the *position* as much
2282/// as the text, and it needs the position of the body specifically: a
2283/// definition's `[^1]:` marker is decoration the caret can't occupy (the rich
2284/// view draws it as `[1] ` and gives it no stop), so aiming a caret at the
2285/// definition's first byte lands it on the nearest real stop instead — which is
2286/// up in the paragraph *above* the note. The body's first byte is a stop, and is
2287/// where a reader following a reference wants to arrive anyway.
2288pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
2289    let rest = source.get(span.clone())?.strip_prefix("[^")?;
2290    let marker = rest.find("]:")?;
2291    // `span.start` + `[^` + the label + `]:`.
2292    let after_marker = span.start + 2 + marker + 2;
2293    let raw = source.get(after_marker..span.end)?;
2294    // Written as a start plus a length so an all-whitespace body lands on an
2295    // empty range at the end rather than an inverted one.
2296    let start = after_marker + (raw.len() - raw.trim_start().len());
2297    Some(start..start + raw.trim().len())
2298}
2299
2300/// The label of the footnote *reference* spanning `span` — the `1` in `[^1]`.
2301///
2302/// The peer of [`footnote_label`] for the other half of the pair, and needed for
2303/// the same reason: a reference whose node carries neither a `content_span` nor
2304/// a `text` still spells its label plainly in the source. `None` when the bytes
2305/// aren't a reference after all.
2306pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
2307    let rest = source.get(span)?.strip_prefix("[^")?;
2308    let end = rest.find(']')?;
2309    Some(&rest[..end])
2310}
2311
2312/// Where a heading's *content* starts — past the `#`s and the space the rich
2313/// view hides, for an ATX heading; the block's own start for a setext one (which
2314/// has no leading marker) and for a format that spells headings some other way.
2315///
2316/// Only an empty heading needs asking: with any content at all, the row ends on
2317/// its last glyph. Bounded to the heading's own first line so a marker-less
2318/// heading can't scan into the text under it.
2319fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
2320    let end = span.end.min(source.len());
2321    let Some(line) = source.get(span.start..end) else {
2322        return span.start;
2323    };
2324    let line = line.split('\n').next().unwrap_or("");
2325    let hashes = line.len() - line.trim_start_matches('#').len();
2326    if hashes == 0 {
2327        return span.start;
2328    }
2329    let after = &line[hashes..];
2330    span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
2331}
2332
2333struct Builder<'a> {
2334    nodes: &'a [FlatNode],
2335    /// The document source, consulted to place blank-line rows at the source
2336    /// offsets the caret should occupy on them (the AST drops blank lines).
2337    source: &'a str,
2338    /// The word-wrap column budget, or `None` to emit each block as a single
2339    /// unwrapped row (the frontend wraps).
2340    wrap: Option<usize>,
2341    rows: Vec<VRow>,
2342    /// Built alongside `rows`, never instead of them — see [`TableInfo`].
2343    tables: Vec<TableInfo>,
2344    /// The end offset of the last content emitted — the anchor for blank
2345    /// separator rows so the caret never snaps onto one.
2346    last_off: usize,
2347    /// The end of the last block the walk stepped over without drawing — a
2348    /// comment, which the rich view hides. `last_off` moves past it too, for the
2349    /// separators; this is kept apart so the trailing blank lines can be counted
2350    /// from it without also being counted from a code block's closing fence,
2351    /// which `last_off` likewise ends after. `0` until a hidden block is met.
2352    stepped_over: usize,
2353    /// How many rows each block image reserves, keyed by its destination — the
2354    /// frontend's per-image height, threaded in from [`crate::Doc::set_media_rows`]
2355    /// so [`Builder::block_media`] can size the placeholder without core doing any
2356    /// I/O. A destination absent from the map (or a `0`/`1` entry) reserves the
2357    /// bare one-row placeholder, which is the whole-document default and what
2358    /// every existing test — passing an empty map — still gets.
2359    media_rows: &'a HashMap<String, usize>,
2360    /// The glyph a hard break renders as while the current inline run is built:
2361    /// a space in prose (a break folds into the flow the frontend wraps), but a
2362    /// newline (`\n`) inside a table cell, where a row is one source line and the
2363    /// only break it can carry is an explicit one that must show as a line of its
2364    /// own. Set around [`Builder::row_cells`] and otherwise left at `' '`.
2365    break_glyph: Cell<char>,
2366    /// Render a soft break (a bare newline inside a paragraph) as a line break
2367    /// where it was written, rather than folding it into the reflowed paragraph
2368    /// — the `LineFlow::Preserve` behaviour. A soft break emits a `'\n'` glyph
2369    /// (like a hard break in a cell), which [`Builder::emit_wrapped`] turns into
2370    /// a fresh visual row. `false` is the flowing-prose default. Inside a table
2371    /// cell (where `break_glyph` is already `'\n'`) it has no effect: a cell is
2372    /// one line and folds its own soft breaks regardless.
2373    preserve_soft: bool,
2374    /// The source byte range of the one line that should render its markup
2375    /// *raw* — the caret's line under `MarkupMode::Full` (see
2376    /// [`crate::Doc::reveal_line`]). `None` in every other mode and view, which
2377    /// is the delimiters-always-hidden behaviour every build had before the
2378    /// preference existed.
2379    ///
2380    /// Read only by [`Builder::revealed`], which every delimiter-bearing arm of
2381    /// [`Builder::inline`] consults. A range rather than a bare caret offset
2382    /// because the decision is per-*node*, not per-caret: a node is revealed
2383    /// when its span meets this line, so `*em*` shows both its asterisks even
2384    /// with the caret at one end of it.
2385    reveal: Option<Range<usize>>,
2386    /// The content ends of the hidden marks rendered since the last row was
2387    /// pushed — recorded as the inline walk meets each mark, and drained onto
2388    /// the rows as they are emitted (see [`Builder::take_mark_ends`]). A cell
2389    /// rather than a `&mut`, for the reason `break_glyph` is: the inline walk
2390    /// borrows the builder shared.
2391    pending_mark_ends: RefCell<Vec<usize>>,
2392}
2393
2394impl Builder<'_> {
2395    /// Note that the mark `id` closes with a hidden delimiter, so its content
2396    /// end is a caret home — unless the mark is empty, where the end is the
2397    /// start and there is nothing to extend.
2398    fn note_mark_end(&self, id: usize) {
2399        let node = &self.nodes[id];
2400        if let Some(content) = &node.content_span
2401            && content.end < node.span.end
2402            && !content.is_empty()
2403        {
2404            self.pending_mark_ends.borrow_mut().push(content.end);
2405        }
2406    }
2407
2408    /// The pending mark ends at or before `end_src`, for the row ending there
2409    /// — every mark rendered so far that closes on it. A mark's end never
2410    /// exceeds the end of the row its last glyph is on, so the leftovers are
2411    /// those of rows still to come.
2412    fn take_mark_ends(&self, end_src: usize) -> Vec<usize> {
2413        let mut pending = self.pending_mark_ends.borrow_mut();
2414        let (taken, kept): (Vec<usize>, Vec<usize>) =
2415            pending.drain(..).partition(|&o| o <= end_src);
2416        *pending = kept;
2417        taken
2418    }
2419    /// Whether `span` belongs to the line that is showing its raw markup. True
2420    /// only when a reveal line is set (`MarkupMode::Full`) and the two ranges
2421    /// actually meet.
2422    ///
2423    /// Touching at an endpoint counts: an emphasis ending exactly where the line
2424    /// does is on that line, and a zero-length reveal range (the caret alone on
2425    /// a blank line) still meets a node that starts there. The test is
2426    /// deliberately generous — the failure it avoids is revealing one delimiter
2427    /// of a pair while hiding the other, which looks like corruption rather than
2428    /// like markup.
2429    fn revealed(&self, span: &Range<usize>) -> bool {
2430        self.reveal
2431            .as_ref()
2432            .is_some_and(|r| span.start <= r.end && r.start <= span.end)
2433    }
2434
2435    /// The `(opening, closing)` source byte ranges of a node's delimiters — the
2436    /// bytes its `span` holds that its `content_span` doesn't.
2437    ///
2438    /// This is how *every* inline delimiter is recovered, rather than a table of
2439    /// spellings per kind: twig gives `*em*` a span of `13..17` and a content
2440    /// span of `14..16`, so the gaps at each end are the delimiters, whatever
2441    /// they happen to be. That matters because one kind has many spellings —
2442    /// `*em*` and `_em_` are both emphasis, `` `x` `` and ``` ``x`` ``` both
2443    /// verbatim — and re-deriving the text from the source is the only way to
2444    /// show back what the author actually typed. It also gets a link's
2445    /// asymmetric `[` / `](dest)` right for free.
2446    ///
2447    /// `None` when the node has no content span, or when content and span
2448    /// coincide (nothing was elided, so there is nothing to reveal).
2449    fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
2450        let node = &self.nodes[id];
2451        let content = node.content_span.clone()?;
2452        let span = node.span.clone();
2453        // A content span that escapes its own node's span means the two are
2454        // describing different things; reveal nothing rather than slice wildly.
2455        if content.start < span.start || content.end > span.end {
2456            return None;
2457        }
2458        let (open, close) = (span.start..content.start, content.end..span.end);
2459        // A delimiter that spans a newline isn't this line's to reveal — a setext
2460        // heading's `\n=====` underline is the case that arises in practice. It
2461        // would also inject a `'\n'` glyph, which `emit_wrapped` reads as a hard
2462        // row break, so the row would split where the author wrote no break.
2463        let multiline =
2464            |r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
2465        if multiline(&open) || multiline(&close) {
2466            return None;
2467        }
2468        (!open.is_empty() || !close.is_empty()).then_some((open, close))
2469    }
2470
2471    /// Emit the source bytes of `range` as revealed markup — real glyphs, each
2472    /// mapped to its own source byte and each a caret stop, so a delimiter shown
2473    /// is a delimiter that can be selected, edited and deleted like any other
2474    /// text. Styled [`Role::Delimiter`] on top of the run's own style, which is
2475    /// how a frontend tells scaffolding from prose and dims it.
2476    ///
2477    /// Deliberately *not* [`push_escaped_text`]: this is raw source, not parsed
2478    /// text, so there is no escape-driven drift between the two to correct.
2479    fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
2480        let Some(text) = self.source.get(range.clone()) else {
2481            return;
2482        };
2483        push_text(out, text, range.start, base.role(Role::Delimiter));
2484    }
2485
2486    /// Render an inline node's children wrapped in its raw delimiters when the
2487    /// node is on the revealed line, and bare (delimiters resolved away) when it
2488    /// isn't — the shared body of every delimiter-bearing arm of
2489    /// [`inline`](Self::inline).
2490    ///
2491    /// `style` is the resolved styling the content still gets in *both* modes:
2492    /// revealing `*em*` shows the asterisks *and* keeps the text italic, the
2493    /// live-preview behaviour. Showing the markup is not the same as turning the
2494    /// rendering off — that is what [`crate::View::Source`] is for.
2495    fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
2496        let show = self
2497            .revealed(&self.nodes[id].span)
2498            .then(|| self.delims(id))
2499            .flatten();
2500        if let Some((open, _)) = &show {
2501            self.push_delim(out, open, style);
2502        }
2503        self.recurse(id, style, out);
2504        match &show {
2505            Some((_, close)) => self.push_delim(out, close, style),
2506            // Hidden, so the content's end has no glyph after it: give the
2507            // caret its home there.
2508            None => self.note_mark_end(id),
2509        }
2510    }
2511
2512    fn children(&self, id: usize) -> Vec<usize> {
2513        let mut out = Vec::new();
2514        let mut c = self.nodes[id].first_child;
2515        while let Some(cid) = c {
2516            out.push(cid.0 as usize);
2517            c = self.nodes[cid.0 as usize].next_sibling;
2518        }
2519        out
2520    }
2521
2522    /// Render a node's block children, a blank separator between each. `tight`
2523    /// suppresses the *fabricated* separator between adjacent children that share
2524    /// a source line boundary — a tight list item and the sub-list nested in it —
2525    /// while a real blank source line between them still opens a gap.
2526    fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
2527        // Frontmatter (a leading `metadata` block) is document metadata, not
2528        // prose: hide it entirely in the rich-text view. Skipping it here means
2529        // no phantom blank rows for its lines and no separator before the first
2530        // real block — the document opens straight into its content.
2531        let kids: Vec<usize> = self
2532            .children(id)
2533            .into_iter()
2534            .filter(|&c| self.nodes[c].kind != Kind::Metadata)
2535            .collect();
2536        let mut above: Option<BlockClass> = None;
2537        for child in kids {
2538            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2539            let before_sep = self.rows.len();
2540            if let Some(above) = above {
2541                self.emit_separators_before(
2542                    self.nodes[child].span.start,
2543                    pc,
2544                    !tight,
2545                    Boundary { above, below },
2546                );
2547            }
2548            // The first *drawn* child wears the first-row prefix (a bullet, a
2549            // footnote label), not the first child: a comment opening a list
2550            // item draws nothing, and the bullet belongs to what follows it.
2551            let first = if above.is_none() { pf } else { pc };
2552            if self.block_or_hidden(child, before_sep, first, pc) {
2553                above = Some(below);
2554            }
2555        }
2556    }
2557
2558    /// Render `child` after the separator [`Builder::emit_separators_before`]
2559    /// spelled for it from row `before_sep` on, and say whether it drew
2560    /// anything.
2561    ///
2562    /// A block that draws no rows — an HTML comment, which the rich view hides
2563    /// the way it hides frontmatter — is still *there* in the source, and the
2564    /// walk has to step over it: `last_off` moves past it so the next separator
2565    /// counts the blank lines from its end, not from wherever the last drawn
2566    /// block stopped. Left where it was, the separator counted every line of the
2567    /// comment as a blank row; and the cached path, whose per-block builder
2568    /// starts at offset 0, handed back a `last_off` of 0 and counted every line
2569    /// of the *document* — one phantom blank row per source line, once per
2570    /// comment. The separator drawn for it is taken back too, so a hidden block
2571    /// leaves no gap of its own: what stands either side of it meets across one
2572    /// boundary, as if the comment were not there.
2573    fn block_or_hidden(
2574        &mut self,
2575        child: usize,
2576        before_sep: usize,
2577        pf: &[Glyph],
2578        pc: &[Glyph],
2579    ) -> bool {
2580        let after_sep = self.rows.len();
2581        self.block(child, pf, pc);
2582        if self.rows.len() > after_sep {
2583            return true;
2584        }
2585        self.rows.truncate(before_sep);
2586        let end = self.nodes[child].span.end;
2587        self.last_off = self.last_off.max(end);
2588        self.stepped_over = self.stepped_over.max(end);
2589        false
2590    }
2591
2592    /// Render an explicit, ordered list of top-level blocks — [`Builder::blocks`]
2593    /// for a walk that isn't "the children of one node". The document's top level
2594    /// no longer is: a footnote definition is a root beside `doc`, not under it,
2595    /// and [`top_level`] merges it into this list by source position.
2596    ///
2597    /// The separator between blocks is spelled by the same
2598    /// [`Builder::emit_separators_before`] the incremental top-level walk in
2599    /// [`build_cached`] uses, so the two paths can't drift on how a boundary
2600    /// looks.
2601    ///
2602    /// Returns the class of the last block that drew anything — what the
2603    /// trailing blank lines close — or `None` when nothing did.
2604    fn top_blocks(&mut self, ids: &[usize]) -> Option<BlockClass> {
2605        let mut above: Option<BlockClass> = None;
2606        for &child in ids {
2607            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2608            let before_sep = self.rows.len();
2609            if let Some(above) = above {
2610                self.emit_separators_before(
2611                    self.nodes[child].span.start,
2612                    &[],
2613                    true,
2614                    Boundary { above, below },
2615                );
2616            }
2617            if self.block_or_hidden(child, before_sep, &[], &[]) {
2618                above = Some(below);
2619            }
2620        }
2621        above
2622    }
2623
2624    /// Emit the blank separator row(s) that sit between a block ending at the
2625    /// current `last_off` and the next block starting at `next_start`, wearing
2626    /// the continuation prefix `pc`. Shared by [`Builder::blocks`] and the
2627    /// incremental top-level walk so the two can't drift on how a boundary is
2628    /// spelled.
2629    ///
2630    /// The blank line(s) between two blocks are real caret stops, each needing
2631    /// its *own* source offset — one strictly past the previous block's content,
2632    /// else it collides with that block's last row and `pos_of_offset`
2633    /// (first-match-wins) would resolve the caret onto the wrong row, pinning
2634    /// downward motion there.
2635    ///
2636    /// One row *per* blank source line, not a single collapsed separator: an
2637    /// empty paragraph opened between two blocks (Enter in the gap,
2638    /// `…\n\n\n\n…`) must be a navigable empty row, not vanish — else the caret
2639    /// in it snaps onto the *next* block's start and Enter looks like it did
2640    /// nothing.
2641    fn emit_separators_before(
2642        &mut self,
2643        next_start: usize,
2644        pc: &[Glyph],
2645        synthetic: bool,
2646        boundary: Boundary,
2647    ) {
2648        let mut offs = self.blank_rows_between(self.last_off, next_start);
2649        if offs.is_empty() {
2650            if !synthetic {
2651                // A tight list item's own text sits directly above the sub-list
2652                // nested in it — no fabricated gap. The "breathe" row belongs
2653                // between free-standing blocks, not between an item and its
2654                // child list, which the source writes on the very next line. A
2655                // real blank source line (a loose list) still lands a gap below,
2656                // because `blank_rows_between` found it and we never reach here.
2657                return;
2658            }
2659            // A tight gap with no blank line (e.g. a heading directly above its
2660            // text): keep the one conventional separator row so blocks still
2661            // breathe, as they always have.
2662            offs.push(self.blank_line_offset(self.last_off, next_start));
2663        }
2664        let last = offs.len() - 1;
2665        for (k, end_src) in offs.into_iter().enumerate() {
2666            // Only the drawn-only rows carry the boundary: the navigable blank
2667            // lines between them (and every blank line under preserve-soft flow)
2668            // are somewhere text can go, not a gap between blocks, and a frontend
2669            // that shrank one would be shrinking a line the author is typing on.
2670            let drawn = !self.preserve_soft && (k == 0 || k == last);
2671            // The blank line a boundary is *drawn* with isn't a place text can
2672            // go. The first one closes the block above and the last one opens the
2673            // block below — with a single blank line, the usual case, doing both
2674            // at once. Typing on either just continues the paragraph it abuts,
2675            // since the blank line it would need to be a paragraph of its own is
2676            // the very line being typed on. So they're a gap, like a table's
2677            // border: drawn, clickable, never a caret's home.
2678            //
2679            // The lines *between* them are the real ones. That's what Enter
2680            // opens: it inserts a paragraph break (`\n\n`), which leaves a blank
2681            // line spare on each side and the caret on the navigable line
2682            // between them.
2683            //
2684            // Preserve flow is the exception: there a bare `\n` is a visible line
2685            // break the author edits directly, so a lone blank line *is* a caret
2686            // home — typing on it makes the soft break the mode exists to show,
2687            // and Enter at a line's end lands the caret on exactly this row. So no
2688            // separator is drawn-only; every blank line is navigable.
2689            self.rows.push(VRow {
2690                glyphs: pc.to_vec(),
2691                end_src,
2692                decoration: drawn,
2693                code: false,
2694                code_lang: None,
2695                directive: false,
2696                directive_label: None,
2697                media: None,
2698                task: None,
2699                leaf_directive: None,
2700                heading: None,
2701                boundary: drawn.then_some(boundary),
2702                mark_ends: Vec::new(),
2703            });
2704        }
2705    }
2706
2707    fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2708        let node = &self.nodes[id];
2709        match node.kind.as_str() {
2710            "doc" | "section" => self.blocks(id, pf, pc, false),
2711            "heading" => {
2712                // A heading whose only visible content is a single image — a
2713                // banner set in an `<h1>` (`<h1><picture><img></picture></h1>`),
2714                // or `# ![](banner.png)` — is a block picture, not text. Render
2715                // it as one; anything with real heading text falls through.
2716                if let Some((m, kind)) = self.media_only(id) {
2717                    self.block_media(m, kind, id, pf);
2718                    return;
2719                }
2720                let level = node.level.unwrap_or(1);
2721                let style = heading_style(level);
2722                let mut glyphs = Vec::new();
2723                // On the revealed line the `# ` comes back as real, editable
2724                // text in front of the heading. Only the opening marker: a
2725                // closing `#`-run (`## title ##`) is covered by the same
2726                // `delims` pair, and a setext underline is excluded there for
2727                // being on another line entirely.
2728                if let Some((open, close)) =
2729                    self.revealed(&node.span).then(|| self.delims(id)).flatten()
2730                {
2731                    self.push_delim(&mut glyphs, &open, style);
2732                    glyphs.extend(self.inline_children_with_trailing(id, style));
2733                    self.push_delim(&mut glyphs, &close, style);
2734                } else {
2735                    glyphs = self.inline_children_with_trailing(id, style);
2736                }
2737                // An *empty* heading — `# ` with nothing typed after it, which is
2738                // what the toolbar's H1 leaves on a blank line — has no glyph for
2739                // its row to end on, so the fallback below is the row's whole
2740                // extent: its only caret stop, and the offset every row after it
2741                // is measured from. The block's start is the wrong answer for
2742                // both, because it sits *in front of* the `# ` the rich view
2743                // hides: the caret drew (and typed) before the hashes, and the
2744                // rows below inherited an offset short by the marker's length,
2745                // which put the caret on one of them the moment the heading grew
2746                // text. Its content's start is where the caret belongs.
2747                let home = heading_content_start(self.source, &node.span);
2748                let first = self.rows.len();
2749                self.emit_wrapped(glyphs, home, pf, pc);
2750                // Stamp the level on every row the heading just emitted — a
2751                // wrapped heading's continuation rows as much as its first, and
2752                // an empty one's single glyphless row, which is the whole point
2753                // (see [`VRow::heading`]).
2754                for row in &mut self.rows[first..] {
2755                    row.heading = Some(level.min(255) as u8);
2756                }
2757            }
2758            "block_quote" => {
2759                let (start, end) = (node.span.start, node.span.end);
2760                let gutter = synth("│ ", Role::QuoteGutter, start);
2761                let f = concat(pf, &gutter);
2762                let c = concat(pc, &gutter);
2763                // A childless quote — a bare `> ` on an otherwise blank line,
2764                // which is what the toolbar's Quote button leaves there — has no
2765                // inner block to carry the gutter or a caret home, so `blocks`
2766                // emitted *nothing at all*: the quote didn't merely draw
2767                // unstyled, it disappeared, and a document that was only `> `
2768                // rendered zero rows with the caret nowhere to stand. Emit the
2769                // gutter row itself, ending just past the marker, exactly as an
2770                // empty `list_item` emits its bare bullet.
2771                if self.children(id).is_empty() {
2772                    self.push_row_at(f, end.min(self.source.len()));
2773                } else {
2774                    self.blocks(id, &f, &c, false);
2775                    self.emit_quote_trailing_lines(&c, end);
2776                }
2777            }
2778            // A generic `:::name{.class}` fenced-div container (twig's
2779            // `directive`, container form). Core is agnostic of `name` — it's
2780            // the host app's vocabulary (diaryx's `vis` for audience
2781            // visibility, say) and isn't available here regardless: twig only
2782            // threads an `element`'s tag name through `FlatNode::name`, not a
2783            // directive's own identifier. Every row gets marked `directive` (a
2784            // frontend draws a tinted panel around each maximal run, the
2785            // `code`/`code_block` recipe) and the first row carries a label —
2786            // the way a code fence's language rides only its first row.
2787            //
2788            // The label reads BOTH attribute conventions diaryx content
2789            // actually uses: twig's own dot-prefixed classes (`{.public
2790            // .family}`, one combined `class` attr) and bare pandoc-style
2791            // words with no leading dot (`{public family}` — the syntax
2792            // `diaryx_core::visibility`'s hand-rolled publish-time filter and
2793            // apps/web's directive serializer both write; twig parses each
2794            // bare word as its own attribute with an empty value, per
2795            // `languages/markdown/attributes.zig`). Reading only `.class`
2796            // would leave every *existing* diaryx `:::vis{...}` block
2797            // unlabeled.
2798            // Only the *container* form is the panel below. A `text` directive
2799            // is inline and never reaches the block walker (see `is_inline`); a
2800            // `leaf` one is a standalone block with no body, drawn as a
2801            // placeholder the way an image is.
2802            "container"
2803                if container_is_directive(node)
2804                    && node.directive_form == Some(DirectiveForm::Leaf) =>
2805            {
2806                self.block_directive(id, pf);
2807            }
2808            "container" if container_is_directive(node) => {
2809                let label = directive_attr_label(&node.attrs);
2810                let start_row = self.rows.len();
2811                self.blocks(id, pf, pc, false);
2812                for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
2813                    row.directive = true;
2814                    if i == 0 {
2815                        row.directive_label = label.clone();
2816                    }
2817                }
2818                // Anchor the block's end past its closing `:::` fence, exactly as
2819                // the code-block arm anchors past its ```` ``` ````. A container's
2820                // last content row ends at its last *child*, before the fence and
2821                // the blank line under it, so the separator logic counted the
2822                // fence line as a blank row of its own and drew a second boundary
2823                // — one gap's worth of margin twice, under every fenced div.
2824                self.last_off = node.span.end;
2825            }
2826            "bullet_list" | "ordered_list" | "task_list" => {
2827                let ordered = node.kind == Kind::OrderedList;
2828                let mut item_no = 0usize;
2829                let kids = self.children(id);
2830                for (i, child) in kids.iter().copied().enumerate() {
2831                    let kind = &self.nodes[child].kind;
2832                    if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
2833                        let start = self.nodes[child].span.start;
2834                        item_no += 1;
2835                        // A task item's box replaces the bullet rather than
2836                        // joining it. The `[ ] ` that spells it is markup twig
2837                        // has already consumed — the item's paragraph *content*
2838                        // starts past it — so without a drawn box a task item
2839                        // was indistinguishable from a plain bullet, ticked or
2840                        // not. `☐`/`☑` is the marker for the same reason `•` is:
2841                        // it stands where the source's own marker stands. Which
2842                        // way it faces is `checked`, straight off the node.
2843                        let checked = self.nodes[child].checked;
2844                        let marker = match (checked, ordered) {
2845                            (Some(true), _) => "☑ ".to_string(),
2846                            (Some(false), _) => "☐ ".to_string(),
2847                            (None, true) => format!("{item_no}. "),
2848                            (None, false) => "• ".to_string(),
2849                        };
2850                        let bullet = synth(&marker, Role::ListMarker, start);
2851                        let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
2852                        let first_row = self.rows.len();
2853                        self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
2854                        // On the item's first row, the way `code_lang` rides the
2855                        // first row of its block.
2856                        if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
2857                            row.task = Some(c);
2858                        }
2859                    } else {
2860                        // twig can nest a *following* top-level block as a direct
2861                        // child of the list rather than a sibling of it — e.g.
2862                        // `- item\n\n> quote` parses the block quote under the
2863                        // `bullet_list`. It isn't a list item, so render it de-nested:
2864                        // no bullet, at the list's own prefix, with the usual block
2865                        // separator — never `• │ quote`.
2866                        if i > 0 {
2867                            self.emit_separators_before(
2868                                self.nodes[child].span.start,
2869                                pc,
2870                                true,
2871                                Boundary {
2872                                    above: BlockClass::from_node_kind(
2873                                        &self.nodes[kids[i - 1]].kind,
2874                                    ),
2875                                    below: BlockClass::from_node_kind(&self.nodes[child].kind),
2876                                },
2877                            );
2878                        }
2879                        self.block(child, pc, pc);
2880                    }
2881                }
2882            }
2883            "list_item" | "task_list_item" => {
2884                // A childless item — the empty bullet you get the instant you
2885                // press Enter to open a new one — has no inner block to carry the
2886                // marker prefix or a caret home, so `blocks` would emit nothing
2887                // and the new bullet simply wouldn't appear until something was
2888                // typed into it. Emit the prefixed row itself, ending at a caret
2889                // stop just past the marker (the item's `span.end`), the way an
2890                // empty paragraph emits its one prefixed row via `emit_wrapped`.
2891                if self.children(id).is_empty() {
2892                    let home = self.nodes[id].span.end.min(self.source.len());
2893                    self.push_row_at(pf.to_vec(), home);
2894                } else {
2895                    // Tight: an item's text and the list nested under it butt
2896                    // together (`• a` / `  • b`), no fabricated blank row between —
2897                    // a loose item's real blank line still parts them.
2898                    self.blocks(id, pf, pc, true);
2899                }
2900            }
2901            // A footnote *definition* (`[^1]: the note`). It reaches this walker
2902            // only because [`top_level`] merges it back in — twig hangs it off no
2903            // parent at all, so a walk from `doc` never sees one and every byte
2904            // of its body used to render as nothing.
2905            //
2906            // Drawn as a hanging-indent item, the way a list item is: the marker
2907            // reads `[1] `, matching the `[1]` its references render as, so the
2908            // two can be paired by eye, and the body wraps under it. The marker
2909            // is synthetic decoration (one shared offset, never a caret stop) —
2910            // the `[^1]: ` that spells it in the source is markup, hidden like a
2911            // heading's `# `.
2912            "footnote" => {
2913                let (start, end) = (node.span.start, node.span.end);
2914                let source = self.source;
2915                let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
2916                let indent = " ".repeat(text_width(&marker));
2917                let f = concat(pf, &synth(&marker, Role::ListMarker, start));
2918                let c = concat(pc, &synth(&indent, Role::Body, start));
2919                if self.children(id).is_empty() {
2920                    // A definition with no body yet — the instant `[^1]: ` has
2921                    // been typed and nothing after it. `blocks` would emit
2922                    // nothing and the definition simply wouldn't appear, so emit
2923                    // the marker row itself with a caret home just past it,
2924                    // exactly as an empty list item does.
2925                    self.push_row_at(f, end.min(source.len()));
2926                } else {
2927                    self.blocks(id, &f, &c, false);
2928                }
2929            }
2930            // A link reference definition (`[foo]: /url`): resolved by label
2931            // into the links that use it, and drawn nowhere — the rich view has
2932            // no more use for its line than for a comment's. It is walked at all
2933            // (see [`top_level`]) so [`Builder::block_or_hidden`] can step the
2934            // walk past its bytes rather than count them as blank lines.
2935            "reference" => {}
2936            "table" => self.table(id, pf, pc),
2937            "code_block" => {
2938                let style = Style::default().role(Role::Code);
2939                let text = node.text.clone().unwrap_or_default();
2940                // Cut the block's *terminator*, not every trailing newline. A
2941                // block whose last line is empty spells that as a second `\n`,
2942                // and `trim_end_matches` ate it along with the terminator: the
2943                // Return that made the line got no row, so the caret placed on
2944                // it fell through to the paragraph below and typing landed
2945                // outside the block. twig's `content_span` is `text` less
2946                // exactly this one newline, so cutting one and no more is also
2947                // what keeps `code_line_offsets` lined up.
2948                let lines: Vec<&str> = text
2949                    .strip_suffix('\n')
2950                    .unwrap_or(text.as_str())
2951                    .split('\n')
2952                    .collect();
2953                // Each line at its own source offset, so the caret can walk the
2954                // code a character at a time like any other text. Where the
2955                // lines can't be lined up with the source there's no honest
2956                // offset to give, so the block maps coarsely to its start (and
2957                // stays a source-view job, as all of it once was).
2958                let offs = node
2959                    .content_span
2960                    .as_ref()
2961                    .and_then(|c| self.code_line_offsets(c, &lines));
2962                // The fence's info string, carried on the block's first row as
2963                // its language label (`None` for an indented block or a bare
2964                // fence). Kept on the row so it rides the block cache.
2965                let lang = code_language(self.source, node.span.start);
2966                // The block's syntax highlighting, a token per byte range of
2967                // each line — `None` unless the fence names a language the
2968                // grammars know (and unless the `syntax` feature is on). Done
2969                // here, once per build of the block, because the rows it
2970                // colours ride the block cache: an edit elsewhere in the
2971                // document reuses them, tokens and all.
2972                let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
2973                for (i, raw) in lines.iter().enumerate() {
2974                    let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
2975                    // No gutter glyph: the block is set apart by the border and
2976                    // tint a frontend draws around the whole run of `code` rows,
2977                    // not by a per-line mark. Just the block prefix (a list
2978                    // indent, a quote gutter) and the code text.
2979                    let mut glyphs: Vec<Glyph> = pf.to_vec();
2980                    match tokens.as_ref().and_then(|t| t.get(i)) {
2981                        Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
2982                        None => push_text(&mut glyphs, raw, at, style),
2983                    }
2984                    // Explicitly past the line's *text*: a blank code line has no
2985                    // glyph, and any prefix's offset would put the row's end
2986                    // inside the next line.
2987                    self.push_row_at(glyphs, at + raw.len());
2988                    if let Some(row) = self.rows.last_mut() {
2989                        row.code = true;
2990                        if i == 0 {
2991                            row.code_lang = lang.clone();
2992                        }
2993                    }
2994                }
2995                // Anchor the block's end past its closing fence. Its last content
2996                // row ends at the last code line, before the ``` and the blank
2997                // line under it; without this the separator logic would count the
2998                // closing-fence line as its own blank row and open a phantom
2999                // second gap below the block.
3000                self.last_off = node.span.end;
3001            }
3002            "thematic_break" => {
3003                let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
3004                let w = full.saturating_sub(prefix_width(pf)).max(4);
3005                let mut glyphs = pf.to_vec();
3006                for _ in 0..w {
3007                    glyphs.push(Glyph {
3008                        ch: '─',
3009                        style: Style::default().role(Role::Rule),
3010                        src: node.span.start,
3011                        // A rule is a block the caret can sit on, as it always
3012                        // has; it maps coarsely to the block's start.
3013                        stop: true,
3014                    });
3015                }
3016                // The dashes share one caret home in front of the atomic block,
3017                // while the row's end is the second home just past its source.
3018                // Without that trailing stop a final rule made the document end
3019                // unreachable: Right could not cross it and a click in the
3020                // empty space below it snapped back before the rule.
3021                let after_line = node.span.end
3022                    + self.source[node.span.end..]
3023                        .strip_prefix("\r\n")
3024                        .map_or_else(
3025                            || usize::from(self.source[node.span.end..].starts_with('\n')),
3026                            |_| 2,
3027                        );
3028                self.push_row_at(glyphs, after_line);
3029            }
3030            // A block-level image node with no wrapping paragraph — a promoted
3031            // top-level HTML `<img>` lands as a direct `doc` child like this
3032            // (a Markdown `![](…)` comes wrapped in a `para`, handled below).
3033            "image" => self.block_media(id, MediaKind::Image, id, pf),
3034            // The same case for a promoted top-level `<video>`/`<audio>`, which
3035            // arrives as a generic `container` rather than a node kind of its
3036            // own. It can't be found by the `media_only` scan below the way a
3037            // wrapped one is: that scan looks at a wrapper's *children*, and here
3038            // the media element is itself the block.
3039            "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3040                let kind = match element_tag(node) {
3041                    Some("audio") => MediaKind::Audio,
3042                    _ => MediaKind::Video,
3043                };
3044                self.block_media(id, kind, id, pf);
3045            }
3046            _ => {
3047                // A container of blocks, or an inline-bearing paragraph.
3048                let kids = self.children(id);
3049                // A block-level image: a paragraph (or other wrapper — a
3050                // `<picture>`, an `<h1>` banner) whose only visible content is a
3051                // single `image` node. Render it as a placeholder row + record an
3052                // [`MediaInfo`] a capable frontend replaces. An image mixed with
3053                // real text or other images on the line isn't block-level and
3054                // falls through to the inline path below, still as its alt text.
3055                if let Some((m, kind)) = self.media_only(id) {
3056                    self.block_media(m, kind, id, pf);
3057                    return;
3058                }
3059                let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
3060                if inline || kids.is_empty() {
3061                    let glyphs = self.inline_children_with_trailing(id, Style::default());
3062                    if !glyphs.is_empty() {
3063                        self.emit_wrapped(glyphs, node.span.start, pf, pc);
3064                    }
3065                } else {
3066                    self.blocks(id, pf, pc, false);
3067                }
3068            }
3069        }
3070    }
3071
3072    /// Render a table as a box-drawn grid: every column as wide as its widest
3073    /// cell, the header bold and ruled off, each cell padded to its column's
3074    /// alignment. This is the *default* monospace rendering (see
3075    /// [`VisualMap::rows`]); the same cells are also published structurally as
3076    /// [`TableInfo`], so a frontend that lays the grid out in its own units draws
3077    /// from there and skips the picture built here.
3078    ///
3079    /// The alignment comes from twig's `cell.alignment` — the delimiter row
3080    /// (`|:--|--:|`) that spells it out is consumed by the parser and leaves no
3081    /// node, so the snapshot is the only source for it.
3082    ///
3083    /// Borders and padding are *decoration*: they carry the source offset of the
3084    /// text they surround, so a click lands in that cell, but they're never
3085    /// caret stops — the caret steps cell-to-cell instead of into the box art.
3086    fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
3087        let node_end = self.nodes[id].span.end;
3088        // twig's shape is `[caption, row, row, …]`: the caption is always
3089        // present (usually empty in Markdown) and is not part of the grid.
3090        let row_ids: Vec<usize> = self
3091            .children(id)
3092            .into_iter()
3093            .filter(|&c| self.nodes[c].kind == Kind::Row)
3094            .collect();
3095        if row_ids.is_empty() {
3096            return;
3097        }
3098        // Lay every cell out first — the column widths depend on all of them.
3099        let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
3100        let heads: Vec<bool> = row_ids
3101            .iter()
3102            .map(|&r| self.nodes[r].head.unwrap_or(false))
3103            .collect();
3104        let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
3105        if cols == 0 {
3106            return;
3107        }
3108        let mut widths = vec![0usize; cols];
3109        for row in &grid {
3110            for (c, cell) in row.iter().enumerate() {
3111                widths[c] = widths[c].max(cell_width(&cell.glyphs));
3112            }
3113        }
3114        // Every column at its widest cell is only the *wish*; a grid wider than
3115        // the surface has its far side hanging off the edge where no amount of
3116        // caret motion can reach it. Cut it down to what's actually there, and
3117        // let the cells wrap into the space they're given.
3118        if let Some(w) = self.wrap {
3119            fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
3120        }
3121
3122        // Where the picture starts, so a frontend drawing its own grid knows
3123        // which rows to skip. Recorded before the first border goes down.
3124        let rows_start = self.rows.len();
3125
3126        let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
3127        self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
3128        for (ri, row) in grid.iter().enumerate() {
3129            self.push_table_row(row, &widths, pc);
3130            // The rule under the header: only where the head actually ends.
3131            let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
3132            if ends_head {
3133                let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
3134                self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
3135            }
3136        }
3137        self.push_rule(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
3138
3139        // The same cells the picture above was drawn from, published unwrapped
3140        // and unpadded for a frontend that lays them out in pixels.
3141        self.tables.push(TableInfo {
3142            rows_span: rows_start..self.rows.len(),
3143            end_src: node_end,
3144            // The *continuation* prefix: `pf` opens the block and only its first
3145            // row wears it, but every row of a grid is a continuation of the
3146            // block the table sits in.
3147            prefix: pc.to_vec(),
3148            grid: grid
3149                .into_iter()
3150                .zip(heads)
3151                .map(|(cells, head)| TableRow { head, cells })
3152                .collect(),
3153        });
3154        // The table's own end anchors whatever separator follows it; the border
3155        // rows deliberately don't move `last_off` (they hold no content).
3156        self.last_off = node_end;
3157    }
3158
3159    /// One row of laid-out cells, in column order.
3160    fn row_cells(&self, row: usize) -> Vec<TableCell> {
3161        // A cell is one source line, so a break within it is an explicit line
3162        // break (an inline `<br>`) that must render as a line of its own — not the
3163        // flow-folding space a break is in prose.
3164        self.break_glyph.set('\n');
3165        let cells = self
3166            .children(row)
3167            .into_iter()
3168            .filter(|&c| self.nodes[c].kind == Kind::Cell)
3169            .enumerate()
3170            .map(|(col, c)| {
3171                let n = &self.nodes[c];
3172                let style = if n.head.unwrap_or(false) {
3173                    Style::default().bold()
3174                } else {
3175                    Style::default()
3176                };
3177                // Only `content_span` bounds a cell's text, and an EMPTY cell
3178                // has none at all — twig records no interior for it — so both
3179                // offsets would fall back to the cell's `span.start`: on the
3180                // pipe that opens it, or (under a twig that gave every cell
3181                // the whole row's span) the row's start, where every empty
3182                // cell collapses onto one spot before the first `│` and a
3183                // caret there types *before* the table. Derive the interior
3184                // from the span's own pipes and this cell's column instead,
3185                // so each empty cell has a distinct, editable caret home.
3186                let span = n.content_span.clone().unwrap_or_else(|| {
3187                    let off = empty_cell_offset(
3188                        &self.source[n.span.start.min(self.source.len())
3189                            ..n.span.end.min(self.source.len())],
3190                        n.span.start,
3191                        col,
3192                    );
3193                    off..off
3194                });
3195                TableCell {
3196                    glyphs: self.inline_children(c, style),
3197                    start: span.start,
3198                    end: span.end,
3199                    align: n.alignment.unwrap_or(Alignment::Default),
3200                }
3201            })
3202            .collect();
3203        self.break_glyph.set(' ');
3204        cells
3205    }
3206
3207    /// A horizontal rule between/around rows — entirely decoration.
3208    fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3209        let glyphs = concat(prefix, &synth(text, Role::Rule, src));
3210        self.rows.push(VRow {
3211            glyphs,
3212            end_src: src,
3213            decoration: true,
3214            code: false,
3215            code_lang: None,
3216            directive: false,
3217            directive_label: None,
3218            media: None,
3219            task: None,
3220            leaf_directive: None,
3221            heading: None,
3222            boundary: None,
3223            mark_ends: Vec::new(),
3224        });
3225    }
3226
3227    /// One `│ a │ b │` row of the grid: real cell text between decoration.
3228    ///
3229    /// A row of cells is not a row of the screen — a cell wrapped to its column
3230    /// spans several, each one `│`-divided across the full width so the grid
3231    /// stays square. Cells in the same row are laid out independently and run
3232    /// out at their own heights; a column that has run dry pads out as
3233    /// decoration while its neighbours keep going.
3234    fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
3235        let fallback = cells.last().map(|c| c.end).unwrap_or(0);
3236        let laid: Vec<Vec<Vec<Glyph>>> = cells
3237            .iter()
3238            .enumerate()
3239            .map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
3240            .collect();
3241        let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
3242
3243        for j in 0..height {
3244            let mut glyphs = prefix.to_vec();
3245            for (ci, &w) in widths.iter().enumerate() {
3246                let cell = cells.get(ci);
3247                let line = laid.get(ci).and_then(|l| l.get(j));
3248                // The divider before this column belongs to the cell it
3249                // introduces, so clicking it lands in that cell — on this line
3250                // of it, which is what's next to the divider being clicked.
3251                let at = line
3252                    .and_then(|l| l.first().map(|g| g.src))
3253                    .or_else(|| cell.map(|c| c.start))
3254                    .unwrap_or(fallback);
3255                glyphs.extend(synth("│", Role::Rule, at));
3256                match (cell, line) {
3257                    (Some(cell), Some(line)) => {
3258                        let pad = w.saturating_sub(glyphs_width(line));
3259                        let (lead, trail) = match cell.align {
3260                            Alignment::Right => (pad, 0),
3261                            Alignment::Center => (pad / 2, pad - pad / 2),
3262                            Alignment::Left | Alignment::Default => (0, pad),
3263                        };
3264                        // Every line renders at least one space after its text
3265                        // (the gutter before `│`), so there is always somewhere
3266                        // to put the "after the last character" caret a line
3267                        // needs. It's the one padding glyph that is a stop: on
3268                        // the cell's last line that's the cell's end, and on any
3269                        // other it's the space the wrap consumed.
3270                        let last = laid[ci].len() == j + 1;
3271                        let end = match last {
3272                            true => cell.end,
3273                            false => line
3274                                .last()
3275                                .map(|g| g.src + g.ch.len_utf8())
3276                                .unwrap_or(cell.end),
3277                        };
3278                        glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
3279                        glyphs.extend(line.iter().cloned());
3280                        glyphs.push(Glyph {
3281                            ch: ' ',
3282                            style: Style::default(),
3283                            src: end,
3284                            stop: true,
3285                        });
3286                        glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
3287                    }
3288                    // A ragged row, or a column whose cell ended higher up: pad
3289                    // it out so the grid stays square.
3290                    _ => {
3291                        let at = cell.map(|c| c.end).unwrap_or(fallback);
3292                        glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
3293                    }
3294                }
3295            }
3296            glyphs.extend(synth("│", Role::Rule, fallback));
3297            // The row ends where its last stop does. A table row has no gap
3298            // between its final cell and the border, so inventing an end past
3299            // that would be a stop with nothing under it.
3300            let end_src = glyphs
3301                .iter()
3302                .rev()
3303                .find(|g| g.stop)
3304                .map_or(fallback, |g| g.src);
3305            let mark_ends = self.take_mark_ends(end_src);
3306            self.rows.push(VRow {
3307                glyphs,
3308                end_src,
3309                decoration: false,
3310                code: false,
3311                code_lang: None,
3312                directive: false,
3313                directive_label: None,
3314                media: None,
3315                task: None,
3316                leaf_directive: None,
3317                heading: None,
3318                boundary: None,
3319                mark_ends,
3320            });
3321        }
3322    }
3323
3324    /// Render a block-level image, video, or audio as one placeholder row: the
3325    /// `🖼 alt` / `🎬 alt` / `🔊 alt` label styled [`Role::Image`], every glyph
3326    /// mapped to the media's start offset and a caret stop there (they share the
3327    /// offset, so the stop table dedups them to a single home in front of it, as
3328    /// a rule's dashes do), and the row's end stop set past it so the caret can
3329    /// also rest after it. The row carries a [`MediaMark`] so [`media_spans`]
3330    /// publishes it as a [`MediaInfo`] a capable frontend replaces with the real
3331    /// picture or player; a plain surface paints the label as-is. `pf` is the
3332    /// block prefix (a list indent, a quote gutter) the row opens with, exactly
3333    /// as every other block honours it.
3334    fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
3335        let node = &self.nodes[img];
3336        let start = node.span.start;
3337        let end = node.span.end;
3338        // An `image`'s URL is twig's `destination`; a `<video>`/`<audio>` is a
3339        // generic element, so its URL is the `src` attribute — and may be absent
3340        // entirely, the element naming its candidates in child `<source>`s.
3341        let destination = match kind {
3342            MediaKind::Image => node.destination.clone().unwrap_or_default(),
3343            MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
3344        };
3345        let poster = match kind {
3346            MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
3347            MediaKind::Image | MediaKind::Audio => String::new(),
3348        };
3349        // The `<source>`s under the media element itself, not under `wrapper`: a
3350        // `<video>` is its own container, unlike an `<img>`, whose `<picture>`
3351        // alternatives are its *siblings* and so only reachable from the wrapper.
3352        let sources = match kind {
3353            MediaKind::Image => self.media_sources(wrapper),
3354            MediaKind::Video | MediaKind::Audio => self.media_sources(img),
3355        };
3356        let alt = self.image_alt(img);
3357        let sigil = kind.sigil();
3358        let label = if alt.is_empty() {
3359            // With no alt, name the file — but a `<video>` with neither `src` nor
3360            // alt has only its `<source>`s to be named by, so fall back to the
3361            // first candidate rather than labelling the row a bare sigil.
3362            let named = if destination.is_empty() {
3363                sources
3364                    .first()
3365                    .map(|s| s.srcset.as_str())
3366                    .unwrap_or_default()
3367            } else {
3368                &destination
3369            };
3370            format!("{sigil} {}", media_label(named))
3371        } else {
3372            format!("{sigil} {alt}")
3373        };
3374        let style = Style::default().role(Role::Image);
3375        let mut glyphs = pf.to_vec();
3376        for ch in label.chars() {
3377            glyphs.push(Glyph {
3378                ch,
3379                style,
3380                src: start,
3381                stop: true,
3382            });
3383        }
3384        // How many rows the frontend wants for this picture: the label row plus
3385        // the blank fillers below it. Absent (a GUI that lays images out in
3386        // pixels, an image that didn't resolve, or a plain surface) means the
3387        // bare one-row placeholder.
3388        let rows = self
3389            .media_rows
3390            .get(&destination)
3391            .copied()
3392            .unwrap_or(1)
3393            .max(1);
3394        // End past the image so the caret has a stop after it: the last glyph's
3395        // offset is the image *start*, not its extent, so `push_row`'s
3396        // last-glyph rule would strand the end stop inside the markup.
3397        self.push_row_at(glyphs, end);
3398        if let Some(row) = self.rows.last_mut() {
3399            row.media = Some(MediaMark {
3400                kind,
3401                destination,
3402                sources,
3403                alt,
3404                poster,
3405                rows,
3406            });
3407        }
3408        // Reserve the picture's remaining height as blank `decoration` rows: drawn
3409        // (so the frontend has the vertical room to paint the raster over them),
3410        // but holding no caret and contributing no stops — vertical motion steps
3411        // over them and the caret's only homes stay the stop in front of the image
3412        // and the one just past it, both on the label row above. They anchor at the
3413        // image's end offset so a click on the picture's lower half lands after it,
3414        // the nearest caret home. Mirrors how a table's box-rule rows reserve space
3415        // without ever holding the caret.
3416        for _ in 1..rows {
3417            self.rows.push(VRow {
3418                glyphs: Vec::new(),
3419                end_src: end,
3420                decoration: true,
3421                code: false,
3422                code_lang: None,
3423                directive: false,
3424                directive_label: None,
3425                media: None,
3426                task: None,
3427                leaf_directive: None,
3428                heading: None,
3429                boundary: None,
3430                mark_ends: Vec::new(),
3431            });
3432        }
3433        self.last_off = end;
3434    }
3435
3436    /// The `<picture>` alternatives inside block-image `wrapper`, in document
3437    /// order — every `<source>` element in its subtree. Empty when there's no
3438    /// `<picture>`. Each is a `<source>`'s `media` + `srcset`; core keeps them
3439    /// verbatim and picks none (see [`MediaSource`]). A `<source>` with no
3440    /// `srcset` is dropped (nothing to load); its `media` may be empty (an
3441    /// unconditional override), which a frontend treats as always-matching.
3442    ///
3443    /// It scans the wrapper's whole subtree (via the forward `first_child` /
3444    /// `next_sibling` links, the reliable ones) rather than the `<img>`'s parent,
3445    /// for two reasons. A `<picture>` reaches core in two shapes: twig promotes a
3446    /// block `<picture>` to an `element(picture)` wrapping `[source, img]`, but
3447    /// leaves an inline one's tags as raw siblings — `[raw "<picture>", source,
3448    /// img, raw "</picture>"]` — so the `<source>`s sit at different depths in
3449    /// the two. And the editor's flat arena leaves a promoted inline node's
3450    /// `parent` back-pointer dangling on a phantom root, so only the wrapper
3451    /// (known at the call site) is a trustworthy anchor. A block image is the
3452    /// sole visible content of its wrapper, so every `<source>` under it is its
3453    /// picture's.
3454    fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
3455        let mut out = Vec::new();
3456        self.collect_sources(wrapper, &mut out);
3457        out
3458    }
3459
3460    fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
3461        for c in self.children(id) {
3462            let node = &self.nodes[c];
3463            if node.name.as_deref() == Some("source") {
3464                // `<picture>` spells its candidate `srcset`, `<video>`/`<audio>`
3465                // spell it `src`. Both mean "the URL to load", so they normalise
3466                // onto one field; `srcset` wins where (illegally) both appear.
3467                let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
3468                if let Some(srcset) = url {
3469                    out.push(MediaSource {
3470                        media: attr_of(node, "media").unwrap_or_default(),
3471                        srcset,
3472                        mime: attr_of(node, "type").unwrap_or_default(),
3473                    });
3474                }
3475            }
3476            self.collect_sources(c, out);
3477        }
3478    }
3479
3480    /// The single block-level media `id`'s subtree resolves to, or `None`.
3481    ///
3482    /// A wrapper is a block picture when the only *visible* thing under it is one
3483    /// image: whitespace-only text and structure-only elements (a `<picture>`'s
3484    /// `<source>`, which declares an alternate but paints nothing) don't count,
3485    /// and the search descends through wrapping elements (`<picture>`, a linking
3486    /// `<a>`). This is what makes `<p><img></p>`, a bare `<img>`, and
3487    /// `<h1><picture>…<img></picture></h1>` all render as one framed picture.
3488    /// Any real text, or a second image, means it isn't image-only — it falls
3489    /// back to inline rendering, where the image still shows as its alt text.
3490    ///
3491    /// [`FlatNode`]'s snapshot doesn't carry an element's tag name, so a
3492    /// `<source>` can't be skipped by name — but it needs no special case:
3493    /// contributing no image and no text, it's simply invisible to the scan.
3494    fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
3495        let mut found = None;
3496        let mut count = 0usize;
3497        let mut has_text = false;
3498        self.scan_visual(id, &mut found, &mut count, &mut has_text);
3499        (count == 1 && !has_text).then(|| found.unwrap())
3500    }
3501
3502    /// Walk `id`'s subtree tallying visible leaves for [`media_only`]: each
3503    /// image, `<video>`, or `<audio>` (remembering the last, counting the total)
3504    /// and whether any non-whitespace text appears. Media isn't descended into —
3505    /// an image's inline children are alt text, and a `<video>`'s are its
3506    /// no-support fallback and its `<source>` declarations, none of which is
3507    /// document content.
3508    ///
3509    /// [`media_only`]: Self::media_only
3510    fn scan_visual(
3511        &self,
3512        id: usize,
3513        found: &mut Option<(usize, MediaKind)>,
3514        count: &mut usize,
3515        has_text: &mut bool,
3516    ) {
3517        for c in self.children(id) {
3518            let node = &self.nodes[c];
3519            match node.kind.as_str() {
3520                "image" => {
3521                    *found = Some((c, MediaKind::Image));
3522                    *count += 1;
3523                }
3524                // A `<video>`/`<audio>` reaches core as a generic `container`
3525                // (twig gives neither a semantic node, so `html_elements`
3526                // promotion leaves the tag name on `name`). Counted as media and
3527                // *not* descended into, so its `<source>` children and its
3528                // "your browser does not support…" fallback text neither add a
3529                // second count nor make the block look like text.
3530                "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3531                    let kind = match element_tag(node) {
3532                        Some("audio") => MediaKind::Audio,
3533                        _ => MediaKind::Video,
3534                    };
3535                    *found = Some((c, kind));
3536                    *count += 1;
3537                }
3538                // Text leaves: only non-whitespace counts as visible content.
3539                // (Twig keeps the whitespace `str`s between HTML tags — the
3540                // newlines and indentation inside a `<picture>` — as real nodes.)
3541                "str" | "smart_punctuation" | "verbatim" | "inline_math" => {
3542                    if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
3543                        *has_text = true;
3544                    }
3545                }
3546                // Structural breaks carry no visible glyph of their own.
3547                "soft_break" | "hard_break" | "non_breaking_space" => {}
3548                // Any other wrapper (emphasis, a link, a `<picture>`) is
3549                // transparent to the scan — descend into it.
3550                _ => self.scan_visual(c, found, count, has_text),
3551            }
3552        }
3553    }
3554
3555    /// A leaf directive (`::name{…}`) as one placeholder row — the
3556    /// [`block_media`](Self::block_media) recipe, for the same reason: it is a
3557    /// block that renders as *a thing*, not as text, and the frontend paints
3558    /// whatever the host app's vocabulary makes of it.
3559    ///
3560    /// The row's glyphs are a `⧉ label` (or `⧉ name`) stand-in a plain surface
3561    /// paints as-is, every glyph anchored at the directive's start with a caret
3562    /// stop there, and the row ending past it so the caret can also rest after
3563    /// it. It carries a [`DirectiveMark`] for [`directive_spans`], and is marked
3564    /// [`directive`](VRow::directive) so a frontend already drawing the
3565    /// container form's panel frames this one identically for free.
3566    ///
3567    /// Before this, a leaf directive emitted no rows at all: it was invisible,
3568    /// held no caret, and vertical motion crossed a void where it stood.
3569    fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
3570        let node = &self.nodes[id];
3571        let (start, end) = (node.span.start, node.span.end);
3572        let name = node.name.clone().unwrap_or_default();
3573        let attrs = node.attrs.clone();
3574        let label = self.image_alt(id); // its `[label]` children, flattened
3575        let shown = if label.is_empty() { &name } else { &label };
3576        let style = Style::default().role(Role::Image);
3577        let mut glyphs = pf.to_vec();
3578        for ch in format!("⧉ {shown}").chars() {
3579            glyphs.push(Glyph {
3580                ch,
3581                style,
3582                src: start,
3583                stop: true,
3584            });
3585        }
3586        // End past the directive so the caret has a stop after it — the same
3587        // reason `block_media` anchors its row at the image's end.
3588        self.push_row_at(glyphs, end);
3589        if let Some(row) = self.rows.last_mut() {
3590            row.directive = true;
3591            row.leaf_directive = Some(DirectiveMark {
3592                name,
3593                attrs,
3594                label,
3595                rows: 1,
3596            });
3597        }
3598        self.last_off = end;
3599    }
3600
3601    /// An image's alt text: the flattened text of its inline descendants (an
3602    /// image's children *are* its alt content), empty when it has none. Also a
3603    /// leaf directive's `[label]`, which is the same shape — inline children
3604    /// standing for the block.
3605    fn image_alt(&self, id: usize) -> String {
3606        let mut out = String::new();
3607        self.collect_text(id, &mut out);
3608        out
3609    }
3610
3611    /// Append every descendant's `text` to `out`, in document order. Inline text
3612    /// (`str`) nodes are leaves, so a node never contributes both its own text and
3613    /// a child's — no double counting.
3614    fn collect_text(&self, id: usize, out: &mut String) {
3615        for c in self.children(id) {
3616            if let Some(t) = &self.nodes[c].text {
3617                out.push_str(t);
3618            }
3619            self.collect_text(c, out);
3620        }
3621    }
3622
3623    fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
3624        let mut out = Vec::new();
3625        for c in self.children(id) {
3626            self.inline(c, base, &mut out);
3627        }
3628        out
3629    }
3630
3631    /// [`inline_children`](Self::inline_children) plus any trailing whitespace the
3632    /// block carries past its inline content (see [`trailing_ws_glyphs`]). Used
3633    /// for the leaf inline blocks — paragraphs and headings — whose own `span`
3634    /// bounds exactly one line of text, so the trailing gap is theirs. *Not* for
3635    /// a table cell, whose `span` is the whole row and would swallow the
3636    /// delimiters and neighbours between it and the row's end.
3637    ///
3638    /// [`trailing_ws_glyphs`]: Self::trailing_ws_glyphs
3639    fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
3640        let mut out = self.inline_children(id, base);
3641        out.extend(self.trailing_ws_glyphs(id, base));
3642        out
3643    }
3644
3645    /// Glyphs for whatever trailing whitespace a block's source carries past its
3646    /// last inline node — the space(s) at the end of `hello ` that Markdown and
3647    /// Djot drop from the `str` node as insignificant. twig still records them:
3648    /// a block's `content_span` ends at its last meaningful character while its
3649    /// `span` runs to the end of the line's text (before the terminating
3650    /// newline), so the gap between the two *is* that trailing whitespace.
3651    ///
3652    /// Emitting it as real caret-stop glyphs is what lets the caret be drawn
3653    /// past the last visible character. Without it, typing a space at the end of
3654    /// a paragraph moved the caret in the source but not on screen — the caret
3655    /// stuck on the last glyph until the next visible character reparsed the
3656    /// space into an interior `str` node that finally carried it.
3657    ///
3658    /// Restricted to spaces: only they are safe to synthesize one-cell-per-byte,
3659    /// and only they are what the parser silently strips. Anything else in the
3660    /// gap means the span accounting isn't what this assumes, so it's left alone.
3661    fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
3662        let node = &self.nodes[id];
3663        let Some(content) = &node.content_span else {
3664            return Vec::new();
3665        };
3666        let (from, to) = (content.end, node.span.end);
3667        let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
3668            return Vec::new();
3669        };
3670        if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
3671            return Vec::new();
3672        }
3673        slice
3674            .bytes()
3675            .enumerate()
3676            .map(|(i, _)| Glyph {
3677                ch: ' ',
3678                style,
3679                src: from + i,
3680                stop: true,
3681            })
3682            .collect()
3683    }
3684
3685    fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
3686        let node = &self.nodes[id];
3687        match node.kind.as_str() {
3688            "str" | "smart_punctuation" => push_escaped_text(
3689                out,
3690                node.text.as_deref().unwrap_or(""),
3691                node.span.clone(),
3692                self.source,
3693                base,
3694            ),
3695            "soft_break" | "hard_break" | "non_breaking_space" => {
3696                // A break renders as a real, caret-navigable glyph — but twig
3697                // gives it no span of its own (`0..0`), so the offset comes from
3698                // the text in front of it: one *past* the last glyph, which is
3699                // the newline the break stands for. Past, not on: sharing the
3700                // previous glyph's offset would put two stops on one byte, and a
3701                // caret that can't change offset can't move.
3702                let src = if node.span.start != 0 {
3703                    node.span.start
3704                } else {
3705                    out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
3706                };
3707                // A *hard* break renders as this run's break glyph — a newline
3708                // inside a table cell (its own line), the same space in prose the
3709                // frontend re-wraps. A soft break normally folds into a space;
3710                // under `LineFlow::Preserve` it renders as a `'\n'` too, so the
3711                // author's line break shows where it was written. Never inside a
3712                // cell (`break_glyph` is `'\n'` there): a cell is one line and
3713                // folds its own soft breaks regardless.
3714                let ch = if node.kind == Kind::HardBreak {
3715                    self.break_glyph.get()
3716                } else if node.kind == Kind::SoftBreak
3717                    && self.preserve_soft
3718                    && self.break_glyph.get() == ' '
3719                {
3720                    '\n'
3721                } else {
3722                    ' '
3723                };
3724                out.push(Glyph {
3725                    ch,
3726                    style: base,
3727                    src,
3728                    stop: true,
3729                });
3730            }
3731            // A cell's only spelling for an in-line break is a raw `<br>`; read it
3732            // back as one (outside a cell it stays the literal text it falls to
3733            // below). The tag's bytes carry no stop of their own — the line it
3734            // ends stops just before it, the next just after.
3735            "raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
3736                out.push(Glyph {
3737                    ch: '\n',
3738                    style: base,
3739                    src: node.span.start,
3740                    stop: true,
3741                });
3742            }
3743            "emph" => self.inline_delimited(id, base.italic(), out),
3744            "strong" => self.inline_delimited(id, base.bold(), out),
3745            // A coloured highlight's emoji is spelling, not content: twig strips
3746            // it and records the colour on the node, so the glyphs are the
3747            // author's words and the colour rides the role. Revealed markup
3748            // still shows the emoji, because `delims` reads the source bytes
3749            // between the span and the content span — which is exactly the
3750            // `==🔴 ` the author typed.
3751            "mark" => {
3752                let color = MarkColor::from_attrs(&node.attrs);
3753                self.inline_delimited(id, base.role(Role::Mark(color)), out)
3754            }
3755            "insert" => self.inline_delimited(id, base.underline(), out),
3756            "delete" => self.inline_delimited(id, base.strikethrough(), out),
3757            // The one pair whose whole meaning is *where the glyphs sit*. Drawn
3758            // in the surrounding style otherwise, so `^**2**^` stays bold and a
3759            // superscript inside a heading keeps the heading's role — which is
3760            // exactly why this is a `Baseline` and not a `Role`.
3761            "superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
3762            "subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
3763            "verbatim" | "inline_math" => {
3764                // The interior begins at `content_span.start` — past however many
3765                // backticks the fence used, which `span.start + 1` only guessed
3766                // right for a single one. Fall back to that guess if it's absent.
3767                let at = node
3768                    .content_span
3769                    .as_ref()
3770                    .map_or(node.span.start + 1, |c| c.start);
3771                let style = base.role(Role::Code);
3772                // Not `inline_delimited`: verbatim has no child nodes to recurse
3773                // into — its content is its own `text` — so the fences bracket a
3774                // `push_text` instead. The fences themselves keep `Role::Code`'s
3775                // sibling treatment via `push_delim`'s role override.
3776                let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
3777                if let Some((open, _)) = &show {
3778                    self.push_delim(out, open, style);
3779                }
3780                push_text(out, node.text.as_deref().unwrap_or(""), at, style);
3781                match &show {
3782                    Some((_, close)) => self.push_delim(out, close, style),
3783                    None => self.note_mark_end(id),
3784                }
3785            }
3786            // A text directive (`:name[label]{…}`) — the inline form of a generic
3787            // directive. Its `[label]` children are the visible text; the name and
3788            // the `{…}` attributes are the host app's vocabulary (diaryx's
3789            // `:vis[…]`) and stay hidden markup, exactly as a link's `](dest)` is.
3790            // Drawn in the surrounding style: a role of its own would need one
3791            // every frontend maps, and the bug this fixes is that the text was
3792            // invisible, not that it was unstyled.
3793            "container" if container_is_directive(node) && !self.children(id).is_empty() => {
3794                self.recurse(id, base, out)
3795            }
3796            // No `[label]`, so there are no children to render and recursing
3797            // emitted *nothing*: the directive's bytes vanished from the document
3798            // and left no caret stop behind. What to draw instead turns on
3799            // whether the syntax looks deliberate.
3800            //
3801            // Bare `:word` almost never is. twig matches a colon followed by any
3802            // letter-led word (`scanTextDirective`, deliberately matching remark),
3803            // so ordinary prose is full of them — `:see below`, a `:smile:`
3804            // shortcode, a stray colon before a word. Those are prose, and prose
3805            // renders as itself: every byte visible, every byte a caret stop, so a
3806            // colon typed by accident can be seen and deleted. Hiding them behind
3807            // a placeholder would be the invisible-and-unreachable failure this
3808            // arm exists to fix, just wearing a nicer glyph.
3809            "container" if container_is_directive(node) && node.attrs.is_empty() => {
3810                let span = node.span.clone();
3811                push_text(
3812                    out,
3813                    self.source.get(span.clone()).unwrap_or(""),
3814                    span.start,
3815                    base,
3816                );
3817            }
3818            // `{…}` attributes, though, are unmistakably deliberate — nobody
3819            // types `:vis{.family}` by accident, and diaryx writes exactly that
3820            // inline. So an attribute-bearing directive with no label draws as a
3821            // chip on `block_directive`'s recipe (`⧉ name attrs`, `Role::Image`),
3822            // the inline peer of the leaf form's placeholder row.
3823            //
3824            // Only the first glyph is a caret stop, and the whole chip shares the
3825            // directive's start offset: the caret treats it as one atomic thing
3826            // rather than walking hidden markup a byte at a time, and a paragraph
3827            // holding nothing but a chip still has a stop to be navigated to.
3828            "container" if container_is_directive(node) => {
3829                let start = node.span.start;
3830                let name = node.name.clone().unwrap_or_default();
3831                let shown = match directive_attr_label(&node.attrs) {
3832                    Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
3833                    Some(attrs) => format!("⧉ {attrs}"),
3834                    None => format!("⧉ {name}"),
3835                };
3836                let style = base.role(Role::Image);
3837                for (i, ch) in shown.chars().enumerate() {
3838                    out.push(Glyph {
3839                        ch,
3840                        style,
3841                        src: start,
3842                        stop: i == 0,
3843                    });
3844                }
3845            }
3846            // A footnote reference (`[^1]`). The label bracketed is what a reader
3847            // needs — bare, `note1` reads as a typo rather than a reference — so
3848            // the `^` is hidden as the spelling artefact it is (a link's
3849            // `](dest)` goes the same way) and the brackets are kept as
3850            // decoration: one shared offset, never a caret stop, like a table's
3851            // borders, so the caret walks the label alone.
3852            //
3853            // Styled `Role::Link`: a reference *is* a link to its definition, and
3854            // every frontend already paints that role. A role of its own would
3855            // need one in each of them, and what a frontend needs to tell the two
3856            // apart is not a paint colour but an answer to "what does clicking
3857            // here do" — which is [`Doc::footnote_at_caret`]'s job, not a glyph's.
3858            //
3859            // Raised, though, because that a reference is *set* differently from
3860            // the prose it interrupts is exactly what makes it read as a
3861            // reference. `[1]` at body size reads as bracketed text.
3862            "footnote_reference" => {
3863                let style = base.role(Role::Link);
3864                // Revealed, the reference is just its source bytes: the `^` that
3865                // is normally elided comes back and every byte becomes a real
3866                // stop, so the brackets stop being decoration and start being
3867                // text. That's the whole point of the mode, and it replaces the
3868                // hand-built chip below rather than decorating it — including the
3869                // raised baseline, since what's on screen there is source, and
3870                // source is set as prose.
3871                if self.revealed(&node.span) {
3872                    self.push_delim(out, &node.span, style);
3873                    return;
3874                }
3875                let style = style.baseline(Baseline::Super);
3876                // The label's own span, so its glyphs map to their true bytes.
3877                // Absent one, it starts past the `[^` that opens the reference.
3878                let (label, at) = match &node.content_span {
3879                    Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
3880                    None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
3881                };
3882                out.push(Glyph {
3883                    ch: '[',
3884                    style,
3885                    src: node.span.start,
3886                    stop: false,
3887                });
3888                push_text(out, label, at, style);
3889                out.push(Glyph {
3890                    ch: ']',
3891                    style,
3892                    src: node.span.end.saturating_sub(1),
3893                    stop: false,
3894                });
3895            }
3896            "link" | "url" | "email" => {
3897                let style = base.role(Role::Link);
3898                if self.children(id).is_empty() {
3899                    // A bare autolink (`<a@b.c>`, a naked URL): the destination
3900                    // *is* the visible text, so there is nothing elided to
3901                    // reveal and both modes draw the same thing.
3902                    push_text(
3903                        out,
3904                        node.destination
3905                            .as_deref()
3906                            .or(node.text.as_deref())
3907                            .unwrap_or("link"),
3908                        node.span.start,
3909                        style,
3910                    );
3911                } else {
3912                    // An inline link reveals asymmetrically — `[` before the
3913                    // label, `](dest)` after it — which the generic
3914                    // span-minus-content derivation already produces.
3915                    self.inline_delimited(id, style, out);
3916                }
3917            }
3918            _ => {
3919                if self.children(id).is_empty() {
3920                    if let Some(t) = &node.text {
3921                        push_text(out, t, node.span.start, base);
3922                    }
3923                } else {
3924                    self.recurse(id, base, out);
3925                }
3926            }
3927        }
3928    }
3929
3930    fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
3931        for c in self.children(id) {
3932            self.inline(c, style, out);
3933        }
3934    }
3935
3936    /// Lay a block's inline `glyphs` into visual rows, prefixing the first with
3937    /// `pf` and the rest with `pc`. A preserved soft break arrives as a `'\n'`
3938    /// glyph (see the `soft_break` arm): a hard row boundary that splits the
3939    /// glyphs so each run lays out on its own and the author's line structure
3940    /// shows on screen. The `'\n'` is dropped from the row it closes and its
3941    /// source offset becomes that row's end stop — exactly how a table cell's
3942    /// in-line `<br>` is handled — so the caret can rest at the line's end
3943    /// without a zero-width control char leaking into what the frontends render.
3944    /// With no `'\n'` present (the folding default, and every build that isn't
3945    /// `LineFlow::Preserve`) there is one run and this is byte-identical to
3946    /// laying the glyphs out directly.
3947    fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
3948        if !glyphs.iter().any(|g| g.ch == '\n') {
3949            self.emit_line(glyphs, block_start, pf, pc, None);
3950            return;
3951        }
3952        // Each run up to a '\n' is a line of its own: the first wears the block's
3953        // opening prefix, every later one the continuation prefix, and the break's
3954        // own offset ends the run's last row. The break glyph is dropped. A
3955        // trailing '\n' flushes its run and leaves nothing behind, so no spurious
3956        // blank row follows it.
3957        let mut run: Vec<Glyph> = Vec::new();
3958        let mut first = true;
3959        for g in glyphs {
3960            if g.ch == '\n' {
3961                let lead = if first { pf } else { pc };
3962                self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
3963                first = false;
3964            } else {
3965                run.push(g);
3966            }
3967        }
3968        if !run.is_empty() {
3969            let lead = if first { pf } else { pc };
3970            self.emit_line(run, block_start, lead, pc, None);
3971        }
3972    }
3973
3974    /// Word-wrap a single line of `glyphs` (no interior line breaks) to the
3975    /// available width and push the visual rows, prefixing the first with `pf`
3976    /// and the rest with `pc`. `end`, when set, is the source offset that ends
3977    /// the line's final row — the offset of the break that terminated it, which
3978    /// the caller has already stripped from `glyphs`; when `None` the row ends
3979    /// just past its last glyph, as an unbroken block's does.
3980    fn emit_line(
3981        &mut self,
3982        glyphs: Vec<Glyph>,
3983        block_start: usize,
3984        pf: &[Glyph],
3985        pc: &[Glyph],
3986        end: Option<usize>,
3987    ) {
3988        // The line's final row ends at `end` when a break gave one, else just
3989        // past its last glyph (`push_row`'s default).
3990        let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
3991            Some(e) => b.push_row_at(row, e),
3992            None => b.push_row(row, block_start),
3993        };
3994
3995        // No column budget: emit the whole line as one row and let the frontend
3996        // wrap it at its own (pixel) width.
3997        let Some(width) = self.wrap else {
3998            let row = if glyphs.is_empty() {
3999                pf.to_vec()
4000            } else {
4001                concat(pf, &glyphs)
4002            };
4003            push_last(self, row);
4004            return;
4005        };
4006
4007        // Split into words (maximal non-space runs), each carrying the space
4008        // glyph that followed it (so its source offset is preserved).
4009        let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4010        let mut word: Vec<Glyph> = Vec::new();
4011        for g in glyphs {
4012            if g.ch == ' ' {
4013                words.push((std::mem::take(&mut word), Some(g)));
4014            } else {
4015                word.push(g);
4016            }
4017        }
4018        if !word.is_empty() {
4019            words.push((word, None));
4020        }
4021        if words.is_empty() {
4022            // An empty block (or an empty preserved line) still occupies one
4023            // (prefixed) row.
4024            push_last(self, pf.to_vec());
4025            return;
4026        }
4027
4028        let mut line: Vec<Glyph> = Vec::new();
4029        let mut used = 0usize;
4030        let mut first = true;
4031        for (w, space) in words {
4032            let avail = width
4033                .saturating_sub(prefix_width(if first { pf } else { pc }))
4034                .max(1);
4035            let cells = glyphs_width(&w);
4036            if used > 0 && used + cells > avail {
4037                let row = concat(if first { pf } else { pc }, &line);
4038                self.push_row(row, block_start);
4039                line = Vec::new();
4040                used = 0;
4041                first = false;
4042            }
4043            used += cells;
4044            line.extend(w);
4045            if let Some(sp) = space {
4046                used += 1;
4047                line.push(sp);
4048            }
4049        }
4050        let row = concat(if first { pf } else { pc }, &line);
4051        push_last(self, row);
4052    }
4053
4054    /// The source offset of each line of a code block's `text`.
4055    ///
4056    /// `content` is the block's `content_span` — where twig says the body lives
4057    /// in the source, fences already excluded. Its lines run 1:1 with the
4058    /// rendered `text` lines, so no search is needed; each is anchored at the
4059    /// *end* of its source line, which places it past whatever indent `text` had
4060    /// stripped (a fenced block's fences, an indented one's leading spaces)
4061    /// without having to know how much there was.
4062    ///
4063    /// `None` when the body and the rendered lines don't line up — a coarse
4064    /// fallback the caller turns into the block's start offset.
4065    fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
4066        let mut src_lines: Vec<(usize, &str)> = Vec::new();
4067        let mut at = content.start;
4068        for l in self.source.get(content.start..content.end)?.split('\n') {
4069            src_lines.push((at, l));
4070            at += l.len() + 1;
4071        }
4072        if src_lines.len() != lines.len() {
4073            return None;
4074        }
4075        Some(
4076            lines
4077                .iter()
4078                .zip(&src_lines)
4079                .map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
4080                .collect(),
4081        )
4082    }
4083
4084    fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
4085        // Step past the character the *source* holds at the last glyph's offset,
4086        // not past the glyph's own `ch`. The two agree for ordinary text, but a
4087        // glyph is not always the character it stands on: `synth` decoration and
4088        // a substituted run (an image's `⧉ label`) share one offset by design.
4089        // Trusting `ch` there yields an offset inside a multi-byte character,
4090        // which every later slice of `source` panics on.
4091        let end_src = glyphs
4092            .last()
4093            .map(|g| {
4094                let at = g.src.min(self.source.len());
4095                at + self.source[at..].chars().next().map_or(0, char::len_utf8)
4096            })
4097            .unwrap_or(fallback);
4098        self.push_row_at(glyphs, end_src);
4099    }
4100
4101    /// Push a row with an explicit end stop, for content that knows its own
4102    /// extent better than its last glyph does.
4103    fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
4104        self.last_off = end_src;
4105        let mark_ends = self.take_mark_ends(end_src);
4106        self.rows.push(VRow {
4107            glyphs,
4108            end_src,
4109            decoration: false,
4110            code: false,
4111            code_lang: None,
4112            directive: false,
4113            directive_label: None,
4114            media: None,
4115            task: None,
4116            leaf_directive: None,
4117            heading: None,
4118            boundary: None,
4119            mark_ends,
4120        });
4121    }
4122
4123    /// The quote's own trailing marker lines: the `>` / `> ` lines that lie past
4124    /// its last child but inside its span, one gutter row each.
4125    ///
4126    /// Pressing Enter at the end of `> a` writes `> a\n>\n> \n` — twig's
4127    /// spelling, and the right one. Those last two lines hold no block (a
4128    /// `block_quote`'s `content_span` still stops at its last child) so the
4129    /// children walk never reaches them, and they used to fall all the way to
4130    /// the document-level [`Builder::emit_trailing_blank_lines`], which knows no
4131    /// prefix: the gutter simply stopped, and a writer adding a line to a quote
4132    /// watched it draw as plain prose.
4133    ///
4134    /// This is only answerable since twig 3.2.0, where a Markdown `block_quote`'s
4135    /// span covers its own trailing marker lines (it reported `0..3` for that
4136    /// source and now reports `0..8`). Before that the lines belonged to no node
4137    /// at any level, and the only way to draw them was to sniff `>` off the raw
4138    /// source and re-derive the nesting depth by counting markers — format
4139    /// inference this crate exists to keep out of the render path.
4140    ///
4141    /// Each row is a real caret home rather than a decoration gap: the writer
4142    /// spelled every one of these lines with a marker of its own, so each is a
4143    /// line of the quote to stand on, not the spacing between two blocks (which
4144    /// is [`Builder::emit_separators_before`]'s, and falls *between* children
4145    /// where this never looks).
4146    fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
4147        let end = end.min(self.source.len());
4148        let mut at = self.rows.last().map_or(0, |r| r.end_src);
4149        // Walk line by line from the last child's end to the quote's, taking each
4150        // line's *end* as the row's offset — the caret home at the end of a line
4151        // is where one on an empty quoted line belongs, and it keeps every row's
4152        // offset distinct from its neighbours'.
4153        while at < end {
4154            let Some(k) = self.source[at..end].find('\n') else {
4155                break;
4156            };
4157            let line_start = at + k + 1;
4158            let line_end = self.source[line_start..end]
4159                .find('\n')
4160                .map_or(end, |i| line_start + i);
4161            self.push_row_at(pc.to_vec(), line_end);
4162            at = line_end;
4163        }
4164    }
4165
4166    /// The source offset the caret rests at on the blank line separating a block
4167    /// that ends at `prev_end` from the next block starting at `next_start`:
4168    /// just past the newline that terminates the previous block, but kept
4169    /// strictly before the next block so the offset is unique to this row.
4170    fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
4171        let after_nl = self.source[prev_end..]
4172            .find('\n')
4173            .map_or(prev_end, |p| prev_end + p + 1);
4174        after_nl.min(next_start.saturating_sub(1)).max(prev_end)
4175    }
4176
4177    /// The source offset of each blank row between a block ending at `prev_end`
4178    /// and content starting at `next_start` — one per blank source line. The
4179    /// first newline terminates the previous block's line; every line it opens up
4180    /// to (but not including) the line that holds `next_start` is a blank row the
4181    /// caret can occupy. Offsets are unique and ascending so `pos_of_offset`
4182    /// resolves each to its own row. Empty when the two blocks are tight (no
4183    /// blank line between them).
4184    fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
4185        // Spans aren't always in tidy source order (e.g. a block after
4186        // frontmatter can start *before* the previous block's rendered content
4187        // ends). There's no blank line to place then — fall back to the clamped
4188        // single separator (an empty return) rather than slicing an inverted
4189        // range.
4190        if next_start <= prev_end {
4191            return Vec::new();
4192        }
4193        let gap = &self.source[prev_end..next_start];
4194        let Some(nl) = gap.find('\n') else {
4195            return Vec::new();
4196        };
4197        // The line holding `next_start` belongs to the next block; blank rows
4198        // stop before it.
4199        let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
4200        let mut offs = Vec::new();
4201        let mut start = prev_end + nl + 1;
4202        while start < next_line_start {
4203            offs.push(start);
4204            match self.source[start..next_start].find('\n') {
4205                Some(k) => start += k + 1,
4206                None => break,
4207            }
4208        }
4209        offs
4210    }
4211
4212    /// Blank lines the user typed past the end of the last block (e.g. two
4213    /// `Enter`s to open a fresh paragraph) leave no AST node, so nothing renders
4214    /// and the caret appears stuck on the old line. Reconstruct one empty row
4215    /// per extra trailing newline from the source, each at its own offset, so
4216    /// the caret rides down onto the new line the moment it's created.
4217    ///
4218    /// `above` is the class of the last block in the document — the one this gap
4219    /// closes. A document with no blocks at all has nothing above these rows, and
4220    /// [`BlockClass::Paragraph`] is the honest answer there too: what they are is
4221    /// empty paragraphs, on both sides of the gap.
4222    fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
4223        // With no rows at all the count starts past any hidden frontmatter, not
4224        // at 0: its newlines are not trailing blank lines, and counting them
4225        // opened phantom rows *inside* the metadata for a frontmatter-only file.
4226        //
4227        // Or past the last hidden block, if that is later: a closing comment
4228        // draws no row, and its lines are not blank lines the author opened.
4229        let last_end = self
4230            .rows
4231            .last()
4232            .map_or(hidden_end, |r| r.end_src)
4233            .max(self.stepped_over);
4234        if last_end >= self.source.len() {
4235            return;
4236        }
4237        // The first newline after the last content just terminates that line, so
4238        // a lone trailing `\n` (an ordinary file ending) opens no blank row. A
4239        // *second* newline opens an empty paragraph: render it the way a block
4240        // boundary is rendered — a blank spacer row, then the empty paragraph row
4241        // the caret rests on — so the just-pressed-Enter view already shows the
4242        // gap it will keep once text is typed, and typing doesn't shift the line
4243        // down. One row per trailing newline (each its own caret offset), the
4244        // last landing at the document end where the caret sits.
4245        let extra = self.source[last_end..].matches('\n').count();
4246        if extra < 2 {
4247            return;
4248        }
4249        for k in 1..=extra {
4250            self.rows.push(VRow {
4251                glyphs: Vec::new(),
4252                end_src: last_end + k,
4253                // As between two blocks: the first blank row is the gap that
4254                // closes the block above, not somewhere to type. Nothing follows
4255                // to need a gap of its own, though, so every row after it is a
4256                // real empty paragraph — the end of the document bounds the last
4257                // one the way a following block would. Preserve flow makes even
4258                // that first row navigable, as it does every blank line.
4259                decoration: !self.preserve_soft && k == 1,
4260                code: false,
4261                code_lang: None,
4262                directive: false,
4263                directive_label: None,
4264                media: None,
4265                task: None,
4266                leaf_directive: None,
4267                heading: None,
4268                // The one drawn row here is a block boundary like any other —
4269                // "rendered the way a block boundary is rendered" is the whole
4270                // point of it — so it says so, and a frontend spacing boundaries
4271                // spaces this one the same. The rows below it are navigable empty
4272                // paragraphs, not gaps.
4273                boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
4274                    above,
4275                    below: BlockClass::Paragraph,
4276                }),
4277                mark_ends: Vec::new(),
4278            });
4279        }
4280    }
4281}
4282
4283// ── display width ────────────────────────────────────────────────────────────
4284//
4285// Two things a row can be counted in, and they are not the same number:
4286//
4287//   *glyphs*, one per codepoint — how the text is stored here, and what an
4288//   index into `VRow::glyphs` means; and
4289//   *columns*, one per terminal cell — where the text is drawn, and what every
4290//   `col` in this crate means.
4291//
4292// `你` is one glyph in two columns. Counting columns with `glyphs.len()` (or,
4293// in the source view, `chars().count()`) is the same number only for the ASCII
4294// that most fixtures are written in, and drifts one cell per wide character
4295// everywhere else — the caret drawn a column short of the text it types into.
4296// Everything below converts between the two; nothing else should have to.
4297
4298/// The display width of `s` in terminal cells.
4299///
4300/// Measured per grapheme cluster, because that is the unit a surface advances
4301/// by: `👨‍👩‍👧` is five codepoints measuring 2 + 0 + 2 + 0 + 2 cells one at a
4302/// time, but the character they spell is drawn in 2. Both frontends already
4303/// measure it that way — ratatui asks `unicode-width` per cluster, and the GUI
4304/// asks its own text system — so the caret only lands where the text is if this
4305/// agrees with them.
4306pub fn text_width(s: &str) -> usize {
4307    UnicodeWidthStr::width(s)
4308}
4309
4310/// One grapheme cluster of a laid-out row: the glyphs that spell it, and the
4311/// cells it is drawn in.
4312///
4313/// The cluster, not the glyph, is what has a width. A row's glyphs are one per
4314/// codepoint, so an accented letter or an emoji is several of them drawn in one
4315/// character's worth of cells — the glyph that opens the cluster claims those
4316/// cells, and the ones continuing it are drawn *inside* them rather than beside
4317/// them. It's the same cluster the stop table is built on: the opening glyph is
4318/// the one a caret can rest on, and so the only one whose column it can be
4319/// drawn at.
4320struct Cluster {
4321    /// Index of the glyph that opens it.
4322    glyph: usize,
4323    /// The display column it starts at.
4324    col: usize,
4325    /// How many cells it is drawn in. Zero for a cluster with no width of its
4326    /// own (a lone joiner), which therefore sits at no column at all.
4327    cells: usize,
4328}
4329
4330/// Walk a row's glyphs as the clusters they spell, in column order.
4331fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
4332    let text: String = glyphs.iter().map(|g| g.ch).collect();
4333    let mut out = Vec::new();
4334    let (mut glyph, mut col) = (0, 0);
4335    for cluster in text.graphemes(true) {
4336        let cells = text_width(cluster);
4337        out.push(Cluster { glyph, col, cells });
4338        // One glyph per codepoint, so a cluster spans exactly its own.
4339        glyph += cluster.chars().count();
4340        col += cells;
4341    }
4342    out
4343}
4344
4345/// The display width of a run of glyphs.
4346fn glyphs_width(glyphs: &[Glyph]) -> usize {
4347    clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
4348}
4349
4350/// A cell's display width — the widest of its lines, since an in-cell `\n` break
4351/// splits it into several. Sizes the column that must hold every line.
4352fn cell_width(glyphs: &[Glyph]) -> usize {
4353    glyphs
4354        .split(|g| g.ch == '\n')
4355        .map(glyphs_width)
4356        .max()
4357        .unwrap_or(0)
4358}
4359
4360/// Whether a raw inline HTML tag is a line break (`<br>`, `<br/>`, `<br />`,
4361/// case-insensitively) — the one tag a table cell reads as an in-cell break.
4362fn is_br(text: Option<&str>) -> bool {
4363    let Some(t) = text else { return false };
4364    matches!(
4365        t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
4366        "<br>" | "<br/>"
4367    )
4368}
4369
4370impl VRow {
4371    /// The row's width in display columns — and so the column of the caret
4372    /// placed past its last glyph, which is the rightmost column it can occupy.
4373    fn width(&self) -> usize {
4374        glyphs_width(&self.glyphs)
4375    }
4376
4377    /// The display column glyph `i` is drawn at. Glyphs continuing a cluster
4378    /// report the column of the glyph that opened it, since that is where they
4379    /// are drawn; none of them is ever a stop, so no caret is placed by it.
4380    fn col_of_glyph(&self, i: usize) -> usize {
4381        clusters(&self.glyphs)
4382            .iter()
4383            .rev()
4384            .find(|c| c.glyph <= i)
4385            .map_or(0, |c| c.col)
4386    }
4387
4388    /// The glyph drawn at display column `col`, or `None` past the row's last
4389    /// cell.
4390    ///
4391    /// A column landing on the *second* cell of a wide glyph resolves to that
4392    /// glyph: half a character is not a place to be, so clicking either cell of
4393    /// `你` means `你`, and the caret comes to rest at its start — the column it
4394    /// would be drawn at anyway. That rule is what makes the mapping invertible:
4395    /// every offset has one column, and every column has one offset.
4396    fn glyph_at_col(&self, col: usize) -> Option<usize> {
4397        clusters(&self.glyphs)
4398            .into_iter()
4399            .find(|c| col < c.col + c.cells)
4400            .map(|c| c.glyph)
4401    }
4402}
4403
4404// ── helpers ──────────────────────────────────────────────────────────────────
4405
4406/// The caret home inside an *empty* table cell (`col`, 0-based) whose node
4407/// `span` is `src` starting at byte `start`. twig gives an empty cell no
4408/// `content_span`, so its interior is read from the pipes: the home is one
4409/// space past the pipe that opens the cell — mimicking the `| ` padding a
4410/// filled cell has — and never at or past the pipe that closes it. So
4411/// `|  |  |` gives the two cells distinct, editable homes instead of both
4412/// collapsing onto the row's start.
4413///
4414/// Two shapes of span are read. A twig from 3.3.3 gave every cell of a row
4415/// the *row's* span, so the cell's own pipes are the `col`-th and
4416/// `col+1`-th unescaped `|` in it; a later twig spans a cell from the pipe
4417/// that opens it to the one that closes it, exclusive, so the span holds at
4418/// most that one pipe, at its start, and the closing one is the byte past
4419/// its end. The two are told apart by the pipes the span holds — a row's
4420/// span has several, or one that is not at its start.
4421fn empty_cell_offset(src: &str, start: usize, col: usize) -> usize {
4422    let bytes = src.as_bytes();
4423    let mut pipes = Vec::new();
4424    for (i, &b) in bytes.iter().enumerate() {
4425        if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
4426            pipes.push(i);
4427        }
4428    }
4429    let whole_row = pipes.len() > 1 || pipes.first().is_some_and(|&i| i != 0);
4430    let (open, close) = if whole_row {
4431        (pipes.get(col).copied(), pipes.get(col + 1).copied())
4432    } else {
4433        (pipes.first().copied(), Some(src.len()))
4434    };
4435    match (open, close) {
4436        (Some(open), Some(close)) => {
4437            let lo = open + 1; // just inside the opening pipe
4438            let hi = close.saturating_sub(1); // just inside the closing pipe
4439            let inside = if hi < lo {
4440                lo
4441            } else {
4442                (open + 2).clamp(lo, hi)
4443            };
4444            start + inside
4445        }
4446        (Some(open), None) => start + open + 1,
4447        _ => start,
4448    }
4449}
4450
4451/// One laid-out table cell: its rendered text, the source range that text
4452/// occupies (`start`/`end` are the caret anchors decoration points at), and the
4453/// column alignment its padding honours.
4454///
4455/// `glyphs` is the cell's inline content *unwrapped* — the box-drawn rows wrap
4456/// it to a column width, but a frontend laying the grid out itself needs the
4457/// text before that decision was made.
4458#[derive(Clone)]
4459pub struct TableCell {
4460    pub glyphs: Vec<Glyph>,
4461    pub start: usize,
4462    pub end: usize,
4463    pub align: Alignment,
4464}
4465
4466/// One row of a table's grid, as the document spells it — not as it's drawn.
4467#[derive(Clone)]
4468pub struct TableRow {
4469    /// A header row: drawn bold, and ruled off from the body below it.
4470    pub head: bool,
4471    pub cells: Vec<TableCell>,
4472}
4473
4474/// A table's structure, published alongside the box-drawn rows that spell it.
4475///
4476/// The rows in [`VisualMap::rows`] are the *default monospace* picture of a
4477/// table: every border a `│`, every column a whole number of character cells.
4478/// That picture is exactly right on any monospace surface, and unfixable off one
4479/// — in a proportional font the `│`s of two rows land at different x and the grid
4480/// shears. So a frontend that draws its own geometry reads this instead: the
4481/// cells, their alignment, and which rows are the head, with no opinion about
4482/// how wide a column is or what a border looks like.
4483///
4484/// Both are always built. The TUI paints `rows` and ignores this; the GUI skips
4485/// `rows` for the span in `rows_span` and draws from here. They describe the
4486/// same cells, so the caret lands on the same offsets either way.
4487#[derive(Clone)]
4488pub struct TableInfo {
4489    /// The `VisualMap::rows` this table's picture occupies, borders included —
4490    /// what a frontend drawing its own table skips over.
4491    pub rows_span: Range<usize>,
4492    /// The source span of the table node, and the offset its trailing caret
4493    /// stop sits at.
4494    pub end_src: usize,
4495    /// The block prefix every row of this table carries — a blockquote's `│ `
4496    /// gutter, a list item's indent. Empty for a table at the top level.
4497    ///
4498    /// A frontend drawing its own grid has to render this and start the table
4499    /// past it, exactly as the picture does; a table nested in a quote that
4500    /// draws flush at the left margin has left the quote.
4501    pub prefix: Vec<Glyph>,
4502    pub grid: Vec<TableRow>,
4503}
4504
4505/// A fenced or indented code block, named by the [`VisualMap::rows`] it occupies.
4506///
4507/// Unlike a table, the rows *are* the block's content — a frontend still paints
4508/// them, it just draws a border and a tinted background around the whole span
4509/// and lets the code inside scroll horizontally instead of wrapping. So this
4510/// carries only the row range; there's no structural alternative to the picture
4511/// the way [`TableInfo`] is one. Derived from [`VRow::code`] — see
4512/// [`code_block_spans`].
4513#[derive(Clone, Debug, PartialEq, Eq)]
4514pub struct CodeBlockInfo {
4515    /// The contiguous run of [`VisualMap::rows`] this code block spans, blank
4516    /// code lines included.
4517    pub rows_span: Range<usize>,
4518    /// The block's language, from a fenced block's info string — what a frontend
4519    /// paints as a small label on the box (`` ```rust `` → `Some("rust")`).
4520    /// `None` for a fence written without one, or an indented block. Editing it
4521    /// goes through [`crate::Doc::set_code_language`], which re-finds the fence
4522    /// in the AST, so this stays a display string.
4523    pub lang: Option<String>,
4524}
4525
4526/// A block-level image (`![alt](url)` on its own line), named by the single
4527/// [`VisualMap::rows`] row it occupies.
4528///
4529/// Like [`CodeBlockInfo`], the row *is* the block's default rendering — a plain
4530/// surface paints the `🖼 alt` placeholder glyphs as-is. An image-capable
4531/// frontend instead **skips the row in `rows_span`** and paints the resolved
4532/// picture there, exactly as it skips a [`TableInfo`]'s box-drawn rows. Derived
4533/// from [`VRow::media`] by [`media_spans`], so it survives the row reuse of
4534/// [`BlockCache`] and [`build_spliced`].
4535#[derive(Clone, Debug, PartialEq, Eq)]
4536pub struct MediaInfo {
4537    /// The [`VisualMap::rows`] rows this media's placeholder occupies — what a
4538    /// capable frontend replaces with the picture or player.
4539    pub rows_span: Range<usize>,
4540    /// Whether this is a picture, a movie, or a sound — which widget the
4541    /// frontend builds over [`rows_span`](MediaInfo::rows_span). A frontend that
4542    /// handles only some kinds leaves the rest as core's placeholder rows, which
4543    /// already read sensibly on their own.
4544    pub kind: MediaKind,
4545    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
4546    /// the AST. A frontend resolves a relative path against the document's own
4547    /// directory; core does no I/O. For a `<picture>` this is the `<img>`
4548    /// fallback — the source used when no [`sources`](MediaInfo::sources) media
4549    /// query matches (or the frontend has no theme). Empty when a `<video>`/
4550    /// `<audio>` carries no `src` and names its candidates in `<source>`s
4551    /// instead; [`resolve`](MediaInfo::resolve) already accounts for that.
4552    pub destination: String,
4553    /// The `<source>` alternatives in document order, or empty for a plain
4554    /// image. See [`MediaSource`]; a theme- or codec-aware frontend picks one and
4555    /// otherwise loads [`destination`](MediaInfo::destination).
4556    pub sources: Vec<MediaSource>,
4557    /// The media's alt text, flattened from its inline children (empty when it
4558    /// has none).
4559    pub alt: String,
4560    /// A `<video poster="…">`'s still frame, or empty when there is none — an
4561    /// image destination, resolved exactly as [`destination`] is.
4562    ///
4563    /// [`destination`]: MediaInfo::destination
4564    pub poster: String,
4565}
4566
4567/// One leaf directive (`::name{…}`) as a frontend sees it: which rows its
4568/// placeholder occupies, its type, and its attributes. A plain surface paints
4569/// the `⧉ name` placeholder glyphs as-is; a frontend that knows the host app's
4570/// vocabulary **skips the rows in `rows_span`** and paints the real thing there,
4571/// exactly as an image-capable one does with [`MediaInfo`]. Derived from
4572/// [`VRow::leaf_directive`] by [`directive_spans`].
4573///
4574/// Core resolves nothing here — it has no idea what an `embed` or a `toc` is,
4575/// and deliberately so: the directive vocabulary belongs to the app on top.
4576#[derive(Clone, Debug, PartialEq, Eq)]
4577pub struct DirectiveInfo {
4578    /// The [`VisualMap::rows`] rows this directive's placeholder occupies — the
4579    /// label row plus any blank fillers under it.
4580    pub rows_span: Range<usize>,
4581    /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
4582    pub name: String,
4583    /// Its `{…}` attributes in source order; a bare one has a `None` value.
4584    pub attrs: Vec<(String, Option<String>)>,
4585    /// Its `[label]` text, flattened from its inline children (empty when it has
4586    /// none) — what the placeholder row shows.
4587    pub label: String,
4588}
4589
4590impl DirectiveInfo {
4591    /// The value of attribute `key`, if it has one with a value. The convenience
4592    /// a frontend reaches for first (`info.attr("src")`), since almost every
4593    /// directive that draws as something real is pointed at by one attribute.
4594    pub fn attr(&self, key: &str) -> Option<&str> {
4595        self.attrs
4596            .iter()
4597            .find(|(k, _)| k == key)
4598            .and_then(|(_, v)| v.as_deref())
4599    }
4600}
4601
4602impl MediaInfo {
4603    /// The image URL to load under `scheme`: the first [`sources`] `<source>`
4604    /// whose media query matches, else the [`destination`] `<img>` fallback. The
4605    /// pick is a `<source>`'s first `srcset` URL or the destination — a frontend
4606    /// resolves whichever it gets against the document directory exactly as it
4607    /// resolves `destination`, and reserves/keys the picture under `destination`
4608    /// regardless, so a theme switch just re-picks without disturbing the layout.
4609    ///
4610    /// Only `prefers-color-scheme` is understood (that's what a light/dark banner
4611    /// uses); a `<source>` with any other media query is skipped, and one with no
4612    /// media at all always matches (an unconditional override). With no matching
4613    /// source — including every frontend that can't/doesn't theme and passes
4614    /// [`ColorScheme::Light`] to a dark-only picture — it's the plain `<img>`.
4615    ///
4616    /// [`sources`]: MediaInfo::sources
4617    /// [`destination`]: MediaInfo::destination
4618    pub fn resolve(&self, scheme: ColorScheme) -> &str {
4619        if let Some(url) = self
4620            .sources
4621            .iter()
4622            .find(|s| media_matches(&s.media, scheme))
4623            .and_then(|s| first_srcset_url(&s.srcset))
4624        {
4625            return url;
4626        }
4627        // A `<video>`/`<audio>` may carry no `src` of its own, naming its
4628        // candidates only in child `<source>`s — none of which matched above,
4629        // because a codec-typed `<source>` has no media query and core judges no
4630        // MIME types. Falling through to an empty destination would hand the
4631        // frontend nothing to load, so take the first candidate URL instead and
4632        // let the frontend reject it if it can't decode it. An `<img>` never
4633        // reaches this: its `src` is the picture.
4634        if self.destination.is_empty()
4635            && let Some(url) = self
4636                .sources
4637                .iter()
4638                .find_map(|s| first_srcset_url(&s.srcset))
4639        {
4640            return url;
4641        }
4642        &self.destination
4643    }
4644
4645    /// The **still picture** that stands for this media under `scheme`, for a
4646    /// frontend that can rasterize an image but not play a movie — a terminal, or
4647    /// a GUI still growing its player. `None` when there is no picture to draw,
4648    /// which is the honest answer for audio and for a poster-less video: the
4649    /// caller leaves core's labelled placeholder row, which already reads as
4650    /// *a thing that isn't text*.
4651    ///
4652    /// This exists so those frontends never hand a `.mp4` to an image decoder.
4653    /// That fails harmlessly today (a failed decode falls back to the same
4654    /// placeholder), but it spends a file read and a decode attempt per frame to
4655    /// arrive where this gets in one match.
4656    pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
4657        match self.kind {
4658            MediaKind::Image => Some(self.resolve(scheme)),
4659            // A `poster` is an image destination, so it resolves the same way —
4660            // but it is named directly and has no `<source>` alternatives of its
4661            // own, so it needs no theme matching.
4662            MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
4663            MediaKind::Video | MediaKind::Audio => None,
4664        }
4665    }
4666}
4667
4668/// A frontend's active color scheme — what a `<picture>`'s `prefers-color-scheme`
4669/// `<source>`s are matched against by [`MediaInfo::resolve`]. A frontend with no
4670/// notion of theme passes [`Light`](ColorScheme::Light), the web's own default.
4671#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4672pub enum ColorScheme {
4673    Light,
4674    Dark,
4675}
4676
4677/// Whether a `<source media="…">` query applies under `scheme`. Empty media is
4678/// an unconditional `<source>` (always matches); otherwise only a
4679/// `prefers-color-scheme: dark|light` feature is understood — anything else
4680/// (a width query, `print`, …) doesn't match, so resolution falls through to the
4681/// next source or the `<img>`. Deliberately lax about the surrounding syntax
4682/// (`(prefers-color-scheme: dark)`, `screen and (prefers-color-scheme:dark)`):
4683/// it keys off the feature and its value, which is all the theme case needs.
4684fn media_matches(media: &str, scheme: ColorScheme) -> bool {
4685    let media = media.trim();
4686    if media.is_empty() {
4687        return true;
4688    }
4689    let lower = media.to_ascii_lowercase();
4690    let Some(after) = lower
4691        .split_once("prefers-color-scheme")
4692        .map(|(_, rest)| rest)
4693    else {
4694        return false;
4695    };
4696    // Skip the `:` and any spaces to reach the value word.
4697    let value = after.trim_start_matches([':', ' ', '\t']);
4698    let wanted = match scheme {
4699        ColorScheme::Light => "light",
4700        ColorScheme::Dark => "dark",
4701    };
4702    value.starts_with(wanted)
4703}
4704
4705/// The first URL in a `srcset`: its first comma-separated candidate, before any
4706/// `1x`/`2x`/width descriptor. The theme case only ever puts one URL per
4707/// `<source>`, so the first candidate is the picture.
4708fn first_srcset_url(srcset: &str) -> Option<&str> {
4709    let first = srcset.split(',').next()?.trim();
4710    first.split_whitespace().next().filter(|u| !u.is_empty())
4711}
4712
4713/// The narrowest a column may be squeezed. Below a few characters a column
4714/// stops carrying text and just shreds it one letter per line, which is worse
4715/// than letting the grid run wide.
4716const MIN_COL_WIDTH: usize = 3;
4717
4718/// Shrink `widths` until the grid fits `avail` screen columns, taking from the
4719/// widest column each time so the loss is shared out rather than falling on
4720/// whichever column happens to be last. No column goes below
4721/// [`MIN_COL_WIDTH`]; a table with more columns than the surface has room for
4722/// still overflows, which is the honest outcome — there's nothing left to give.
4723fn fit_widths(widths: &mut [usize], avail: usize) {
4724    // Chrome: each column is its content plus a gutter either side, and every
4725    // column is closed by a `│` — with one more opening the row.
4726    let budget = avail.saturating_sub(3 * widths.len() + 1);
4727    while widths.iter().sum::<usize>() > budget {
4728        let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
4729            return;
4730        };
4731        *w -= 1;
4732    }
4733}
4734
4735/// Word-wrap `glyphs` into lines of at most `width` columns, hard-breaking any
4736/// single word too long to fit.
4737///
4738/// Unlike a paragraph — where an overlong word just trails off the end of the
4739/// line — a table column is a hard boundary: a glyph past it lands on top of
4740/// the border, or on the next cell. So the width here is a promise, and a word
4741/// that won't keep it is broken.
4742///
4743/// The space at a break is dropped rather than hung past the edge. Its offset
4744/// isn't lost: the caller gives every line an end stop just past its last
4745/// glyph, which is exactly where that space was.
4746///
4747/// `width` is in display columns, and a break only ever falls between grapheme
4748/// clusters. Both matter to more than the picture: the caller anchors each
4749/// line's end stop just past its last glyph, so a line cut mid-cluster would
4750/// put a caret stop inside a character — reachable by Down or a click, and the
4751/// next Backspace would take the cluster apart from the middle.
4752///
4753/// An explicit in-cell break (a `\n` glyph, from a `<br>`) is a hard boundary:
4754/// each run between the breaks wraps on its own and the results stack. The break
4755/// glyphs are dropped — the caller's per-line end stop already sits exactly where
4756/// each break was, so no offset is lost.
4757fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
4758    if glyphs.iter().any(|g| g.ch == '\n') {
4759        return glyphs
4760            .split(|g| g.ch == '\n')
4761            .flat_map(|seg| wrap_segment(seg, width))
4762            .collect();
4763    }
4764    wrap_segment(glyphs, width)
4765}
4766
4767/// [`wrap_glyphs`] for a run with no explicit breaks — the word-wrap proper.
4768fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
4769    let width = width.max(1);
4770    // Words are maximal non-space runs, each carrying the space that followed it
4771    // — which survives only if the next word joins it on this line.
4772    let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4773    let mut word: Vec<Glyph> = Vec::new();
4774    for g in glyphs {
4775        if g.ch == ' ' {
4776            words.push((std::mem::take(&mut word), Some(g.clone())));
4777        } else {
4778            word.push(g.clone());
4779        }
4780    }
4781    if !word.is_empty() {
4782        words.push((word, None));
4783    }
4784
4785    let mut lines: Vec<Vec<Glyph>> = Vec::new();
4786    let mut line: Vec<Glyph> = Vec::new();
4787    let mut used = 0usize;
4788    let mut gap: Option<Glyph> = None;
4789    for (word, space) in words {
4790        for chunk in hard_break(&word, width) {
4791            let sep = gap.is_some() as usize;
4792            let cells = glyphs_width(chunk);
4793            if !line.is_empty() && used + sep + cells > width {
4794                lines.push(std::mem::take(&mut line));
4795                used = 0;
4796                gap = None; // the break swallows the space
4797            }
4798            if let Some(sp) = gap.take() {
4799                line.push(sp);
4800                used += 1;
4801            }
4802            line.extend_from_slice(chunk);
4803            used += cells;
4804        }
4805        gap = space;
4806    }
4807    // An empty cell is still one (empty) line — it has an end the caret can
4808    // sit at, which is how you type into it.
4809    if !line.is_empty() || lines.is_empty() {
4810        lines.push(line);
4811    }
4812    lines
4813}
4814
4815/// Break a single word into pieces of at most `width` columns, cutting only
4816/// between grapheme clusters — the replacement for slicing it into fixed runs
4817/// of glyphs, which measures a wide character as one column and can cut an
4818/// emoji in half.
4819///
4820/// A cluster wider than the whole column still gets a piece to itself: there is
4821/// nowhere legal to cut it, and overflowing by a cell is better than splitting a
4822/// character. An empty word yields no pieces at all, which is what keeps a
4823/// double space from opening a line of its own.
4824fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
4825    let mut out = Vec::new();
4826    if word.is_empty() {
4827        return out;
4828    }
4829    let (mut start, mut used) = (0usize, 0usize);
4830    for c in clusters(word) {
4831        if used > 0 && used + c.cells > width {
4832            out.push(&word[start..c.glyph]);
4833            start = c.glyph;
4834            used = 0;
4835        }
4836        used += c.cells;
4837    }
4838    out.push(&word[start..]);
4839    out
4840}
4841
4842/// A table rule spanning `widths`, e.g. `┌──────┬─────┐`. Each column is its
4843/// content width plus the one-space gutter on either side.
4844fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
4845    let mut s = String::new();
4846    s.push(left);
4847    for (i, w) in widths.iter().enumerate() {
4848        if i > 0 {
4849            s.push(mid);
4850        }
4851        for _ in 0..w + 2 {
4852            s.push('─');
4853        }
4854    }
4855    s.push(right);
4856    s
4857}
4858
4859/// Push real document text: each glyph maps to its own source byte, and the one
4860/// that opens a grapheme cluster is the caret stop for the whole cluster.
4861///
4862/// Per cluster rather than per codepoint because a cluster is the character the
4863/// user sees, and it's the unit backspace and delete already step by. A stop
4864/// inside 👨‍👩‍👧 — five codepoints strung together with joiners — is a caret
4865/// parked in the middle of a character: one press of Right lands there, and the
4866/// next Backspace severs a joiner from what it joined, leaving a dangling ZWJ in
4867/// the source. The rest of the cluster still gets its glyph (it has to be
4868/// drawn); it just isn't somewhere to stand.
4869fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
4870    for (gi, cluster) in text.grapheme_indices(true) {
4871        for (ci, ch) in cluster.char_indices() {
4872            out.push(Glyph {
4873                ch,
4874                style,
4875                src: base_src + gi + ci,
4876                stop: ci == 0,
4877            });
4878        }
4879    }
4880}
4881
4882/// [`push_text`] for one line of a highlighted code block: the same glyphs at
4883/// the same offsets, each additionally carrying the [`Token`] of the span it
4884/// falls in — `spans` being the line's entry from [`code_tokens`], ascending
4885/// byte ranges *into `text`*. A byte between spans keeps `style` as it is.
4886///
4887/// Offsets are what matters here: a token changes how a glyph is painted and
4888/// nothing about where it is or which source byte it stands on, so a caret
4889/// walks a highlighted block exactly as it walks an unhighlighted one.
4890fn push_code_text(
4891    out: &mut Vec<Glyph>,
4892    text: &str,
4893    base_src: usize,
4894    style: Style,
4895    spans: &[(Range<usize>, Token)],
4896) {
4897    let mut spans = spans.iter().peekable();
4898    for (gi, cluster) in text.grapheme_indices(true) {
4899        // Spans are ascending, so the one covering this cluster's first byte
4900        // is at or after the one that covered the last; step past those ended.
4901        while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
4902            spans.next();
4903        }
4904        let token = spans
4905            .peek()
4906            .filter(|(r, _)| r.contains(&gi))
4907            .map(|(_, t)| *t);
4908        // A cluster is classed whole, by its first byte: a grammar that split
4909        // an emoji's scalars between two tokens would otherwise split the
4910        // glyph, and no grammar means to.
4911        let style = style.token(token);
4912        for (ci, ch) in cluster.char_indices() {
4913            out.push(Glyph {
4914                ch,
4915                style,
4916                src: base_src + gi + ci,
4917                stop: ci == 0,
4918            });
4919        }
4920    }
4921}
4922
4923/// One line's highlighting — `crate::syntax::LineTokens`, spelled here so the
4924/// shape exists whether or not the feature that fills it does.
4925type LineTokens = Vec<(Range<usize>, Token)>;
4926
4927/// The syntax highlighting for a code block's lines, or `None` when the fence's
4928/// language is not one the grammars know. Without the `syntax` feature nothing
4929/// is known, and every code glyph draws in the plain code colour.
4930#[cfg(feature = "syntax")]
4931fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
4932    crate::syntax::highlight(lang, lines)
4933}
4934
4935#[cfg(not(feature = "syntax"))]
4936fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
4937    None
4938}
4939
4940/// Emit an inline `str`/`smart_punctuation` run, mapping every visible char back
4941/// to its *true* source byte even when the source carries backslash escapes the
4942/// parsed `text` dropped (`\*` → `*`). The naive `span.start + text_offset`
4943/// mapping [`push_text`] uses drifts by one byte after each escape, so a caret or
4944/// click past an escaped `*` would land on the wrong character; walking the text
4945/// against its source keeps them aligned, and the hidden escape backslash gets no
4946/// glyph of its own (it is a spelling artefact, not something the caret lands on).
4947fn push_escaped_text(
4948    out: &mut Vec<Glyph>,
4949    text: &str,
4950    span: Range<usize>,
4951    source: &str,
4952    style: Style,
4953) {
4954    let end = span.end.min(source.len());
4955    let src = source.get(span.start..end).unwrap_or("");
4956    // Fast path — no dropped bytes, so text and source align 1:1 (the common
4957    // case: prose with no escapes). Byte lengths equal ⇒ no backslash was eaten.
4958    if src.len() == text.len() {
4959        push_text(out, text, span.start, style);
4960        return;
4961    }
4962    // Slow path: some `\` was consumed. Walk char-by-char, skipping a backslash
4963    // in the source exactly when it escapes the next visible char (a real escape),
4964    // never when it is a literal backslash the parse kept (that case has equal
4965    // lengths and takes the fast path above).
4966    let sb = src.as_bytes();
4967    let mut si = 0usize;
4968    'text: for (_, cluster) in text.grapheme_indices(true) {
4969        for (ci, ch) in cluster.char_indices() {
4970            // The text outlasted the source it is being mapped onto. In a
4971            // consistent document that cannot happen on this path: the slow path
4972            // is only entered when the two lengths differ, and everything that
4973            // makes them differ makes the *source* the longer one — an escape
4974            // backslash the parse ate, or source folded into a neighbouring node.
4975            // A `smart_punctuation` node reports its canonical ASCII spelling
4976            // (`--`, `...`, `"`), which is never longer than what was written.
4977            //
4978            // So reaching here means `span` was measured against a document that
4979            // `source` is no longer, and there is no honest offset left to give
4980            // the remaining characters. Stop: the row comes out short, which is
4981            // a wrong picture of a document that is already inconsistent. The
4982            // alternative was `si` stepping past the end and the slice below
4983            // panicking — which is what it did, in a paint loop.
4984            if si >= sb.len() {
4985                break 'text;
4986            }
4987            // Advance to the source character this one came from, stepping over
4988            // whatever the parse dropped on the way. An escape backslash is the
4989            // common case, but not the only one: a span can cover source that
4990            // was folded into a neighbouring node (smart punctuation next to a
4991            // bracket gives `text: "]"` over a source span of `"…]"`). Advancing
4992            // by the *text* character's length assumed escapes were the only
4993            // divergence, so one dropped multi-byte character desynchronized
4994            // every glyph after it — placing `]` inside the `…` before it.
4995            while si < sb.len() && !src[si..].starts_with(ch) {
4996                si += src[si..].chars().next().map_or(1, char::len_utf8);
4997            }
4998            out.push(Glyph {
4999                ch,
5000                style,
5001                src: span.start + si.min(src.len()),
5002                stop: ci == 0,
5003            });
5004            si += src[si..]
5005                .chars()
5006                .next()
5007                .map_or(ch.len_utf8(), char::len_utf8);
5008        }
5009    }
5010}
5011
5012/// Build synthetic decoration glyphs (a bullet, a gutter) all pointing at `src`,
5013/// each carrying `role` so the frontend can style it (`Role::Body` for plain
5014/// padding). Synthetic glyphs are never caret stops — they share one offset, so
5015/// the caret steps over them (a click still lands at `src`).
5016fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
5017    let style = Style::default().role(role);
5018    text.chars()
5019        .map(|ch| Glyph {
5020            ch,
5021            style,
5022            src,
5023            stop: false,
5024        })
5025        .collect()
5026}
5027
5028fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
5029    let mut v = a.to_vec();
5030    v.extend_from_slice(b);
5031    v
5032}
5033
5034/// The columns a row's prefix (a bullet, a quote gutter, an indent) takes up
5035/// before the text it introduces — what the wrap budget has left to spend.
5036fn prefix_width(prefix: &[Glyph]) -> usize {
5037    glyphs_width(prefix)
5038}
5039
5040/// The label shown for an image with no alt text: the final path segment of its
5041/// destination (`img/cat.png` → `cat.png`), the whole destination when it has no
5042/// separator, and `"image"` when it's empty. A `data:` URI (which has no useful
5043/// tail) shows its scheme so the placeholder isn't a wall of base64.
5044fn media_label(dest: &str) -> String {
5045    if dest.is_empty() {
5046        return "image".to_string();
5047    }
5048    if dest.starts_with("data:") {
5049        return "data:…".to_string();
5050    }
5051    // Trim a query/fragment so a URL's `?v=2#frag` doesn't ride along.
5052    let clean = dest.split(['?', '#']).next().unwrap_or(dest);
5053    let tail = clean
5054        .trim_end_matches('/')
5055        .rsplit(['/', '\\'])
5056        .next()
5057        .unwrap_or(clean);
5058    if tail.is_empty() {
5059        dest.to_string()
5060    } else {
5061        tail.to_string()
5062    }
5063}
5064
5065/// A directive's attributes read as a human label — what a frontend puts on a
5066/// container's tinted panel, and what an attribute-bearing inline directive
5067/// shows in its chip.
5068///
5069/// Reads BOTH conventions diaryx content actually uses: twig's own dot-prefixed
5070/// classes (`{.public .family}`, arriving as one combined `class` attr) and bare
5071/// pandoc-style words with no leading dot (`{public family}` — what
5072/// `diaryx_core::visibility`'s publish-time filter and apps/web's directive
5073/// serializer both write, and which twig parses as one valueless attribute
5074/// each). Reading only `.class` would leave every *existing* diaryx `:::vis{…}`
5075/// block unlabeled. A `key=value` attr is configuration rather than a name, so
5076/// it contributes nothing. `None` when nothing readable is left.
5077fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
5078    let mut parts: Vec<String> = Vec::new();
5079    for (k, v) in attrs {
5080        if k == "class" {
5081            if let Some(v) = v
5082                && !v.is_empty()
5083            {
5084                parts.push(v.clone());
5085            }
5086        } else if v.as_deref().unwrap_or("").is_empty() {
5087            parts.push(k.clone());
5088        }
5089    }
5090    (!parts.is_empty()).then(|| parts.join(" "))
5091}
5092
5093fn heading_style(level: u32) -> Style {
5094    // Just the role — a frontend decides how a heading of this level *looks*
5095    // (the terminal cycles a color and bolds it, the GUI scales the font). The
5096    // author wrote no emphasis here, so core records none. `level as u8` is safe:
5097    // Markdown/Djot cap headings at 6.
5098    Style::default().role(Role::Heading(level.min(255) as u8))
5099}
5100
5101/// Is this `container` node a *directive* (`:::note{…}`, `::embed{…}`,
5102/// `:vis[…]`) rather than an HTML element (`<video>`, `<picture>`, `<div>`)?
5103///
5104/// twig 2.8 folded `div`/`span`/`directive`/`element` into one `container` kind,
5105/// and left nothing that separated them: `kind`, `name` and `directive_form` all
5106/// agree, field for field, on an HTML `<div>` and a Markdown `:::div`. Leaf
5107/// answered it by sniffing the span for whichever of `:` or `<` came first.
5108/// twig 3.0 records the answer at parse time as [`ContainerOrigin`], so this is
5109/// now the parser's own knowledge rather than a guess rebuilt from the bytes it
5110/// consumed.
5111pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
5112    node.origin == Some(ContainerOrigin::Directive)
5113}
5114
5115/// The tag a `container` node carries when it is an HTML element rather than a
5116/// directive — `Some("video")` for a promoted `<video>`, `None` for a `:::note`
5117/// or for any node that is not a container at all.
5118pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
5119    (node.origin == Some(ContainerOrigin::Element))
5120        .then_some(node.name.as_deref())
5121        .flatten()
5122}
5123
5124pub(crate) fn is_inline(node: &FlatNode) -> bool {
5125    // A directive is inline only in its `text` form (`:name[label]{…}`); the
5126    // `leaf` and `container` forms are blocks. All three report the same `kind`,
5127    // so the form is the only thing telling them apart — and getting it wrong
5128    // costs a whole paragraph: a text directive misread as a block makes its
5129    // paragraph fail the "all children inline" test in `block`, and the line is
5130    // then walked as a container of blocks, rendering as empty rows with no
5131    // caret home at all.
5132    //
5133    // An HTML element shares the `container` kind but never the `text` form, so
5134    // it answers `false` here and is walked as the block it is.
5135    if node.kind == Kind::Container {
5136        return container_is_directive(node) && node.directive_form == Some(DirectiveForm::Text);
5137    }
5138    is_inline_kind(&node.kind)
5139}
5140
5141/// [`is_inline`] by kind alone — for the ancestor walks, whose `QueryMatch`es
5142/// carry no `directive_form`. It answers `false` for every directive, which its
5143/// callers must (and do) reconcile: they pair it with `is_block_container`,
5144/// which claims every directive, so the pair's verdict is the same one a form
5145/// would have given. Anything looking at a *directive itself* wants [`is_inline`]
5146/// and a real node.
5147pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
5148    matches!(
5149        kind,
5150        Kind::Str
5151            | Kind::SoftBreak
5152            | Kind::HardBreak
5153            | Kind::NonBreakingSpace
5154            | Kind::Emph
5155            | Kind::Strong
5156            | Kind::Mark
5157            | Kind::Insert
5158            | Kind::Delete
5159            | Kind::Verbatim
5160            | Kind::InlineMath
5161            | Kind::DisplayMath
5162            | Kind::Url
5163            | Kind::Email
5164            | Kind::Link
5165            | Kind::Image
5166            | Kind::SmartPunctuation
5167            | Kind::Superscript
5168            | Kind::Subscript
5169            | Kind::FootnoteReference
5170    )
5171}
5172
5173/// Assert two maps are identical down to every glyph, stop, and table span — the
5174/// contract `build_cached` and `build_spliced` must hold against `build`. Lives
5175/// at module scope (not in `mod tests`) so the Doc-driven differential test in
5176/// `doc.rs` can reach it and the private `stops` field it compares.
5177#[cfg(test)]
5178pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
5179    assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
5180    for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
5181        assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
5182        assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
5183        // The incremental walk labels a boundary from a query match's kind
5184        // string and the whole-arena walk from a `FlatNode`'s; this is what says
5185        // the two doors reach the same answer.
5186        assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
5187        assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
5188        assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
5189        assert_eq!(
5190            ra.glyphs.len(),
5191            rb.glyphs.len(),
5192            "row {i} glyph count ({ctx})"
5193        );
5194        for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
5195            assert_eq!(
5196                (ga.ch, ga.src, ga.stop, ga.style),
5197                (gb.ch, gb.src, gb.stop, gb.style),
5198                "row {i} glyph {j} ({ctx})"
5199            );
5200        }
5201    }
5202    assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
5203    assert_eq!(a.stops, b.stops, "stops ({ctx})");
5204    assert_eq!(a.mark_ends, b.mark_ends, "mark_ends ({ctx})");
5205    assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
5206    for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
5207        assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
5208        assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
5209    }
5210    assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
5211    assert_eq!(a.media, b.media, "images ({ctx})");
5212}
5213
5214#[cfg(test)]
5215mod tests {
5216    use super::*;
5217    use twig::{Editor, Format, NodeId};
5218
5219    fn map(src: &str) -> VisualMap {
5220        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5221        build_t(&ed.nodes().unwrap(), src, Some(80))
5222    }
5223
5224    /// [`map`] over a Djot source. Djot is the format that spells superscript
5225    /// and subscript at all — Markdown has no syntax for either.
5226    fn map_djot(src: &str) -> VisualMap {
5227        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5228        build_t(&ed.nodes().unwrap(), src, Some(80))
5229    }
5230
5231    /// The baseline every glyph spelling `ch` was built with, in row order —
5232    /// how a test reads a raised or lowered run off the map without caring
5233    /// which row it landed on.
5234    fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
5235        m.rows
5236            .iter()
5237            .flat_map(|r| r.glyphs.iter())
5238            .filter(|g| g.ch == ch)
5239            .map(|g| g.style.baseline)
5240            .collect()
5241    }
5242
5243    /// [`map`] at a chosen wrap width.
5244    fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
5245        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5246        build_t(&ed.nodes().unwrap(), src, wrap)
5247    }
5248
5249    /// [`map`], but with twig's `directives` extension on (off by twig's own
5250    /// default) — the `:::name{.class}` fenced-div containers leaf-core's
5251    /// `"directive"` wysiwyg arm renders.
5252    fn map_directives(src: &str) -> VisualMap {
5253        let mut ed = Editor::new_ext(
5254            src.as_bytes(),
5255            Format::Markdown,
5256            twig::MarkdownExtensions {
5257                directives: true,
5258                ..Default::default()
5259            },
5260        )
5261        .unwrap();
5262        build_t(&ed.nodes().unwrap(), src, Some(80))
5263    }
5264
5265    /// [`map`] with soft breaks preserved (`LineFlow::Preserve`).
5266    fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
5267        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5268        build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
5269    }
5270
5271    /// The cache-free reference [`build`], with no per-image height overrides —
5272    /// every block image stays its default one-row placeholder. The tests that
5273    /// need a taller image drive it through [`crate::Doc::set_media_rows`] instead.
5274    fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
5275        build(nodes, src, wrap, false, &HashMap::new(), None)
5276    }
5277
5278    /// An arena and a string that disagree — spans reaching past the source they
5279    /// are built against.
5280    ///
5281    /// [`crate::Doc`] keeps the two in step, so this is a "cannot happen" that
5282    /// nonetheless *did*: `examples/bench` timed a loop of `edit_range` and then
5283    /// went on handing the grown editor's spans to a builder holding the string
5284    /// from before it, and every run ended in a slice panic rather than a
5285    /// number. `push_escaped_text` was already written to survive the mismatch —
5286    /// it clamps the span's end and falls back to an empty slice — and this is
5287    /// the half of that intent it did not carry through.
5288    ///
5289    /// Rendering the wrong thing is the acceptable answer here; panicking in a
5290    /// paint loop is not.
5291    #[test]
5292    fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
5293        // An escape puts the run on `push_escaped_text`'s slow path — the fast
5294        // path is a length comparison that a truncated source fails anyway.
5295        let src = "alpha \\*beta\\* gamma delta epsilon\n";
5296        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5297        let nodes = ed.nodes().unwrap();
5298
5299        // Every truncation of it, so the cut lands before, inside and after the
5300        // escaped run rather than only where one hand-picked index put it.
5301        for cut in 0..=src.len() {
5302            if !src.is_char_boundary(cut) {
5303                continue;
5304            }
5305            let map = build_t(&nodes, &src[..cut], Some(80));
5306            for row in &map.rows {
5307                for g in &row.glyphs {
5308                    assert!(
5309                        g.src <= src.len(),
5310                        "cut {cut}: glyph {:?} points past the source at {}",
5311                        g.ch,
5312                        g.src
5313                    );
5314                }
5315            }
5316        }
5317    }
5318
5319    fn rendered(m: &VisualMap) -> String {
5320        m.rows
5321            .iter()
5322            .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
5323            .collect::<Vec<_>>()
5324            .join("\n")
5325    }
5326
5327    /// Render a source both ways: `build` over the whole marshalled arena (the
5328    /// reference), and `build_cached` driven the way [`crate::Doc`] drives it —
5329    /// top-level blocks from `child_spans`, per-block subtrees on a miss.
5330    fn render_both(
5331        ed: &mut Editor,
5332        src: &str,
5333        wrap: Option<usize>,
5334        cache: &mut BlockCache,
5335    ) -> (VisualMap, VisualMap) {
5336        let all = ed.nodes().unwrap();
5337        let media_rows = HashMap::new();
5338        let plain = build(&all, src, wrap, false, &media_rows, None);
5339        let top = top_blocks(ed);
5340        let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
5341            ed.subtree(NodeId(id)).unwrap_or_default()
5342        });
5343        (plain, cached)
5344    }
5345
5346    /// The whole correctness claim of the block cache: `build_cached` produces a
5347    /// byte-identical map to `build`, on a fresh cache *and* — the case that
5348    /// actually exercises reuse-and-shift plus per-block subtree marshalling — on
5349    /// a warm cache after the source has been edited underneath it.
5350    /// **Every glyph must stand on the character it claims.** A row's source
5351    /// extent is computed from its last glyph's offset, so a glyph carrying an
5352    /// offset that is not its own character's start yields a row end inside a
5353    /// multi-byte character — and every later slice of the source panics on it.
5354    ///
5355    /// Reproduces a real crash from a journal entry: a bracketed elision inside
5356    /// a blockquote (`[…]`) gave the closing bracket a `text` of `"]"` over a
5357    /// source span covering `"…]"`, because the parse folded the ellipsis into a
5358    /// neighbouring node. `push_escaped_text` walked that span assuming a
5359    /// dropped backslash was the only way text and source could diverge, so the
5360    /// `]` landed on the `…`'s first byte:
5361    /// `byte index 1236 is not a char boundary; it is inside '…'`.
5362    #[test]
5363    fn a_glyph_never_lands_inside_the_character_before_it() {
5364        let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
5365        let vmap = map(src);
5366        for (r, row) in vmap.rows.iter().enumerate() {
5367            assert!(
5368                src.is_char_boundary(row.end_src.min(src.len())),
5369                "row {r} ends at {} — inside a character",
5370                row.end_src
5371            );
5372            for g in &row.glyphs {
5373                assert!(
5374                    src.is_char_boundary(g.src.min(src.len())),
5375                    "row {r} has {:?} at {}, which is inside a character",
5376                    g.ch,
5377                    g.src
5378                );
5379            }
5380        }
5381        // The elision survives, and its bracket sits on the real `]`.
5382        let text: String = vmap
5383            .rows
5384            .iter()
5385            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5386            .collect();
5387        assert!(text.contains("[…]"), "the elision should render: {text:?}");
5388        let close = vmap
5389            .rows
5390            .iter()
5391            .flat_map(|r| r.glyphs.iter())
5392            .find(|g| g.ch == ']')
5393            .expect("a closing bracket");
5394        assert_eq!(
5395            src[close.src..].chars().next(),
5396            Some(']'),
5397            "the bracket glyph should stand on the source's own `]`"
5398        );
5399    }
5400
5401    #[test]
5402    fn build_cached_matches_build() {
5403        let docs = [
5404            "# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
5405            "## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
5406            "para one\n\n```\ncode\nlines\n```\n\nafter code\n",
5407            "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
5408            "line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
5409            "> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
5410            "intro\n\n![a cat](img/cat.png)\n\nbetween\n\n![](https://x.dev/logo.svg)\n\nend\n",
5411            "- text item\n- ![alt](pic.png)\n- more text\n",
5412            // Footnotes: twig parses each definition as a root beside `doc`, so
5413            // these are the docs where the reference build and the incremental
5414            // one could disagree about what the top-level blocks even are.
5415            "A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
5416            "note[^a]\n\n[^a]: body **bold**\n    wrapped on\n    three lines\n\nafter\n",
5417            // No trailing newline. twig closes the document's last block on the
5418            // virtual newline it supplies at EOF, so that block's `span.end` is
5419            // `source.len() + 1` — a range that slices no bytes at all. Keying
5420            // the block cache off such a slice made every last block hash alike;
5421            // see [`block_bytes`].
5422            "# Title\n\nThe quick brown fox.\n\nA tail with no newline",
5423            "A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
5424            // Comments draw nothing. The per-block builder the cached path
5425            // renders one with starts at offset 0 and, drawing nothing, never
5426            // moved — so the walk went on from 0 and spelled every line of the
5427            // document as a blank row. One at the start, one between blocks,
5428            // one at the end, so each position is covered.
5429            "<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
5430            // Link reference definitions: roots beside `doc` like footnotes,
5431            // but drawing nothing. Alone between blocks, glued under a
5432            // paragraph, and closing the file under a comment — the README
5433            // shape.
5434            "see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
5435        ];
5436        for wrap in [None, Some(80usize), Some(20)] {
5437            for src in docs {
5438                let ctx = format!("wrap={wrap:?} src={src:?}");
5439                let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5440                let mut cache = BlockCache::default();
5441
5442                // 1) Fresh cache equals the cache-free build.
5443                let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
5444                assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
5445
5446                // 2) Type a char mid-document, reparse, rebuild with the now-warm
5447                //    cache: the edited block is re-marshalled and re-rendered,
5448                //    every block below it is reused shifted, and the result must
5449                //    still match a from-scratch build.
5450                let at = (src.len() / 2..=src.len())
5451                    .find(|&i| src.is_char_boundary(i))
5452                    .unwrap();
5453                ed.edit_range(at, at, "Z").unwrap();
5454                let src2 = ed.source_str().unwrap();
5455                let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
5456                assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
5457
5458                // 3) Delete it again: offsets shift back the other way, and the
5459                //    warm cache must not hand back stale shifted rows.
5460                ed.edit_range(at, at + 1, "").unwrap();
5461                let src3 = ed.source_str().unwrap();
5462                let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
5463                assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
5464            }
5465        }
5466    }
5467
5468    /// A document that does not end in a newline is the one place twig hands
5469    /// leaf a top-level span that addresses no source: the last block is closed
5470    /// on the virtual newline the parser supplies at EOF, so its `span.end` is
5471    /// `source.len() + 1`. The block cache keys on the bytes under that span, and
5472    /// reading the out-of-range slice as *no bytes* broke it two ways at once —
5473    /// [`block_bytes`] has the full account. Both ways are checked here, because
5474    /// they fail independently.
5475    #[test]
5476    fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
5477        // One: two overrunning blocks collide. A footnote definition is a root
5478        // beside `doc` that [`top_blocks`] merges into the top level, while the
5479        // `section` above it spans the definition's bytes too — so when the
5480        // definition ends the file, both blocks end past it. The second was
5481        // served the first's rows, and the definition rendered as a copy of the
5482        // heading.
5483        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.";
5484        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5485        let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
5486        assert_maps_eq(&plain, &cached, "a definition ending the file");
5487        let text = rendered(&cached);
5488        assert!(
5489            text.ends_with("[note] A note with a word for a label."),
5490            "the last definition should render itself: {text:?}"
5491        );
5492        assert_eq!(
5493            text.matches("A heading with a reference").count(),
5494            1,
5495            "the heading should render exactly once: {text:?}"
5496        );
5497
5498        // Two: one overrunning block goes stale. Its bytes are its cache key, so
5499        // a block that keeps hashing the same however it is edited is served the
5500        // rows built before the edit — the whole last line frozen as the user
5501        // types in it.
5502        let mut cache = BlockCache::default();
5503        let first = "first para\n\n# A heading\n\nlast para with no newline";
5504        let mut ed = Editor::new_str(first, Format::Djot).unwrap();
5505        let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
5506        assert!(rendered(&warm).ends_with("last para with no newline"));
5507
5508        let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
5509        let mut ed = Editor::new_str(second, Format::Djot).unwrap();
5510        let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
5511        assert_maps_eq(&plain, &cached, "edited last block, warm cache");
5512        let text = rendered(&cached);
5513        assert!(
5514            text.ends_with("DIFFERENT text without a newline"),
5515            "the warm cache served the pre-edit rows: {text:?}"
5516        );
5517    }
5518
5519    #[test]
5520    fn resolves_markup_to_plain_text() {
5521        let text = rendered(&map("# Title\n\na **bold** word\n"));
5522        assert!(!text.contains('#'), "heading marker shown: {text:?}");
5523        assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
5524        assert!(text.contains("Title") && text.contains("bold word"));
5525    }
5526
5527    #[test]
5528    fn every_glyph_points_at_its_source_byte() {
5529        let src = "a **bold** c\n";
5530        let m = map(src);
5531        for row in &m.rows {
5532            for g in &row.glyphs {
5533                // A real (non-synthetic) glyph's source byte is the glyph's char.
5534                if g.src < src.len()
5535                    && src.is_char_boundary(g.src)
5536                    && let Some(sc) = src[g.src..].chars().next()
5537                    && sc == g.ch
5538                {
5539                    continue;
5540                }
5541                // Synthetic prefixes (none here) would be the only exceptions.
5542                panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
5543            }
5544        }
5545    }
5546
5547    #[test]
5548    fn offset_and_position_round_trip_on_visible_text() {
5549        let m = map("hello world\n");
5550        let (r, c) = m.pos_of_offset(6); // the 'w'
5551        assert_eq!(m.offset_of_pos(r, c), 6);
5552    }
5553
5554    #[test]
5555    fn visible_utf16_indices_count_the_text_the_system_sees() {
5556        // Hidden delimiters, a two-unit emoji, and a block gap — every way the
5557        // visible text's UTF-16 length parts company with a source byte count.
5558        let src = "a **b\u{1F600}** c\n\nd\n";
5559        let m = map(src);
5560        let end = m.snap_to_stop(src.len());
5561        let text = m.visible_text(0, end);
5562        assert_eq!(text, "a b\u{1F600} c\nd");
5563
5564        // Forward: the index of each offset is where that character sits in
5565        // the visible string, in UTF-16 units.
5566        for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
5567            let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
5568            assert_eq!(
5569                m.visible_utf16_len(0, *src_off),
5570                expect,
5571                "utf16 index of source offset {src_off}"
5572            );
5573            // And back: the index resolves to the offset it came from.
5574            assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
5575        }
5576        // Inside the emoji's surrogate pair resolves to the emoji.
5577        let emoji_src = src.find('\u{1F600}').unwrap();
5578        let emoji_idx = m.visible_utf16_len(0, emoji_src);
5579        assert_eq!(
5580            m.offset_at_visible_utf16(end, emoji_idx + 1),
5581            Some(emoji_src)
5582        );
5583        // At or past the end is nobody's character.
5584        let total = m.visible_utf16_len(0, end);
5585        assert_eq!(total, text.encode_utf16().count());
5586        assert_eq!(m.offset_at_visible_utf16(end, total), None);
5587    }
5588
5589    #[test]
5590    fn visible_text_spends_exactly_one_character_on_every_stop() {
5591        // A list (whose items' ends no gap row follows), a table (whose cells'
5592        // ends draw a gutter space), and a code block (one row per line):
5593        // every place the text used to part company with the stop count, in
5594        // both directions. `UITextInput`'s tokenizer indexes this text by
5595        // that count, so the two must agree exactly between any two stops.
5596        let src = "- one\n- two\n\n| a | b |\n| - | - |\n| c | d |\n\n```\nx\ny\n```\n\nend\n";
5597        let m = map(src);
5598        let end = m.snap_to_stop(src.len());
5599        assert_eq!(m.visible_text(0, end), "one\ntwo\na\nb\nc\nd\nx\ny\nend");
5600        // Between any two stops, one character per hop.
5601        let first = m.snap_to_glyph_stop(0);
5602        let stops: Vec<usize> = std::iter::successors(Some(first), |&o| m.stop_after(o)).collect();
5603        for (i, &a) in stops.iter().enumerate() {
5604            for (j, &b) in stops.iter().enumerate().skip(i) {
5605                assert_eq!(
5606                    m.visible_text(a, b).chars().count(),
5607                    j - i,
5608                    "text between stops {a} and {b}"
5609                );
5610            }
5611        }
5612        // A cell's end is spelled as a line end, not the space it draws, so a
5613        // tap landing past `a`'s last letter has nothing to step over into `b`.
5614        let a_end = src.find("a |").unwrap() + 1;
5615        assert_eq!(m.visible_text(a_end, a_end + 1), "\n");
5616    }
5617
5618    #[test]
5619    fn unwrapped_mode_emits_one_row_per_paragraph() {
5620        // A long paragraph that would wrap under a column budget stays a single
5621        // row when wrap is None (the GUI wraps it at pixel width instead).
5622        let long = "one two three four five six seven eight nine ten eleven twelve\n";
5623        let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
5624        let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
5625        let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
5626        assert!(wrapped.num_rows() > 1, "narrow column should wrap");
5627        assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
5628        // Every glyph's source byte is preserved in the single row.
5629        let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
5630        assert_eq!(text.trim_end(), long.trim_end());
5631    }
5632
5633    fn line_texts(m: &VisualMap) -> Vec<String> {
5634        m.rows
5635            .iter()
5636            .map(|r| {
5637                // Trim the trailing whitespace a row may carry — the zero-width
5638                // '\n' that closes a preserved line, and any space glyph left at
5639                // a wrap boundary (both real caret stops, neither visible text).
5640                r.glyphs
5641                    .iter()
5642                    .map(|g| g.ch)
5643                    .collect::<String>()
5644                    .trim_end()
5645                    .to_string()
5646            })
5647            .collect()
5648    }
5649
5650    #[test]
5651    fn preserve_lays_each_soft_break_on_its_own_row() {
5652        // A soft break (a bare newline inside a paragraph) folds into a space by
5653        // default — the whole paragraph is one reflowed row...
5654        let src = "one two\nthree four\n";
5655        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5656        let folded = build_t(&ed.nodes().unwrap(), src, None);
5657        assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
5658        assert_eq!(
5659            line_texts(&folded),
5660            vec!["one two three four"],
5661            "break folded to a space"
5662        );
5663
5664        // ...and under Preserve it renders where it was written, a row per line.
5665        let kept = map_preserve(src, None);
5666        assert_eq!(
5667            line_texts(&kept),
5668            vec!["one two", "three four"],
5669            "preserve: a row per line"
5670        );
5671    }
5672
5673    #[test]
5674    fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
5675        // The break must leave a caret stop at the newline byte, or the caret
5676        // could not rest at the end of the first line. The '\n' glyph is dropped
5677        // from the row (so nothing stray renders); its offset (7 here) becomes the
5678        // row's end stop instead — the same offset the folded space would carry.
5679        let src = "one two\nthree four\n";
5680        let m = map_preserve(src, None);
5681        assert!(
5682            !m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
5683            "the break glyph is dropped"
5684        );
5685        assert_eq!(
5686            m.rows[0].end_src, 7,
5687            "the first row ends at the newline byte"
5688        );
5689        assert!(m.is_stop(7), "the newline offset is a caret stop");
5690        // Row end offsets stay strictly ascending — no two rows pin one offset.
5691        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
5692        assert!(
5693            offs.windows(2).all(|w| w[0] < w[1]),
5694            "offsets not unique: {offs:?}"
5695        );
5696    }
5697
5698    #[test]
5699    fn preserved_lines_wrap_independently() {
5700        // Each preserved line wraps to the column on its own; the break between
5701        // them is hard, so a word never crosses it — "gamma" and "delta" could
5702        // share a row on width alone but the soft break keeps them apart.
5703        let src = "alpha beta gamma\ndelta epsilon\n";
5704        let m = map_preserve(src, Some(12));
5705        assert_eq!(
5706            line_texts(&m),
5707            vec!["alpha beta", "gamma", "delta", "epsilon"],
5708            "each source line wraps on its own"
5709        );
5710    }
5711
5712    #[test]
5713    fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
5714        // "A", then two blank lines (an empty paragraph opened with Enter), then
5715        // "B": the empty paragraph must be navigable rows, not collapsed onto B.
5716        // Rows: "A", spacer, empty-paragraph, spacer, "B" — each blank row a
5717        // distinct source offset.
5718        let m = map("A\n\n\n\nB\n");
5719        let text: Vec<String> = m
5720            .rows
5721            .iter()
5722            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5723            .collect();
5724        assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
5725        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
5726        // Strictly ascending — no two rows share an offset (else the caret pins).
5727        assert!(
5728            offs.windows(2).all(|w| w[0] < w[1]),
5729            "offsets not unique: {offs:?}"
5730        );
5731    }
5732
5733    #[test]
5734    fn a_tight_block_boundary_still_gets_one_separator() {
5735        // A heading directly above text (no blank line between) keeps the single
5736        // conventional separator row, as before.
5737        let m = map("# H\ntext\n");
5738        let text: Vec<String> = m
5739            .rows
5740            .iter()
5741            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5742            .collect();
5743        assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
5744    }
5745
5746    #[test]
5747    fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
5748        // `a\*b` renders the three visible chars `a * b` — the escape backslash
5749        // is hidden — and every glyph points at its real source byte, so a caret
5750        // past the escape lands right (the `*` at source 2, `b` at source 3, not
5751        // the drifted 1/2 the naive text-offset mapping gave).
5752        let m = map("a\\*b\n");
5753        let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
5754        assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
5755    }
5756
5757    #[test]
5758    fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
5759        // `\# hi` is a paragraph beginning with a literal `#`, not a heading —
5760        // the backslash is hidden, the `#` shown at its true offset.
5761        let m = map("\\# hi\n");
5762        let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
5763        assert_eq!(text, "# hi");
5764        assert_eq!(
5765            m.rows[0].glyphs[0].src, 1,
5766            "the # is at source byte 1, past the \\"
5767        );
5768    }
5769
5770    #[test]
5771    fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
5772        // A list item's own text and the sub-list nested under it are written on
5773        // adjacent source lines, so the rich view butts them together — no
5774        // fabricated blank row. Regression: the synthetic "breathe" separator
5775        // used to open a gap between `• a` and its `  • b`.
5776        assert_eq!(rendered(&map("- a\n  - b\n")), "• a\n  • b");
5777    }
5778
5779    #[test]
5780    fn a_loose_nested_list_keeps_its_real_blank_line() {
5781        // A genuine blank source line (a loose list) still parts the item from
5782        // its sub-list — only the *fabricated* separator is suppressed, never a
5783        // real one the author typed. The gap row wears the item's continuation
5784        // prefix (the two-space indent), so it renders as "  ", not empty.
5785        assert_eq!(rendered(&map("- a\n\n  - b\n")), "• a\n  \n  • b");
5786    }
5787
5788    #[test]
5789    fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
5790        // Leading YAML frontmatter renders nothing — no phantom blank rows for
5791        // its lines, no leading gap — and `content_start` points at the first
5792        // real block so the caret floor can keep out of the hidden metadata.
5793        let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
5794        let src = format!("{fm}# leaf\n\nA line.\n");
5795        let m = map(&src);
5796        let text = rendered(&m);
5797        assert!(
5798            !text.contains("config"),
5799            "frontmatter body leaked: {text:?}"
5800        );
5801        assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
5802        assert_eq!(
5803            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5804            "leaf"
5805        );
5806        assert_eq!(
5807            m.content_start,
5808            fm.len(),
5809            "floor should be the first real block"
5810        );
5811    }
5812
5813    #[test]
5814    fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
5815        // Nothing to render, so the caret floor is the end of the hidden
5816        // frontmatter — not 0, which is *before* the opening `---` and made the
5817        // first keystroke in a fresh metadata-only note land ahead of it. And
5818        // the frontmatter's own newlines are not trailing blank lines: they used
5819        // to open phantom rows at offsets 1..4, inside the metadata.
5820        let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
5821        let m = map(src);
5822        assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
5823        assert!(
5824            m.rows.is_empty(),
5825            "frontmatter must render no rows: {:?}",
5826            rendered(&m)
5827        );
5828        assert!(
5829            m.stops.is_empty(),
5830            "no stop may sit inside the metadata: {:?}",
5831            m.stops
5832        );
5833    }
5834
5835    #[test]
5836    fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
5837        // Two blank lines after the frontmatter are the author's empty paragraph
5838        // and still render, counted from the metadata's end rather than from 0.
5839        let fm = "---\ntitle: n\n---\n";
5840        let m = map(&format!("{fm}\n\n"));
5841        assert_eq!(m.content_start, fm.len());
5842        assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
5843        assert!(
5844            m.rows.iter().all(|r| r.end_src > fm.len()),
5845            "rows must sit past the frontmatter"
5846        );
5847    }
5848
5849    #[test]
5850    fn a_document_without_frontmatter_has_a_zero_floor() {
5851        let m = map("# leaf\n\nbody\n");
5852        assert_eq!(m.content_start, 0);
5853    }
5854
5855    #[test]
5856    fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
5857        // Markdown/Djot drop the trailing space in `hello ` from the `str` node,
5858        // so without help the row would end at `hello` and the caret couldn't be
5859        // drawn past column 5 — typing a space at a line's end wouldn't move it
5860        // on screen until the next visible character reparsed the space into an
5861        // interior node. The builder recovers it from the block's span/content_span
5862        // gap and emits it as a real, caret-stoppable glyph.
5863        let m = map("hello \n");
5864        assert_eq!(
5865            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5866            "hello "
5867        );
5868        assert_eq!(
5869            m.rows[0].end_src, 6,
5870            "the row now ends past the trailing space"
5871        );
5872        // The caret can rest both on and past the space.
5873        assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
5874        assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
5875        // Two trailing spaces, both stops.
5876        let m = map("hello  \n");
5877        assert_eq!(
5878            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5879            "hello  "
5880        );
5881        assert_eq!(m.pos_of_offset(7), (0, 7));
5882    }
5883
5884    #[test]
5885    fn a_headings_trailing_space_is_a_caret_stop_too() {
5886        // The hidden `# ` marker means `# hi ` renders as `hi ` in three columns;
5887        // the caret past the trailing space lands on the third.
5888        let m = map("# hi \n");
5889        assert_eq!(
5890            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5891            "hi "
5892        );
5893        assert_eq!(m.pos_of_offset(5), (0, 3));
5894    }
5895
5896    #[test]
5897    fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
5898        // A cell's own `span` is the whole row, so the trailing-whitespace
5899        // recovery must not run for cells or it would swallow the `│` delimiters
5900        // and neighbours between the cell text and the row's end. The grid stays
5901        // exactly as before.
5902        let text = rendered(&map(TABLE));
5903        assert!(
5904            text.contains("│ Pear │   3 │"),
5905            "cell padding disturbed:\n{text}"
5906        );
5907    }
5908
5909    #[test]
5910    fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
5911        // A drag into the empty space under a short document used to resolve to
5912        // offset 0 — the wrong direction, and not even a caret stop when the
5913        // document opens on hidden frontmatter (its `content_start` floor is not
5914        // a stop), which crashed the caret invariant. It now lands on the last
5915        // stop: the end of the document, where dragging downward should reach.
5916        let fm = "---\ntitle: n\n---\n";
5917        let m = map(&format!("{fm}# Hi\n\nbody\n"));
5918        let below = m.num_rows() + 5;
5919        let off = m.offset_of_pos(below, 0);
5920        assert!(
5921            m.is_stop(off),
5922            "offset {off} from a below-content click is not a stop"
5923        );
5924        assert_eq!(
5925            off,
5926            m.stops.last().copied().unwrap(),
5927            "should be the document's last stop"
5928        );
5929        assert!(
5930            off > fm.len(),
5931            "must not fall onto the hidden frontmatter floor"
5932        );
5933    }
5934
5935    #[test]
5936    fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
5937        // The invariant the caret motion asserts: whatever cell a click names,
5938        // the offset it resolves to is one the caret can actually rest at.
5939        for src in [
5940            "hello \n",
5941            "# A heading here \n\nbody text goes on \n",
5942            "---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
5943        ] {
5944            let m = map(src);
5945            for row in 0..m.num_rows() + 3 {
5946                for col in 0..30 {
5947                    let off = m.offset_of_pos(row, col);
5948                    assert!(
5949                        m.is_stop(off),
5950                        "row {row} col {col} → {off} is not a stop in {src:?}"
5951                    );
5952                }
5953            }
5954        }
5955    }
5956
5957    /// `| Name | Qty |` with Name left-aligned and Qty right-aligned.
5958    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
5959
5960    #[test]
5961    fn a_table_renders_as_an_aligned_grid() {
5962        let text = rendered(&map(TABLE));
5963        assert_eq!(
5964            text,
5965            "┌──────┬─────┐\n\
5966             │ Name │ Qty │\n\
5967             ├──────┼─────┤\n\
5968             │ Pear │   3 │\n\
5969             │ Fig  │  12 │\n\
5970             └──────┴─────┘",
5971            "got:\n{text}"
5972        );
5973    }
5974
5975    #[test]
5976    fn table_columns_honour_their_alignment() {
5977        // Centre and default(left) come straight from twig's cell.alignment —
5978        // the delimiter row it's spelled in is consumed and has no node.
5979        let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
5980        assert!(text.contains("│ x │  y  │"), "centred column: {text:?}");
5981    }
5982
5983    #[test]
5984    fn table_borders_are_decoration_the_caret_never_lands_on() {
5985        let m = map(TABLE);
5986        // The rules are whole decoration rows.
5987        for r in [0, 2, 5] {
5988            assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
5989            assert!(
5990                !m.rows[r].glyphs.iter().any(|g| g.stop),
5991                "row {r} has a stop"
5992            );
5993        }
5994        // A content row's `│` and padding are decoration; only the cell text
5995        // and each cell's one end-stop are stops.
5996        let header = &m.rows[1];
5997        assert!(!header.decoration);
5998        for g in &header.glyphs {
5999            if g.ch == '│' {
6000                assert!(!g.stop, "a border is not a caret stop");
6001            }
6002        }
6003        let stops: String = header
6004            .glyphs
6005            .iter()
6006            .filter(|g| g.stop)
6007            .map(|g| g.ch)
6008            .collect();
6009        assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
6010    }
6011
6012    #[test]
6013    fn a_cell_maps_to_its_own_source_text() {
6014        let m = map(TABLE);
6015        // "Pear" starts at byte 32 in TABLE; the caret there draws on the 'P'.
6016        let pear = TABLE.find("Pear").unwrap();
6017        let (r, c) = m.pos_of_offset(pear);
6018        assert_eq!(m.rows[r].glyphs[c].ch, 'P');
6019        assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
6020    }
6021
6022    #[test]
6023    fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
6024        // Columns wider than the surface used to run off the right edge, where
6025        // nothing could reach them. They're cut to the budget instead, and the
6026        // text wraps down inside the column — the header rule stays put, and
6027        // an alignment holds on every line of a wrapped cell, not just the first.
6028        let src = "| Ingredient | Notes |\n|---|---:|\n\
6029                   | flour milled coarse | sift it twice |\n| salt | a pinch |\n";
6030        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6031        let m = build_t(&ed.nodes().unwrap(), src, Some(30));
6032        let text = rendered(&m);
6033        assert_eq!(
6034            text,
6035            "┌──────────────┬─────────────┐\n\
6036             │ Ingredient   │       Notes │\n\
6037             ├──────────────┼─────────────┤\n\
6038             │ flour milled │     sift it │\n\
6039             │ coarse       │       twice │\n\
6040             │ salt         │     a pinch │\n\
6041             └──────────────┴─────────────┘",
6042            "got:\n{text}"
6043        );
6044        for (r, row) in m.rows.iter().enumerate() {
6045            assert!(
6046                row.glyphs.len() <= 30,
6047                "row {r} overflows: {}",
6048                row.glyphs.len()
6049            );
6050        }
6051    }
6052
6053    #[test]
6054    fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
6055        // A paragraph lets an overlong word trail off the end of the line; a
6056        // table column can't — a glyph past the border lands on the border.
6057        let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
6058        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6059        let m = build_t(&ed.nodes().unwrap(), src, Some(20));
6060        for (r, row) in m.rows.iter().enumerate() {
6061            assert!(
6062                row.glyphs.len() <= 20,
6063                "row {r} overflows: {}",
6064                row.glyphs.len()
6065            );
6066        }
6067        // Broken across lines, but whole: every letter is still drawn, at its
6068        // own source byte, where the caret can reach it.
6069        let word = "antidisestablishmentarianism";
6070        let at = src.find(word).unwrap();
6071        for (i, ch) in word.char_indices() {
6072            assert!(
6073                m.rows
6074                    .iter()
6075                    .flat_map(|r| r.glyphs.iter())
6076                    .any(|g| g.stop && g.src == at + i && g.ch == ch),
6077                "{ch:?} at {} was lost to the break",
6078                at + i
6079            );
6080        }
6081    }
6082
6083    #[test]
6084    fn a_code_block_maps_each_line_to_its_own_source_text() {
6085        // Every glyph used to point at the block's start, which made the whole
6086        // block one offset — visible, but impossible to put a caret inside.
6087        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
6088        let m = map(src);
6089        for row in &m.rows {
6090            for g in row.glyphs.iter().filter(|g| g.stop) {
6091                assert_eq!(
6092                    src[g.src..].chars().next(),
6093                    Some(g.ch),
6094                    "glyph {:?} at {} isn't the source byte it claims",
6095                    g.ch,
6096                    g.src
6097                );
6098            }
6099        }
6100    }
6101
6102    #[test]
6103    fn an_indented_code_block_maps_past_its_stripped_indent() {
6104        // twig strips the four-space indent, so `text` isn't a source slice and
6105        // the lines have to be re-found. Offsets land on the code, not the indent.
6106        let src = "    indented\n    code\n";
6107        let m = map(src);
6108        let stops: Vec<(char, usize)> = m
6109            .rows
6110            .iter()
6111            .flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
6112            .collect();
6113        assert_eq!(
6114            stops[0],
6115            ('i', 4),
6116            "first line should start past the indent"
6117        );
6118        assert!(
6119            stops.contains(&('c', 17)),
6120            "second line misplaced: {stops:?}"
6121        );
6122    }
6123
6124    #[test]
6125    fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
6126        // The one case that defeats a forward search: the opening fence
6127        // ```` ```rust ```` ends with the same text as the code under it.
6128        let src = "```rust\nrust\n```\n";
6129        let m = map(src);
6130        let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
6131        assert_eq!(first.src, 8, "matched the info string, not the code");
6132    }
6133
6134    #[test]
6135    fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
6136        // The old `▏ ` gutter is gone: a code row is the block prefix (none, at
6137        // the top level) plus the code text, and the whole run is named in
6138        // `code_blocks` so a frontend can box it.
6139        let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
6140        let m = map(src);
6141        assert_eq!(m.code_blocks.len(), 1, "one code block");
6142        let span = m.code_blocks[0].rows_span.clone();
6143        let rows: Vec<String> = m.rows[span.clone()]
6144            .iter()
6145            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6146            .collect();
6147        assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
6148        assert!(!rendered(&m).contains('▏'), "gutter still drawn");
6149        assert!(
6150            m.rows[span].iter().all(|r| r.code),
6151            "every row in the span is flagged code"
6152        );
6153    }
6154
6155    #[test]
6156    fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
6157        // `trim_end_matches('\n')` cut the block's terminator *and* the newline
6158        // that spells a trailing empty line, so the row the Return had just made
6159        // never appeared and the caret on it fell through to the block below.
6160        // Every empty line is a row, wherever in the block it falls.
6161        let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
6162        let m = map(src);
6163        let span = m.code_blocks[0].rows_span.clone();
6164        let rows: Vec<String> = m.rows[span.clone()]
6165            .iter()
6166            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6167            .collect();
6168        assert_eq!(
6169            rows,
6170            vec!["alpha".to_string(), "beta".to_string(), String::new()],
6171            "the empty last line gets a row"
6172        );
6173        assert!(
6174            m.rows[span.clone()].iter().all(|r| r.code),
6175            "the empty row is flagged code like the rest of the block"
6176        );
6177        // And it is the *source's* empty line, not a coarse fallback to the
6178        // block start: the offset the caret resolves to is the one Return made.
6179        let empty = span.end - 1;
6180        assert_eq!(
6181            m.rows[empty].end_src,
6182            src.find("beta\n\n").unwrap() + "beta\n".len(),
6183            "the empty row maps to the line the Return opened"
6184        );
6185
6186        // Nothing is invented where there is no empty line, and a second one is
6187        // a second row.
6188        assert_eq!(
6189            map("```\nalpha\nbeta\n```\n").code_blocks[0]
6190                .rows_span
6191                .len(),
6192            2,
6193            "a block that ends at its last code line keeps two rows"
6194        );
6195        assert_eq!(
6196            map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
6197            3,
6198            "two trailing empty lines are two rows"
6199        );
6200    }
6201
6202    #[test]
6203    fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
6204        // diaryx's `:::vis{.public .family}` visibility block, and any other
6205        // `:::name{.class}` fenced div — core is agnostic of `name`.
6206        let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
6207        let m = map_directives(src);
6208
6209        let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
6210        assert!(!content_rows.is_empty(), "some row is flagged directive");
6211
6212        let after_rows: Vec<usize> = (0..m.rows.len())
6213            .filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
6214            .collect();
6215        assert!(
6216            after_rows.iter().all(|&i| !m.rows[i].directive),
6217            "content outside the fence isn't tinted"
6218        );
6219
6220        let labels: Vec<&str> = content_rows
6221            .iter()
6222            .filter_map(|&i| m.rows[i].directive_label.as_deref())
6223            .collect();
6224        assert_eq!(
6225            labels,
6226            vec!["public family"],
6227            "only the first row carries the label"
6228        );
6229
6230        assert_eq!(
6231            rendered(&m)
6232                .lines()
6233                .filter(|l| !l.is_empty())
6234                .collect::<Vec<_>>(),
6235            vec!["hello", "world", "after"],
6236            "fence markers don't leak into the rendered text"
6237        );
6238    }
6239
6240    #[test]
6241    fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
6242        // diaryx_core::visibility's own `:::vis{public family}` — no leading
6243        // dots — is what apps/web's directive serializer and the native
6244        // publish-time filter both actually write today, distinct from twig's
6245        // `.class` convention. Both must label the same way so every existing
6246        // diaryx `:::vis{...}` block reads, not just newly dot-authored ones.
6247        let src = ":::vis{public family}\nhello\n:::\n";
6248        let m = map_directives(src);
6249        let label = m.rows.iter().find_map(|r| r.directive_label.clone());
6250        assert_eq!(label.as_deref(), Some("public family"));
6251    }
6252
6253    #[test]
6254    fn a_text_directive_keeps_its_paragraph_visible() {
6255        // Regression: an inline `:name[label]{…}` used to make its paragraph
6256        // fail the "all children inline" test, so the whole line was walked as
6257        // a container of blocks and rendered as empty rows with NO caret stops —
6258        // the text vanished from the editor and the caret couldn't enter it.
6259        // diaryx's inline `:vis[…]` is exactly this shape.
6260        let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
6261        let m = map_directives(src);
6262        assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
6263        // Every character of the line is a caret home, markup excluded — the
6264        // label reads as ordinary text, the way a link's does.
6265        let stops: usize = m
6266            .rows
6267            .iter()
6268            .map(|r| r.glyphs.iter().filter(|g| g.stop).count())
6269            .sum();
6270        assert_eq!(stops, "Text with HTML inline.".chars().count());
6271        // It is inline, so it is not the container form's tinted panel.
6272        assert!(m.rows.iter().all(|r| !r.directive));
6273    }
6274
6275    #[test]
6276    fn a_text_directives_label_maps_to_its_true_source_bytes() {
6277        // Regression (needs twig-doc >= 2.5.0): twig parses a `[label]` as a
6278        // detached slice, and until it rebased the enclosing scan's segments
6279        // onto it every node inside the label reported a span of `(0,0)`. Read
6280        // by anything that trusts a span that means "byte 0", so the label's
6281        // glyphs mapped to the START OF THE DOCUMENT — a click on the label put
6282        // the caret at the top of the file, its stops collided with the real
6283        // first line's, and an edit there landed on the wrong bytes entirely.
6284        //
6285        // The sibling test `a_text_directive_keeps_its_paragraph_visible` only
6286        // counts stops, which is exactly why this went unnoticed: the right
6287        // NUMBER of stops at completely wrong offsets.
6288        let src = "x :abbr[HTML]{title=\"y\"} z\n";
6289        let m = map_directives(src);
6290        let stops: Vec<(char, usize)> = m
6291            .rows
6292            .iter()
6293            .flat_map(|r| &r.glyphs)
6294            .filter(|g| g.stop)
6295            .map(|g| (g.ch, g.src))
6296            .collect();
6297        // `HTML` sits at 8..12. The name, brackets and `{…}` are hidden markup
6298        // the caret steps over, so the line's stops run 0, 1, 8..12, then 24.
6299        assert_eq!(
6300            stops,
6301            [
6302                ('x', 0),
6303                (' ', 1),
6304                ('H', 8),
6305                ('T', 9),
6306                ('M', 10),
6307                ('L', 11),
6308                (' ', 24),
6309                ('z', 25)
6310            ]
6311        );
6312    }
6313
6314    #[test]
6315    fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
6316        // The `every_glyph_points_at_its_source_byte` invariant, extended over
6317        // directive labels now that their offsets are real. Nested markup is
6318        // included: its delimiters are hidden, so the visible glyphs must skip
6319        // them and still name their own bytes.
6320        let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
6321        let m = map_directives(src);
6322        for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
6323            let at = src[g.src..].chars().next();
6324            assert_eq!(
6325                at,
6326                Some(g.ch),
6327                "glyph {:?} claims byte {}, which is {at:?}",
6328                g.ch,
6329                g.src
6330            );
6331        }
6332        assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
6333    }
6334
6335    #[test]
6336    fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
6337        let src = "x :abbr[a *b* c] y\n";
6338        let m = map_directives(src);
6339        let b = m
6340            .rows
6341            .iter()
6342            .flat_map(|r| &r.glyphs)
6343            .find(|g| g.ch == 'b')
6344            .expect("the emphasised char");
6345        assert!(b.style.italic, "the label's *b* lost its emphasis");
6346        assert_eq!(b.src, 11, "the label's *b* lost its source byte");
6347    }
6348
6349    #[test]
6350    fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
6351        // Regression: twig matches a colon followed by any letter-led word, so
6352        // ordinary prose is full of "text directives" nobody meant to write.
6353        // With no `[label]` there are no children, and the arm recursed into
6354        // them — rendering *nothing*. The word vanished from the document with
6355        // no caret stop left behind, so it could not even be deleted.
6356        for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
6357            let m = map_directives(src);
6358            assert_eq!(
6359                rendered(&m).trim_end(),
6360                src.trim_end(),
6361                "prose was eaten: {src:?}"
6362            );
6363        }
6364    }
6365
6366    #[test]
6367    fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
6368        let src = "a :word b\n";
6369        let m = map_directives(src);
6370        // Nothing here is markup, so nothing is hidden: each byte maps to
6371        // itself and can be stood on, which is what makes the colon deletable.
6372        let stops: Vec<(char, usize)> = m
6373            .rows
6374            .iter()
6375            .flat_map(|r| &r.glyphs)
6376            .filter(|g| g.stop)
6377            .map(|g| (g.ch, g.src))
6378            .collect();
6379        assert_eq!(
6380            stops,
6381            "a :word b"
6382                .chars()
6383                .enumerate()
6384                .map(|(i, c)| (c, i))
6385                .collect::<Vec<_>>()
6386        );
6387    }
6388
6389    #[test]
6390    fn an_attribute_bearing_text_directive_draws_a_chip() {
6391        // `{…}` is deliberate in a way a bare colon is not — diaryx writes
6392        // `:vis{.family}` inline — so this one reads as an embed, on the same
6393        // `⧉ label` recipe the leaf form's placeholder row uses.
6394        // Both attribute conventions label it: twig's dot-prefixed classes and
6395        // the bare pandoc-style words diaryx also writes.
6396        for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
6397            let m = map_directives(src);
6398            assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
6399        }
6400        // A `key=value` attr is configuration, not a name, so it adds nothing.
6401        let m = map_directives("a :foo{title=\"x\"} b\n");
6402        assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
6403    }
6404
6405    #[test]
6406    fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
6407        let src = "a :vis{.family} b\n";
6408        let m = map_directives(src);
6409        let stops: Vec<usize> = m
6410            .rows
6411            .iter()
6412            .flat_map(|r| &r.glyphs)
6413            .filter(|g| g.stop)
6414            .map(|g| g.src)
6415            .collect();
6416        // The chip contributes exactly one stop, at the directive's start (2),
6417        // so the caret steps over it whole instead of walking hidden markup a
6418        // byte at a time. `{.family}`'s bytes (3..15) are never stood on.
6419        assert_eq!(stops, [0, 1, 2, 15, 16]);
6420    }
6421
6422    #[test]
6423    fn a_paragraph_holding_only_a_chip_is_still_navigable() {
6424        // With no stop of its own the row would be unreachable — the caret
6425        // could never be put on the line to edit or delete the directive.
6426        let m = map_directives(":vis{.family}\n");
6427        assert!(
6428            m.row_is_navigable(0),
6429            "a chip-only paragraph has no caret home"
6430        );
6431        assert_eq!(
6432            m.offset_of_pos(0, 0),
6433            0,
6434            "its caret home isn't the directive's start"
6435        );
6436    }
6437
6438    #[test]
6439    fn a_ratio_or_a_clock_time_is_never_a_directive() {
6440        // twig requires a letter after the colon, so these stay prose — the
6441        // verbatim arm must not be reached for them at all.
6442        let src = "ratio 3:4 and 10:30\n";
6443        assert_eq!(
6444            rendered(&map_directives(src)).trim_end(),
6445            "ratio 3:4 and 10:30"
6446        );
6447    }
6448
6449    #[test]
6450    fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
6451        // `::name{…}` is a standalone block with no body — an embed, a table of
6452        // contents. It used to emit no rows at all: invisible, no caret home,
6453        // vertical motion crossing a void. Now it draws the image recipe's
6454        // placeholder and publishes what the host app needs to paint the real
6455        // thing.
6456        let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
6457        let m = map_directives(src);
6458
6459        let row = m
6460            .rows
6461            .iter()
6462            .position(|r| r.leaf_directive.is_some())
6463            .expect("a placeholder row");
6464        assert_eq!(
6465            m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
6466            "⧉ embed"
6467        );
6468        assert!(
6469            m.rows[row].glyphs.iter().any(|g| g.stop),
6470            "the caret can land on it"
6471        );
6472        assert!(
6473            m.rows[row].directive,
6474            "a frontend frames it like the container form"
6475        );
6476
6477        assert_eq!(m.directives.len(), 1);
6478        let info = &m.directives[0];
6479        assert_eq!(info.name, "embed");
6480        assert_eq!(info.rows_span, row..row + 1);
6481        assert_eq!(info.attr("src"), Some("demo.html"));
6482        assert_eq!(info.attr("height"), Some("400"));
6483        assert_eq!(info.attr("nope"), None);
6484        // The prose around it is untouched.
6485        assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
6486    }
6487
6488    #[test]
6489    fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
6490        // A `[label]` names the placeholder (the way an image's alt does), and a
6491        // quoted directive keeps the quote's gutter — it is a block like any
6492        // other, not a special case that escapes its container.
6493        let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
6494        assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
6495        assert_eq!(m.directives[0].label, "Audience demo");
6496
6497        let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
6498        assert_eq!(rendered(&quoted).trim_end(), "│ ⧉ embed");
6499        assert_eq!(quoted.directives[0].name, "embed");
6500    }
6501
6502    #[test]
6503    fn a_container_directive_is_still_a_panel_not_a_placeholder() {
6504        // The three forms must not bleed into each other: only the leaf form is
6505        // a placeholder, and only the container form tints the blocks it wraps.
6506        let m = map_directives(":::note{.warning}\nBody\n:::\n");
6507        assert!(
6508            m.directives.is_empty(),
6509            "a container publishes no placeholder"
6510        );
6511        assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
6512        assert_eq!(rendered(&m).trim_end(), "Body");
6513        assert!(
6514            m.rows
6515                .iter()
6516                .any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
6517        );
6518    }
6519
6520    /// A production-path build with both extensions on — the only way to put a
6521    /// promoted HTML element and a directive in one document, which is what the
6522    /// `container` kind made necessary to tell apart. Returns the whole `Doc`
6523    /// because [`VisualMap`] is not `Clone`; read `doc.vmap`.
6524    fn doc_built(src: &str) -> crate::Doc {
6525        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
6526        doc.build_visual(80);
6527        doc
6528    }
6529
6530    /// Every `container` node in `src`, parsed the way production does (both
6531    /// extensions on), paired with what [`container_is_directive`] makes of it.
6532    fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
6533        let mut ed = Editor::new_ext(
6534            src.as_bytes(),
6535            Format::Markdown,
6536            twig::MarkdownExtensions {
6537                directives: true,
6538                html_elements: true,
6539                ..Default::default()
6540            },
6541        )
6542        .unwrap();
6543        ed.nodes()
6544            .unwrap()
6545            .iter()
6546            .filter(|n| n.kind == Kind::Container)
6547            .map(|n| {
6548                (
6549                    n.name.clone().unwrap_or_default(),
6550                    container_is_directive(n),
6551                    n.directive_form,
6552                )
6553            })
6554            .collect()
6555    }
6556
6557    #[test]
6558    fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
6559        // twig 2.8 folded `div`/`span`/`directive`/`element` into one `container`
6560        // kind. `directive_form` reads as though it separates them and does not:
6561        // a block-level `<div>` reports `Some(DirectiveForm::Container)` exactly
6562        // as a `:::note` does. Trusting it would draw directive chrome — a tinted
6563        // panel, a `.class` audience label — on every pasted Slack/Docs div.
6564        for (src, name, want) in [
6565            (":::note{.a}\nbody\n:::\n", "note", true),
6566            ("::embed{src=x}\n", "embed", true),
6567            ("a :vis[hi]{.b} b\n", "vis", true),
6568            ("<div class=\"x\">\nhi\n</div>\n", "div", false),
6569            ("<video src=\"v.mp4\" controls></video>\n", "video", false),
6570            ("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
6571            ("<figure>\n\nhi\n\n</figure>\n", "figure", false),
6572            // The `:` in an attribute must not read as a directive opener: the
6573            // `<` of the tag comes first, and first one wins.
6574            (
6575                "<video src=\"http://x.test/v.mp4\" controls></video>\n",
6576                "video",
6577                false,
6578            ),
6579            (
6580                "<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
6581                "source",
6582                false,
6583            ),
6584        ] {
6585            let found = containers(src);
6586            let hit = found.iter().find(|(n, ..)| n == name);
6587            let Some((_, is_directive, form)) = hit else {
6588                panic!("no `{name}` container in {src:?} — found {found:?}");
6589            };
6590            assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
6591        }
6592
6593        // And the reason this can't just read the field: for the one collision
6594        // that matters, the field says the same thing for both.
6595        let div = containers("<div class=\"x\">\nhi\n</div>\n");
6596        let note = containers(":::note{.a}\nbody\n:::\n");
6597        assert_eq!(
6598            div[0].2, note[0].2,
6599            "if these ever differ, `directive_form` became usable and this rule can go"
6600        );
6601    }
6602
6603    #[test]
6604    fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
6605        // A container's span opens with its *block prefix*, not its own markup —
6606        // `> ::embed{…}` starts at the `>`. Reading only the first byte to tell a
6607        // directive from an element (both `container` since 2.8) therefore misses
6608        // every nested one, and the placeholder silently renders as nothing.
6609        for (src, ctx) in [
6610            ("> ::embed{src=\"x\"}\n", "quoted"),
6611            ("- ::embed{src=\"x\"}\n", "listed"),
6612            (">> ::embed{src=\"x\"}\n", "twice quoted"),
6613        ] {
6614            let m = map_directives(src);
6615            assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
6616            assert_eq!(m.directives[0].name, "embed", "{ctx}");
6617        }
6618    }
6619
6620    #[test]
6621    fn a_video_is_still_media_and_not_a_directive() {
6622        // The other side of the same coin: `<video>` is a `container` too, and
6623        // must reach `block_media` rather than the directive arms.
6624        let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
6625        assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
6626        assert!(
6627            doc.vmap.rows.iter().all(|r| !r.directive),
6628            "the video drew directive chrome"
6629        );
6630    }
6631
6632    #[test]
6633    fn a_directive_needs_the_extension_flag() {
6634        // `map` (twig's default extensions) leaves `directives` off — the fence
6635        // renders as literal paragraph text, same as any other unrecognized
6636        // punctuation, never corrupting or panicking.
6637        let src = ":::vis{.public}\nhello\n:::\n";
6638        let m = map(src);
6639        assert!(m.rows.iter().all(|r| !r.directive));
6640        assert!(rendered(&m).contains(":::vis{.public}"));
6641    }
6642
6643    #[test]
6644    fn a_footnote_reference_keeps_its_paragraph_visible() {
6645        // Regression: `footnote_reference` was in neither `is_inline_kind` nor
6646        // the inline walker, so a paragraph carrying one failed the "all children
6647        // inline" test, was walked as a container of blocks, and rendered as
6648        // empty rows with no caret stop anywhere — the whole line vanished.
6649        let src = "A claim[^1] and more.\n";
6650        let m = map(src);
6651        assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
6652        // The `^` is spelling, not text: hidden the way a link's `](dest)` is.
6653        assert!(!rendered(&m).contains('^'));
6654    }
6655
6656    #[test]
6657    fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
6658        // What makes `[1]` read as a reference rather than as bracketed text.
6659        // The brackets ride with the label: the chip is one raised mark.
6660        let m = map("A claim[^1] and more.\n");
6661        assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
6662        assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
6663        assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
6664        assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
6665    }
6666
6667    #[test]
6668    fn a_footnote_reference_keeps_the_link_role_it_had() {
6669        // The raised baseline is added to the role, not swapped for it: every
6670        // frontend already paints `Role::Link`, and a reference is one.
6671        let m = map("A claim[^1].\n");
6672        let label = m
6673            .rows
6674            .iter()
6675            .flat_map(|r| &r.glyphs)
6676            .find(|g| g.ch == '1')
6677            .unwrap();
6678        assert_eq!(label.style.role, Role::Link);
6679        assert_eq!(label.style.baseline, Baseline::Super);
6680    }
6681
6682    /// The [`Role`] of the first glyph spelling `ch` — how a test reads one
6683    /// run's styling off a map without caring which row it landed on.
6684    fn role_of(m: &VisualMap, ch: char) -> Role {
6685        m.rows
6686            .iter()
6687            .flat_map(|r| r.glyphs.iter())
6688            .find(|g| g.ch == ch)
6689            .unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
6690            .style
6691            .role
6692    }
6693
6694    #[test]
6695    fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
6696        // twig 3.3's `highlight`/`highlight_colors`, which `parse_extensions`
6697        // turns on for every leaf document: `==text==` is a `mark` in Markdown
6698        // and not the literal `==` it used to be, and `==🔴 text==` is one
6699        // carrying a colour.
6700        //
6701        // `doc_built` rather than `map`, deliberately — the extensions are
6702        // leaf's choice, not twig's default, so a test that parsed bare
6703        // Markdown here would be testing a document leaf never builds.
6704        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
6705        assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
6706        assert_eq!(
6707            role_of(&doc.vmap, 'r'),
6708            Role::Mark(Some(MarkColor::Red)),
6709            "the `data-color` twig stripped the emoji into"
6710        );
6711        assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
6712    }
6713
6714    #[test]
6715    fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
6716        // The colour is *spelling*: twig strips the emoji out of the mark's
6717        // content, so the reader sees the words and the wash, never the circle.
6718        // Drawing it would put a character in the rendered text that the author
6719        // wrote as syntax — the same mistake as drawing an emphasis's `*`.
6720        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
6721        let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6722        assert_eq!(drawn, "Plain yes and red ok");
6723    }
6724
6725    #[test]
6726    fn a_superscript_and_a_subscript_sit_off_the_baseline() {
6727        // Regression: both rendered flat, so the toolbar's superscript button
6728        // produced markup that looked exactly like the text around it.
6729        let m = map_djot("H~2~O and x^2^\n");
6730        assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
6731        assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
6732        assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
6733    }
6734
6735    #[test]
6736    fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
6737        // Why this is a `Baseline` and not a `Role`: raising a glyph says where
6738        // it sits, and must not cost it what it already was.
6739        let m = map_djot("# Heading x^2^\n");
6740        let two = m
6741            .rows
6742            .iter()
6743            .flat_map(|r| &r.glyphs)
6744            .find(|g| g.ch == '2')
6745            .unwrap();
6746        assert_eq!(two.style.baseline, Baseline::Super);
6747        assert_eq!(two.style.role, Role::Heading(1), "still heading text");
6748    }
6749
6750    #[test]
6751    fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
6752        let src = "see[^note] here\n";
6753        let m = map(src);
6754        // `[^note]` spans 3..10, its label `note` 5..9. The caret walks the
6755        // label; the brackets are drawn but never stood on, as a table's are,
6756        // and the `[^`/`]` bytes are stepped over like any hidden delimiter.
6757        let stops: Vec<usize> = m
6758            .rows
6759            .iter()
6760            .flat_map(|r| &r.glyphs)
6761            .filter(|g| g.stop)
6762            .map(|g| g.src)
6763            .collect();
6764        for off in 5..9 {
6765            assert!(
6766                stops.contains(&off),
6767                "label byte {off} isn't a caret stop: {stops:?}"
6768            );
6769        }
6770        for off in [3usize, 4, 9] {
6771            assert!(
6772                !stops.contains(&off),
6773                "delimiter byte {off} is a caret stop: {stops:?}"
6774            );
6775        }
6776    }
6777
6778    #[test]
6779    fn a_task_item_draws_its_box_where_the_bullet_would_be() {
6780        // Regression: the `[ ] ` is markup twig consumes — the item's paragraph
6781        // content starts past it — so a task item used to render as `• todo`,
6782        // identical to a plain bullet and with no way to see it was ticked.
6783        let m = map("- [ ] todo\n- [x] done\n- plain\n");
6784        assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
6785
6786        // The tick rides the item's first row, for a GUI that paints its own box.
6787        let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
6788        assert_eq!(ticks, [Some(false), Some(true), None]);
6789    }
6790
6791    #[test]
6792    fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
6793        let m = map_at(
6794            "- [x] a much longer task that has to wrap somewhere\n",
6795            Some(20),
6796        );
6797        assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
6798        assert_eq!(m.rows[0].task, Some(true));
6799        assert!(
6800            m.rows[1..].iter().all(|r| r.task.is_none()),
6801            "only the first row"
6802        );
6803        // The continuation lines hang under the box, not under column zero.
6804        assert!(
6805            rendered(&m)
6806                .lines()
6807                .nth(1)
6808                .is_some_and(|l| l.starts_with("  "))
6809        );
6810    }
6811
6812    #[test]
6813    fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
6814        // `task_checked` finds the box past the list marker; a plain item whose
6815        // text merely contains a bracket has none, and must keep its bullet.
6816        let m = map("- see [1] below\n");
6817        assert_eq!(rendered(&m), "• see [1] below");
6818        assert_eq!(m.rows[0].task, None);
6819    }
6820
6821    #[test]
6822    fn a_footnote_definition_renders_where_it_was_written() {
6823        // Regression: twig parses `[^1]: …` as a root *beside* `doc` — not a
6824        // child of it — so the walk from `doc` never reached one and every byte
6825        // of the note's body rendered as nothing at all.
6826        let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
6827        let m = map(src);
6828        let text = rendered(&m);
6829        assert!(
6830            text.contains("The note body."),
6831            "the note body is invisible: {text:?}"
6832        );
6833        // In source order — between the paragraph that cites it and the one
6834        // after — not hoisted to the end, and marked to match its reference.
6835        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
6836        assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
6837    }
6838
6839    #[test]
6840    fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
6841        let src = "x[^a].\n\n[^a]: body\n";
6842        let m = map(src);
6843        // `body` sits at 14..18. Its glyphs must map there — a marker that ate
6844        // the offsets would put the caret in the wrong place on every click.
6845        let body: Vec<(char, usize)> = m
6846            .rows
6847            .iter()
6848            .flat_map(|r| &r.glyphs)
6849            .filter(|g| g.stop && g.src >= 14)
6850            .map(|g| (g.ch, g.src))
6851            .collect();
6852        assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
6853    }
6854
6855    #[test]
6856    fn an_empty_footnote_definition_still_shows_its_marker() {
6857        // The instant `[^1]: ` has been typed and nothing after it. `blocks`
6858        // renders no child, so without the explicit marker row the definition
6859        // wouldn't appear at all until something was typed into it.
6860        let src = "x[^1]\n\n[^1]:\n";
6861        let m = map(src);
6862        assert!(
6863            rendered(&m).contains("[1] "),
6864            "no marker row: {:?}",
6865            rendered(&m)
6866        );
6867    }
6868
6869    #[test]
6870    fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
6871        let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
6872        let m = map_at(src, Some(24));
6873        let text = rendered(&m);
6874        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
6875        // Continuation lines hang under the marker, as a list item's do — the
6876        // indent is the marker's own width, not a fixed one.
6877        assert_eq!(lines[1].trim_end(), "[src] one two three four");
6878        assert!(
6879            lines[2].starts_with("      "),
6880            "body doesn't hang: {:?}",
6881            lines[2]
6882        );
6883        assert_eq!(lines[2].trim(), "five six seven");
6884    }
6885
6886    #[test]
6887    fn a_code_block_leaves_exactly_one_blank_row_below_it() {
6888        // The closing fence line used to be miscounted as a blank separator,
6889        // opening a phantom second gap under the block. One block boundary is
6890        // one blank row, code block or not.
6891        let src = "para\n\n```\ncode\n```\n\nafter\n";
6892        let m = map(src);
6893        let code_end = m.code_blocks[0].rows_span.end;
6894        let after = m
6895            .rows
6896            .iter()
6897            .position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
6898            .unwrap();
6899        assert_eq!(
6900            after - code_end,
6901            1,
6902            "exactly one row between code and 'after'"
6903        );
6904    }
6905
6906    #[test]
6907    fn a_fenced_block_publishes_its_language_on_its_code_block() {
6908        // The info string becomes the block's label; a bare fence and an indented
6909        // block carry none.
6910        assert_eq!(
6911            map("```rust\nlet x = 1;\n```\n").code_blocks[0]
6912                .lang
6913                .as_deref(),
6914            Some("rust")
6915        );
6916        assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
6917        assert_eq!(map("    indented\n").code_blocks[0].lang, None);
6918    }
6919
6920    /// The token every glyph spelling `ch` carries, in row order — how a test
6921    /// reads a block's highlighting off the map.
6922    fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
6923        m.rows
6924            .iter()
6925            .flat_map(|r| r.glyphs.iter())
6926            .filter(|g| g.ch == ch)
6927            .map(|g| g.style.token)
6928            .collect()
6929    }
6930
6931    #[cfg(feature = "syntax")]
6932    #[test]
6933    fn a_fenced_block_in_a_known_language_carries_tokens() {
6934        // `let` is a keyword, the string literal a string, and the plain
6935        // identifier `x` nothing at all — it draws in the code colour. Every
6936        // glyph is still `Role::Code`: a token is beside the role, not instead.
6937        let m = map("```rust\nlet x = \"s\";\n```\n");
6938        assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
6939        assert_eq!(tokens_of(&m, 'x'), vec![None]);
6940        assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
6941        assert!(
6942            m.rows
6943                .iter()
6944                .filter(|r| r.code)
6945                .flat_map(|r| r.glyphs.iter())
6946                .all(|g| g.style.role == Role::Code),
6947            "a token replaced the code role"
6948        );
6949    }
6950
6951    #[cfg(feature = "syntax")]
6952    #[test]
6953    fn a_token_changes_nothing_about_where_a_glyph_is() {
6954        // The same block with and without a language it can be highlighted in
6955        // lays out identically: same rows, same offsets, same stops. Only the
6956        // token differs, so the caret walks a highlighted block as it walked an
6957        // unhighlighted one.
6958        let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
6959        let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
6960        assert_eq!(hl.rows.len(), plain.rows.len());
6961        for (a, b) in hl.rows.iter().zip(&plain.rows) {
6962            assert_eq!(a.end_src, b.end_src);
6963            assert_eq!(a.glyphs.len(), b.glyphs.len());
6964            for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
6965                assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
6966                assert_eq!(ga.style.token(None), gb.style);
6967            }
6968        }
6969        assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
6970        assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
6971    }
6972
6973    #[test]
6974    fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
6975        // A bare fence, an indented block, a fence in a language no grammar
6976        // covers, and inline code all draw as plain code — and so does a
6977        // `rust` fence when the `syntax` feature is off.
6978        for src in [
6979            "```\nlet x = 1;\n```\n",
6980            "    let x = 1;\n",
6981            "```no-such-language\nlet x = 1;\n```\n",
6982            "a `let x` b\n",
6983        ] {
6984            assert!(
6985                tokens_of(&map(src), 'l').iter().all(Option::is_none),
6986                "{src:?} was highlighted"
6987            );
6988        }
6989        #[cfg(not(feature = "syntax"))]
6990        assert!(
6991            tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
6992                .iter()
6993                .all(Option::is_none)
6994        );
6995    }
6996
6997    #[test]
6998    fn inline_code_is_not_a_code_block() {
6999        // A `code` span inside prose is styled by role, not boxed: it's part of a
7000        // normal paragraph row, so it names no `code_blocks` entry.
7001        let m = map("a `snippet` b\n");
7002        assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
7003        assert!(
7004            m.rows.iter().all(|r| !r.code),
7005            "inline code flagged a code row"
7006        );
7007    }
7008
7009    #[test]
7010    fn caret_steps_over_hidden_delimiters() {
7011        // "a **bold** c": bytes 8,9 are the closing ** — no glyph. Moving right
7012        // from 'd' (src 7) lands on the space before 'c' (src 10), not inside **.
7013        let m = map("a **bold** c\n");
7014        let (r, c) = m.pos_of_offset(7);
7015        assert_eq!(m.offset_of_pos(r, c + 1), 10);
7016    }
7017
7018    // ── the structural view of a table ───────────────────────────────────────
7019
7020    #[test]
7021    fn a_table_is_published_structurally_beside_its_picture() {
7022        let m = map(TABLE);
7023        let t = &m.tables[0];
7024        let cell = |r: usize, c: usize| -> String {
7025            t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
7026        };
7027        assert_eq!(t.grid.len(), 3, "head + two body rows");
7028        assert_eq!(
7029            (cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
7030            ("Name".into(), "Qty".into(), "Pear".into(), "12".into())
7031        );
7032        assert_eq!(
7033            t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
7034            [true, false, false]
7035        );
7036        // The alignment the delimiter row spelled, carried per cell — the only
7037        // place it survives, since the parser consumes that row.
7038        assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
7039        assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
7040    }
7041
7042    #[test]
7043    fn a_block_media_is_published_structurally_beside_its_placeholder() {
7044        let m = map("intro\n\n![a cat](img/cat.png)\n\nend\n");
7045        assert_eq!(m.media.len(), 1, "one block image");
7046        let img = &m.media[0];
7047        assert_eq!(img.destination, "img/cat.png");
7048        assert_eq!(img.alt, "a cat");
7049        // The placeholder row named by `rows_span` carries the label a plain
7050        // surface paints and a capable frontend replaces.
7051        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7052        assert_eq!(
7053            img.rows_span.end - img.rows_span.start,
7054            1,
7055            "one placeholder row"
7056        );
7057        assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
7058        // The row carries the mark `media_spans` derives the side-table from.
7059        assert!(m.rows[img.rows_span.start].media.is_some());
7060    }
7061
7062    #[test]
7063    fn an_image_without_alt_labels_itself_with_its_filename() {
7064        let m = map("![](photos/beach.jpg)\n");
7065        let row = &m.rows[m.media[0].rows_span.start];
7066        assert_eq!(
7067            row.glyphs.iter().map(|g| g.ch).collect::<String>(),
7068            "🖼 beach.jpg"
7069        );
7070        assert_eq!(m.media[0].alt, "");
7071    }
7072
7073    #[test]
7074    fn an_empty_cells_home_is_read_from_either_shape_of_span() {
7075        // A whole-row span: the cell's pipes are the `col`-th and next.
7076        let row = "|  |  |";
7077        assert_eq!(empty_cell_offset(row, 10, 0), 12);
7078        assert_eq!(empty_cell_offset(row, 10, 1), 15);
7079        // A cell's own span, opening pipe to closing pipe exclusive: the same
7080        // homes, each read from its own span.
7081        assert_eq!(empty_cell_offset("|  ", 10, 0), 12);
7082        assert_eq!(empty_cell_offset("|  ", 13, 1), 15);
7083        // Nothing to stand in: just inside the pipe, never past the span.
7084        assert_eq!(empty_cell_offset("|", 10, 0), 11);
7085        assert_eq!(empty_cell_offset("", 10, 1), 10);
7086    }
7087
7088    #[test]
7089    fn a_hidden_marks_content_end_is_a_caret_home_but_not_a_glyph_stop() {
7090        // `a **bold** b`: the `d` is at 7, the content ends at 8, the closing
7091        // `**` draws nothing, and the space after it is at 10. Two homes at one
7092        // spot on screen: 8 (inside the bold) and 10 (past it).
7093        let m = map("a **bold** b\n");
7094        assert!(
7095            !m.stops.contains(&8),
7096            "8 has no glyph, so it is no glyph stop"
7097        );
7098        assert_eq!(m.mark_ends, vec![8]);
7099        assert!(m.is_stop(8), "but the caret may rest there");
7100        assert_eq!(m.snap_to_stop(8), 8, "and is left there when placed there");
7101        // Left/Right take both homes; the character-pairing walk takes one.
7102        assert_eq!(m.caret_stop_after(7), Some(8));
7103        assert_eq!(m.caret_stop_after(8), Some(10));
7104        assert_eq!(m.caret_stop_before(10), Some(8));
7105        assert_eq!(m.caret_stop_before(8), Some(7));
7106        assert_eq!(m.stop_after(7), Some(10));
7107        assert_eq!(m.stop_before(10), Some(7));
7108        // Drawn where the next glyph is: after the `d`, not on it.
7109        assert_eq!(m.pos_of_offset(8), m.pos_of_offset(10));
7110    }
7111
7112    #[test]
7113    fn every_hidden_inline_mark_gives_its_content_end_a_home() {
7114        // One end per mark, whatever it is spelled with; nested marks closing
7115        // together share the outer's end and the inner's alike.
7116        assert_eq!(
7117            map("*em* `code` [link](u) ~~del~~\n").mark_ends,
7118            vec![3, 10, 17, 27]
7119        );
7120        assert_eq!(map("***both***\n").mark_ends, vec![7]);
7121        // A mark that closes at its row's end coincides with the row's own end
7122        // stop — one offset, in both tables.
7123        let m = map("**bold**\n");
7124        assert_eq!(m.mark_ends, vec![6]);
7125        assert!(m.stops.contains(&6));
7126        // Revealed, the delimiter is glyphs of its own and the end is an
7127        // ordinary glyph stop: nothing to add.
7128        let mut ed = Editor::new_str("a **bold** b\n", Format::Markdown).unwrap();
7129        let src = "a **bold** b\n";
7130        let revealed = build(
7131            &ed.nodes().unwrap(),
7132            src,
7133            Some(80),
7134            false,
7135            &HashMap::new(),
7136            Some(0..src.len()),
7137        );
7138        assert!(revealed.mark_ends.is_empty());
7139        assert!(revealed.stops.contains(&8));
7140    }
7141
7142    #[test]
7143    fn a_marks_content_end_is_a_home_inside_a_table_cell() {
7144        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
7145        let m = map(src);
7146        let end = src.find("bold").unwrap() + 4; // 32, before the closing `**`
7147        assert_eq!(m.mark_ends, vec![end]);
7148        assert_eq!(m.snap_to_stop(end), end);
7149        // Drawn after the `d`, in this cell — where the cell's own end stop is.
7150        assert_eq!(m.pos_of_offset(end), m.pos_of_offset(end + 2));
7151    }
7152
7153    #[test]
7154    fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
7155        // `![x](y)` on its own line: the caret can rest in front of the image
7156        // (its start) and just past it (the row end), and nowhere inside the
7157        // markup — the same coarse mapping a thematic break uses.
7158        let src = "![x](y.png)\n";
7159        let m = map(src);
7160        let img = &m.rows[m.media[0].rows_span.start];
7161        let start = 0; // the image opens the document
7162        let end = "![x](y.png)".len();
7163        // Every placeholder glyph maps to the image start and is a stop there.
7164        assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
7165        assert_eq!(img.end_src, end, "the row ends past the image");
7166        assert_eq!(m.stops.first(), Some(&start));
7167        assert!(m.stops.contains(&end), "a stop sits after the image");
7168        // Nothing inside the markup is a stop.
7169        assert!(!m.stops.iter().any(|&s| s > start && s < end));
7170    }
7171
7172    #[test]
7173    fn an_inline_image_amid_text_is_not_a_block_media() {
7174        // An image sharing its line with prose isn't block-level: it stays in the
7175        // inline path (rendered as its alt text), and publishes no MediaInfo.
7176        let m = map("see ![a cat](cat.png) here\n");
7177        assert!(m.media.is_empty(), "not a block image");
7178        assert!(
7179            rendered(&m).contains("a cat"),
7180            "alt text still renders inline"
7181        );
7182    }
7183
7184    /// The block images `Doc` publishes for `src`, driven through the real
7185    /// production build (`build_visual` → `build_cached`) with `html_elements`
7186    /// on — the path a `<picture>` actually travels. Not the raw `build` the
7187    /// other tests use: the editor's flat whole-arena snapshot tangles the links
7188    /// of inline-promoted HTML (phantom roots, dangling `parent`s), which only
7189    /// the per-block subtree walk `build_cached` does untangles.
7190    fn doc_media(src: &str) -> Vec<MediaInfo> {
7191        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7192        doc.build_visual(80);
7193        doc.vmap.media.clone()
7194    }
7195
7196    #[test]
7197    fn a_video_block_is_media_with_its_src_poster_and_kind() {
7198        // The load-bearing assumption of video support: twig has no `video` node
7199        // kind, so `html_elements` promotion must land a `<video>` as a generic
7200        // `element` whose tag name and attributes survive onto `FlatNode` — the
7201        // same treatment `<picture>` gets. If that ever stops holding, this is
7202        // the test that says so.
7203        let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
7204        assert_eq!(m.len(), 1, "the video is one block media");
7205        assert_eq!(m[0].kind, MediaKind::Video);
7206        assert_eq!(m[0].destination, "clip.mp4");
7207        assert_eq!(m[0].poster, "still.png");
7208    }
7209
7210    #[test]
7211    fn a_single_line_video_is_a_block_too() {
7212        // The spelling everyone actually writes. It used to parse as a paragraph
7213        // of raw inline HTML — CommonMark opens a block on a complete tag only
7214        // when the line ends there, and its fixed tag list predates `<video>` —
7215        // so the tags never reached core as an element at all. twig 2.5.1 widened
7216        // that list under `html_elements`; this is the test that would catch the
7217        // pin sliding back.
7218        let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
7219        assert_eq!(m.len(), 1, "single-line <video> is a block");
7220        assert_eq!(m[0].kind, MediaKind::Video);
7221        assert_eq!(m[0].destination, "clip.mp4");
7222    }
7223
7224    #[test]
7225    fn a_single_line_picture_is_a_block_with_its_alternatives() {
7226        // `<picture>` had the identical gap and it went unnoticed because the
7227        // conventional spelling breaks the lines. Same twig fix covers it.
7228        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
7229                   <img src=\"l.svg\" alt=\"banner\"></picture>\n";
7230        let m = doc_media(src);
7231        assert_eq!(m.len(), 1);
7232        assert_eq!(m[0].kind, MediaKind::Image);
7233        assert_eq!(m[0].destination, "l.svg");
7234        assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
7235    }
7236
7237    #[test]
7238    fn an_audio_block_is_media_with_no_poster() {
7239        let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
7240        assert_eq!(m.len(), 1);
7241        assert_eq!(m[0].kind, MediaKind::Audio);
7242        assert_eq!(m[0].destination, "take.mp3");
7243        assert!(m[0].poster.is_empty(), "audio has no poster frame");
7244    }
7245
7246    #[test]
7247    fn a_videos_source_children_are_its_candidates_typed_by_mime() {
7248        // A `<video>` with no `src` of its own — the common shape, since it's how
7249        // you offer more than one codec. The candidates come from `<source src>`
7250        // (not `srcset`, which is `<picture>`'s spelling) and carry their MIME.
7251        let src = "<video controls>\n\
7252                   <source src=\"a.webm\" type=\"video/webm\">\n\
7253                   <source src=\"a.mp4\" type=\"video/mp4\">\n\
7254                   fallback\n\
7255                   </video>\n";
7256        let m = doc_media(src);
7257        assert_eq!(m.len(), 1);
7258        assert!(
7259            m[0].destination.is_empty(),
7260            "no src attribute on the element"
7261        );
7262        assert_eq!(m[0].sources.len(), 2);
7263        assert_eq!(m[0].sources[0].srcset, "a.webm");
7264        assert_eq!(m[0].sources[0].mime, "video/webm");
7265        assert_eq!(m[0].sources[1].srcset, "a.mp4");
7266        // With an empty destination, `resolve` falls through to the first
7267        // candidate rather than handing the frontend nothing to load.
7268        assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
7269    }
7270
7271    #[test]
7272    fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
7273        // The placeholder contract images already hold, now for a video: the row
7274        // renders as a labelled stand-in a plain surface can paint as-is, and
7275        // carries the mark a capable frontend replaces it from.
7276        let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
7277        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7278        doc.build_visual(80);
7279        let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
7280        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7281        assert!(
7282            text.starts_with('🎬'),
7283            "video sigil, not the image one: {text:?}"
7284        );
7285        assert!(row.media.is_some(), "the mark rides the placeholder row");
7286    }
7287
7288    #[test]
7289    fn a_picture_block_carries_its_source_alternatives() {
7290        // A `<picture>` with a dark-mode `<source>`: one block image, whose
7291        // fallback destination is the `<img>` and whose `sources` carry the
7292        // `<source>`'s media + srcset for a theme-aware frontend to pick.
7293        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
7294        let images = doc_media(src);
7295        assert_eq!(images.len(), 1, "the picture is one block image");
7296        let img = &images[0];
7297        assert_eq!(img.destination, "light.svg", "fallback is the <img>");
7298        assert_eq!(img.alt, "banner");
7299        assert_eq!(
7300            img.sources,
7301            vec![MediaSource {
7302                media: "(prefers-color-scheme: dark)".into(),
7303                srcset: "dark.svg".into(),
7304                mime: String::new(),
7305            }],
7306        );
7307    }
7308
7309    #[test]
7310    fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
7311        // fig.md's shape: the banner is an `<h1>` wrapping the `<picture>`.
7312        let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
7313        let images = doc_media(src);
7314        assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
7315        assert_eq!(images[0].destination, "l.svg");
7316        assert_eq!(images[0].sources.len(), 1);
7317        assert_eq!(images[0].sources[0].srcset, "d.svg");
7318    }
7319
7320    #[test]
7321    fn a_plain_image_has_no_media_sources() {
7322        // A bare Markdown image carries an empty `sources` — nothing to pick from.
7323        let images = doc_media("![alt](p.png)\n");
7324        assert_eq!(images.len(), 1);
7325        assert!(
7326            images[0].sources.is_empty(),
7327            "no <picture>, no alternatives"
7328        );
7329    }
7330
7331    #[test]
7332    fn resolve_picks_the_source_matching_the_scheme() {
7333        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
7334        let images = doc_media(src);
7335        let img = &images[0];
7336        // Dark theme takes the dark source; light falls through to the <img>.
7337        assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
7338        assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
7339    }
7340
7341    #[test]
7342    fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
7343        // A plain image ignores the scheme.
7344        let plain = doc_media("![a](p.png)\n");
7345        assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
7346
7347        // A <source> with an unrecognized media query is skipped; a light source
7348        // is taken under a light theme.
7349        let m = doc_media(
7350            "<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
7351        );
7352        assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
7353        assert_eq!(
7354            m[0].resolve(ColorScheme::Dark),
7355            "f.svg",
7356            "no dark source → <img>"
7357        );
7358    }
7359
7360    #[test]
7361    fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
7362        // A comma/descriptor srcset resolves to its first URL.
7363        assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
7364        assert_eq!(first_srcset_url("  solo.svg  "), Some("solo.svg"));
7365        assert_eq!(first_srcset_url(""), None);
7366        // An empty (unconditional) media always matches.
7367        assert!(media_matches("", ColorScheme::Light));
7368        assert!(media_matches(
7369            "(prefers-color-scheme:dark)",
7370            ColorScheme::Dark
7371        ));
7372        assert!(!media_matches(
7373            "(prefers-color-scheme: dark)",
7374            ColorScheme::Light
7375        ));
7376    }
7377
7378    #[test]
7379    fn a_block_media_carries_its_list_prefix() {
7380        // An image that is a list item's body opens past the bullet, like every
7381        // other block does.
7382        let m = map("- ![alt](p.png)\n");
7383        let row = &m.rows[m.media[0].rows_span.start];
7384        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7385        assert!(
7386            text.starts_with("• "),
7387            "the list marker prefixes the image row: {text:?}"
7388        );
7389        assert!(text.contains("🖼 alt"));
7390    }
7391
7392    #[test]
7393    fn the_structural_table_spans_exactly_its_drawn_rows() {
7394        // A frontend drawing its own grid skips `rows_span` and renders from
7395        // `grid`. If the span were short the leftover border rows would be
7396        // painted as text under the real table; if long it would eat a
7397        // neighbouring paragraph. Both are silent, so pin it to the picture.
7398        let m = map(&format!("before\n\n{TABLE}\nafter\n"));
7399        let t = &m.tables[0];
7400        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7401        assert!(
7402            row_text(t.rows_span.start).starts_with('┌'),
7403            "opens on the top border"
7404        );
7405        assert!(
7406            row_text(t.rows_span.end - 1).starts_with('└'),
7407            "closes on the bottom border"
7408        );
7409        assert!(
7410            !row_text(t.rows_span.start - 1).contains('┌'),
7411            "the row before the span is not the table's"
7412        );
7413        assert_eq!(
7414            row_text(t.rows_span.end),
7415            "",
7416            "the span ends before the gap row"
7417        );
7418    }
7419
7420    #[test]
7421    fn a_nested_tables_structure_carries_the_block_prefix() {
7422        // The picture puts the quote's gutter on every row of the grid. A
7423        // frontend drawing its own table has to draw that too and start past it,
7424        // so the prefix has to travel with the structure — without it a quoted
7425        // table renders flush at the margin and leaves the quote it's in.
7426        let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
7427        let t = &m.tables[0];
7428        let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
7429        assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
7430        // And it matches what the picture actually drew.
7431        let drawn: String = m.rows[t.rows_span.start]
7432            .glyphs
7433            .iter()
7434            .map(|g| g.ch)
7435            .collect();
7436        assert!(
7437            drawn.starts_with(&prefix),
7438            "picture and structure disagree: {drawn:?}"
7439        );
7440    }
7441
7442    #[test]
7443    fn a_top_level_table_carries_no_prefix() {
7444        assert!(map(TABLE).tables[0].prefix.is_empty());
7445    }
7446
7447    #[test]
7448    fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
7449        // The picture wraps a cell to its column; a frontend laying the grid out
7450        // in pixels needs the text as the document spells it, before that
7451        // decision. Narrow enough that the drawn cell must break.
7452        let src = "| Name |\n|------|\n| alpha beta gamma |\n";
7453        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7454        let m = build_t(&ed.nodes().unwrap(), src, Some(12));
7455        let drawn = rendered(&m);
7456        let cell: String = m.tables[0].grid[1].cells[0]
7457            .glyphs
7458            .iter()
7459            .map(|g| g.ch)
7460            .collect();
7461        assert_eq!(
7462            cell, "alpha beta gamma",
7463            "structure must not carry the wrap"
7464        );
7465        assert!(
7466            drawn.lines().count() > 5,
7467            "the picture should have wrapped, else this proves nothing:\n{drawn}"
7468        );
7469    }
7470
7471    // ── display columns ──────────────────────────────────────────────────────
7472
7473    #[test]
7474    fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
7475        // A column sized by counting characters is drawn narrower than the text
7476        // it has to hold — `你好` is two characters in four cells — and the cell
7477        // spills over the border it is supposed to sit inside, taking the whole
7478        // grid out of square with it. Squareness is the property: every row of a
7479        // grid is drawn to the same column, whatever its cells are spelled with.
7480        for src in [
7481            "| A | B |\n|---|---|\n| 你好 | y |\n",
7482            "| A | B |\n|---|---|\n| a👨‍👩‍👧b | y |\n",
7483            "| A | 漢字 |\n|---|---|\n| x | y |\n",
7484        ] {
7485            let m = map(src);
7486            let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
7487            assert!(
7488                widths.windows(2).all(|w| w[0] == w[1]),
7489                "ragged grid {widths:?} for {src:?}:\n{}",
7490                rendered(&m)
7491            );
7492        }
7493    }
7494
7495    #[test]
7496    fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
7497        // A column too narrow for its cell hard-breaks the text, and every line
7498        // of it is given an end stop just past its last glyph. Broken into runs
7499        // of four glyphs, the first line of this cell ends between `👨‍👩` and the
7500        // joiner holding `👧` on — so its end stop lands inside a character,
7501        // where a click or Down can reach it and the next Backspace takes the
7502        // cluster apart from the middle.
7503        let src = "| A |\n|---|\n| 👨‍👩‍👧👨‍👩‍👧 |\n";
7504        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7505        let m = build_t(&ed.nodes().unwrap(), src, Some(8));
7506        let boundaries: Vec<usize> = src
7507            .grapheme_indices(true)
7508            .map(|(i, _)| i)
7509            .chain(std::iter::once(src.len()))
7510            .collect();
7511        for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
7512            assert!(
7513                boundaries.contains(&off),
7514                "stop at {off} is inside a character:\n{}",
7515                rendered(&m)
7516            );
7517        }
7518    }
7519
7520    #[test]
7521    fn a_wrapped_cell_keeps_every_line_inside_its_column() {
7522        // The width is a promise in a table, where a glyph past the column lands
7523        // on the border or in the next cell — and it is a promise about cells,
7524        // which is not what a count of glyphs measures.
7525        let src = "| A |\n|---|\n| 你好世界漢字 |\n";
7526        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7527        let m = build_t(&ed.nodes().unwrap(), src, Some(14));
7528        for r in &m.rows {
7529            assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
7530        }
7531    }
7532
7533    #[test]
7534    fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
7535        let glyphs = |s: &str| {
7536            let mut out = Vec::new();
7537            push_text(&mut out, s, 0, Style::default());
7538            out
7539        };
7540        let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
7541
7542        // Six cells of CJK broken at four: two characters, then one — never
7543        // between the two cells of `好`.
7544        let w = glyphs("你好世");
7545        let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
7546        assert_eq!(pieces, ["你好", "世"]);
7547
7548        // A character wider than the column has nowhere legal to break, so it
7549        // keeps its cells rather than being cut in half.
7550        let w = glyphs("你好");
7551        let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
7552        assert_eq!(pieces, ["你", "好"]);
7553
7554        // An empty word yields no pieces at all — a double space stays a space.
7555        assert!(hard_break(&[], 4).is_empty());
7556    }
7557
7558    #[test]
7559    fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
7560        // Pressing Enter at the end of a list item opens a new, empty item —
7561        // a childless `list_item`. Without a row of its own the new bullet
7562        // wouldn't appear until something was typed into it (the caret would be
7563        // stranded on an offset no row draws). It now renders as one prefixed
7564        // row whose end is a caret stop, so the bullet shows and the caret lands
7565        // just past the marker.
7566        let m = map("- item\n- \n");
7567        assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
7568        assert_eq!(
7569            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7570            "• ",
7571            "the empty item draws just its bullet",
7572        );
7573        // Its end is the caret home (past the `- ` marker), and it's a real stop.
7574        assert!(
7575            m.is_stop(m.rows[1].end_src),
7576            "the empty item's caret home is not a stop"
7577        );
7578        assert_eq!(
7579            m.pos_of_offset(m.rows[1].end_src),
7580            (1, 2),
7581            "caret sits after '• '"
7582        );
7583    }
7584
7585    #[test]
7586    fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
7587        // The peek bug: a note whose body ends in a link has its last byte
7588        // inside the hidden destination, so mapping `end - 1` through
7589        // `pos_of_offset` snapped *forward* — past its own row, past the drawn
7590        // gap, and onto the next note's row. The popover then drew both notes.
7591        let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
7592        let m = map(src);
7593        let body = src.find("[title]").unwrap();
7594        let end = src.find("\n\n[^3]").unwrap();
7595
7596        let (first, last) = m.row_range_for(body..end);
7597        assert_eq!(
7598            first, last,
7599            "a one-block note is one row, not a span onto the next"
7600        );
7601
7602        // The old arithmetic, kept here as the thing that must stay wrong: it
7603        // is what this method exists instead of.
7604        assert_ne!(
7605            m.pos_of_offset(end - 1).0,
7606            last,
7607            "the forward snap still leaves the note's row — that is the whole point",
7608        );
7609
7610        // A note ending in *visible* text was never broken, and still isn't:
7611        // both readings agree there, which is why the original test missed it.
7612        let plain = src.find("bare text").unwrap();
7613        let plain_end = src.find("\n\n[^2]").unwrap();
7614        let (pf, pl) = m.row_range_for(plain..plain_end);
7615        assert_eq!(pf, pl);
7616        assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
7617    }
7618
7619    #[test]
7620    fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
7621        // The range is a span, not a point: a quote of two paragraphs covers its
7622        // gap row and both of its text rows, so a peek draws the whole thing.
7623        let src = "> one\n>\n> two\n\nafter\n";
7624        let m = map(src);
7625        let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
7626        assert_eq!((first, last), (0, 2));
7627
7628        // And a range with no visible byte at all still covers the row it opened
7629        // on, rather than collapsing to nothing.
7630        let (f, l) = m.row_range_for(0..1);
7631        assert_eq!((f, l), (0, 0));
7632    }
7633
7634    #[test]
7635    fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
7636        // The peer of the empty list item, and the case that made an empty line
7637        // in a quote draw as plain body text: a childless `block_quote` — a bare
7638        // `> `, which is what the toolbar's Quote button leaves on a blank line —
7639        // has no inner block to carry the gutter, so the whole quote used to
7640        // render as *nothing*. It didn't merely lose its bar; the row went away
7641        // and the caret had no home on it.
7642        let m = map("a\n\n> \n\nb\n");
7643        assert_eq!(
7644            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
7645            "│ ",
7646            "the empty quote draws just its gutter",
7647        );
7648        assert!(
7649            m.rows[2]
7650                .glyphs
7651                .iter()
7652                .all(|g| g.style.role == Role::QuoteGutter)
7653        );
7654        assert!(
7655            !m.rows[2].decoration,
7656            "it is a line text can go on, not a drawn gap"
7657        );
7658        assert!(
7659            m.is_stop(m.rows[2].end_src),
7660            "the empty quote's caret home is not a stop"
7661        );
7662        assert_eq!(
7663            m.pos_of_offset(m.rows[2].end_src),
7664            (2, 2),
7665            "caret sits after '│ '"
7666        );
7667
7668        // And a document that is *only* an empty quote still renders a row — it
7669        // used to render none at all, leaving the caret nowhere to stand.
7670        let m = map("> \n");
7671        assert_eq!(m.num_rows(), 1);
7672        assert_eq!(
7673            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7674            "│ "
7675        );
7676    }
7677
7678    #[test]
7679    fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
7680        // Enter at the end of `> a` writes `> a\n>\n> \n`. Those last two lines
7681        // hold no block — a quote's `content_span` stops at its last child — so
7682        // the children walk never reaches them, and they used to fall through to
7683        // the document-level trailing pass, which knows no prefix: the gutter
7684        // stopped and the writer's new line drew as plain prose. Fixable only
7685        // since twig 3.2.0, where the quote's *span* covers its own marker lines
7686        // (`0..3` before, `0..8` now) and there is finally a node saying they
7687        // are the quote's.
7688        let m = map("> a\n>\n> \n");
7689        assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
7690        for (i, row) in m.rows.iter().enumerate() {
7691            let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
7692            assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
7693            assert!(
7694                !row.decoration,
7695                "row {i} is a line to type on, not a drawn gap"
7696            );
7697            assert!(m.is_stop(row.end_src), "row {i} has no caret home");
7698        }
7699        assert_eq!(
7700            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7701            "│ a"
7702        );
7703        // Distinct offsets, so ↑/↓ between them moves the caret rather than
7704        // landing twice on the same byte.
7705        assert!(m.rows[0].end_src < m.rows[1].end_src);
7706        assert!(m.rows[1].end_src < m.rows[2].end_src);
7707
7708        // A blank line *after* the quote is not the quote's: it is spelled with
7709        // no marker, so it stays an ordinary boundary and the gutter ends.
7710        let m = map("> a\n\nb\n");
7711        assert_eq!(m.num_rows(), 3);
7712        assert_eq!(
7713            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
7714            "b"
7715        );
7716        assert!(
7717            !m.rows[1]
7718                .glyphs
7719                .iter()
7720                .any(|g| g.style.role == Role::QuoteGutter)
7721        );
7722
7723        // Nesting is the case this could get wrong, and the depth has to come
7724        // from which quote's span the line falls in rather than from the row
7725        // above it. A trailing `>` under `> > a` matches only the OUTER quote,
7726        // so it wears one gutter; spell it `> >` and it wears two.
7727        let m = map("> > a\n>\n");
7728        assert_eq!(
7729            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7730            "│ │ a"
7731        );
7732        assert_eq!(
7733            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7734            "│ "
7735        );
7736        let m = map("> > a\n> >\n");
7737        assert_eq!(
7738            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7739            "│ │ "
7740        );
7741
7742        // And a marker line BETWEEN two quoted paragraphs is untouched: that is
7743        // the boundary `emit_separators_before` spells, and it stays a drawn gap
7744        // rather than becoming a line to type on.
7745        let m = map("> a\n>\n> b\n");
7746        assert_eq!(m.num_rows(), 3);
7747        assert!(
7748            m.rows[1].decoration,
7749            "the gap between two quoted blocks is still a gap"
7750        );
7751    }
7752
7753    #[test]
7754    fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
7755        let m = map("1. item\n2. \n");
7756        assert_eq!(m.num_rows(), 2);
7757        assert_eq!(
7758            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7759            "2. "
7760        );
7761        assert!(m.is_stop(m.rows[1].end_src));
7762        assert_eq!(
7763            m.pos_of_offset(m.rows[1].end_src),
7764            (1, 3),
7765            "caret sits after '2. '"
7766        );
7767    }
7768
7769    #[test]
7770    fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
7771        // The toolbar's H1 on a blank line writes `# ` and nothing else. The row
7772        // it renders is empty (the marker is hidden), so its end *is* its only
7773        // caret stop — and it has to be the offset past the `# `, where typing
7774        // continues the heading. Anchored at the block's start instead, the caret
7775        // drew in front of the hashes and the first character typed there landed
7776        // before them (`x# `), which isn't a heading at all.
7777        let m = map("# \n");
7778        assert_eq!(m.num_rows(), 1);
7779        assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
7780        assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
7781        assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
7782    }
7783
7784    #[test]
7785    fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
7786        // The row-level fact a proportional frontend sizes a whole line by. An
7787        // empty heading has no glyph to read a `Role::Heading` off, so a renderer
7788        // scanning glyphs drew `# ` (and its caret) at body height until the
7789        // first character landed.
7790        let m = map("# \n");
7791        assert_eq!(
7792            m.rows[0].heading,
7793            Some(1),
7794            "the empty heading knows its level"
7795        );
7796
7797        // Every row of one that wraps, not just the first — and nothing else.
7798        let m = map_at(
7799            "## a heading long enough to wrap over two rows\n\nbody\n",
7800            Some(20),
7801        );
7802        let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
7803        assert!(
7804            heads.iter().filter(|h| **h == Some(2)).count() >= 2,
7805            "got {heads:?}"
7806        );
7807        assert_eq!(
7808            m.rows.last().and_then(|r| r.heading),
7809            None,
7810            "the paragraph under it is not a heading",
7811        );
7812    }
7813
7814    #[test]
7815    fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
7816        // The row's end is also what the *next* row's separator is measured from,
7817        // so an empty heading that under-reported it shifted every offset below —
7818        // and the blank line under the heading then claimed the same offset as the
7819        // heading's own end. `pos_of_offset` resolves such a tie downstream (a
7820        // soft wrap belongs to the row below), so the caret at the end of the
7821        // heading was drawn two rows lower, on the blank line.
7822        // `text\n\n# \n\n`: the heading's content opens at 8, and the two rows
7823        // under it end at 9 and 10 — the blank line and the document's end.
7824        let m = map("text\n\n# \n\n");
7825        let end = m.rows.last().expect("a trailing blank row").end_src;
7826        assert_eq!(end, 10, "the trailing rows must end at their real offsets");
7827        // The heading's caret home is its own row's, not one shared with a row
7828        // below — the tie that drew the caret two rows down.
7829        assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
7830        assert!(
7831            m.rows[3..].iter().all(|r| r.end_src > 8),
7832            "rows below own later offsets"
7833        );
7834    }
7835
7836    // ── block boundaries ─────────────────────────────────────────────────────
7837
7838    /// Every drawn boundary in `src`, in order, as `(above, below)`.
7839    fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
7840        m.rows
7841            .iter()
7842            .filter_map(|r| r.boundary)
7843            .map(|b| (b.above, b.below))
7844            .collect()
7845    }
7846
7847    #[test]
7848    fn a_boundary_says_which_blocks_it_divides() {
7849        use BlockClass::*;
7850        let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
7851        assert_eq!(
7852            boundaries(&m),
7853            vec![
7854                (Paragraph, Paragraph),
7855                (Paragraph, Heading),
7856                (Heading, Paragraph),
7857                (Paragraph, Quote),
7858                (Quote, Code),
7859                // The blank the document trails off with is a boundary too — it
7860                // closes the last block above the empty paragraph the caret rests
7861                // on. See `emit_trailing_blank_lines`.
7862                (Code, Paragraph),
7863            ],
7864            "each gap names the pair it falls between, in document order"
7865        );
7866    }
7867
7868    // ── hidden blocks ────────────────────────────────────────────────────────
7869
7870    /// The row texts of `m`, one string per row.
7871    fn row_texts(m: &VisualMap) -> Vec<String> {
7872        m.rows
7873            .iter()
7874            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7875            .collect()
7876    }
7877
7878    #[test]
7879    fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
7880        // `<!-- exec -->` is a top-level block that draws no rows. The blocks
7881        // either side of it meet across the one boundary a paragraph and a code
7882        // block always meet across — not that boundary *plus* one blank row per
7883        // line of the comment, which is what counting the separator from the
7884        // paragraph's end used to spell.
7885        let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
7886        assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
7887        assert_eq!(
7888            boundaries(&m),
7889            vec![
7890                (BlockClass::Paragraph, BlockClass::Code),
7891                (BlockClass::Code, BlockClass::Paragraph),
7892            ],
7893            "the boundary names the drawn blocks either side, not the comment"
7894        );
7895        // The gap stands past the comment, so the caret's row lookup never
7896        // resolves inside it.
7897        assert_eq!(
7898            m.rows[1].end_src, 23,
7899            "the gap row ends at the comment's end"
7900        );
7901    }
7902
7903    #[test]
7904    fn a_comment_opening_the_document_draws_no_leading_gap() {
7905        let m = map("<!-- lead -->\n\npara\n");
7906        assert_eq!(row_texts(&m), ["para"]);
7907        assert_eq!(m.content_start, 0, "the comment is still the first block");
7908    }
7909
7910    #[test]
7911    fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
7912        // Its lines are not blank lines the author opened with Enter, so no
7913        // gap-plus-empty-paragraph is fabricated under the last drawn block.
7914        let m = map("para\n\n<!-- trail -->\n");
7915        assert_eq!(row_texts(&m), ["para"]);
7916        // Enter at the end of the document still opens the empty paragraph the
7917        // caret rests on: the newlines *after* the comment count as they would
7918        // after any block.
7919        let m = map("para\n\n<!-- trail -->\n\n");
7920        assert_eq!(row_texts(&m), ["para", "", ""]);
7921    }
7922
7923    #[test]
7924    fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
7925        // The first *drawn* child wears the item's marker; a hidden first child
7926        // would otherwise take it and leave the text without one.
7927        let m = map("- <!-- note -->\n\n  text\n- two\n");
7928        let texts = row_texts(&m);
7929        assert!(
7930            texts.iter().any(|t| t == "• text"),
7931            "the text wears the bullet: {texts:?}"
7932        );
7933        assert!(
7934            !texts.iter().any(|t| t == "• "),
7935            "no empty bullet row for the comment: {texts:?}"
7936        );
7937    }
7938
7939    #[test]
7940    fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
7941        // The bug as seen: a 200-line document with one comment in it rendered
7942        // ~200 blank rows after the comment, one per source line, because the
7943        // comment's per-block builder handed back a `last_off` of 0. Parity with
7944        // `build` alone would not catch a *shared* wrong answer, so the count is
7945        // pinned outright.
7946        let body = (0..200)
7947            .map(|i| format!("line {i}"))
7948            .collect::<Vec<_>>()
7949            .join("\n\n");
7950        let src = format!("intro\n\n<!-- exec -->\n{body}\n");
7951        let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
7952        let mut cache = BlockCache::default();
7953        let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
7954        assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
7955        // intro, then 200 × (gap, paragraph): 401 rows and not a row more.
7956        assert_eq!(cached.rows.len(), 401);
7957    }
7958
7959    #[test]
7960    fn a_link_reference_definition_is_stepped_over_like_a_comment() {
7961        // `[a]: /a` is a root beside `doc` with no rows of its own. Merged into
7962        // the walk it is a hidden block: the blocks either side meet across one
7963        // boundary, and its line is not a blank row.
7964        let m = map("see [a]\n\n[a]: /a\n\nafter\n");
7965        assert_eq!(row_texts(&m), ["see a", "", "after"]);
7966        assert_eq!(
7967            boundaries(&m),
7968            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
7969        );
7970    }
7971
7972    #[test]
7973    fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
7974        // The README shape: prose, then a `[links]` block nobody reads. Its
7975        // lines used to be counted as blank ones, an empty paragraph per
7976        // definition under the last real block.
7977        let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
7978        assert_eq!(row_texts(&m), ["see a and b"]);
7979    }
7980
7981    #[test]
7982    fn a_definition_glued_under_a_paragraph_stays_inside_it() {
7983        // `[a]: /a` at the front of a paragraph's lines is stripped from the
7984        // paragraph's text, but the paragraph's span still starts on its line.
7985        // Both blocks start at the same offset; the definition, sorted first,
7986        // is stepped over, and the paragraph draws as it always did — one gap
7987        // above it, none inside.
7988        let m = map("intro\n\n[a]: /a\ntext [a]\n");
7989        assert_eq!(row_texts(&m), ["intro", "", "text a"]);
7990    }
7991
7992    #[test]
7993    fn a_definition_with_no_span_is_left_out_of_the_walk() {
7994        // twig before 3.3.3 reported `0..0` for every link reference
7995        // definition. One of those has nowhere to be merged: sorted first by
7996        // its zero start it would open the document with a phantom block, and
7997        // the walk would step back to offset 0. It is simply not a block. A
7998        // footnote definition is always placed; it has a body to draw.
7999        assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
8000        assert!(is_placed_definition(&Kind::Reference, &(7..14)));
8001        assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
8002        assert!(!is_placed_definition(&Kind::Str, &(7..14)));
8003    }
8004
8005    #[test]
8006    fn the_trailing_gap_closes_the_last_block() {
8007        // Two Enters at the end of a document: a drawn gap, then the navigable
8008        // empty paragraph. Only the gap is labelled, so a frontend that shrinks
8009        // boundaries shrinks the spacer and leaves the row being typed on alone.
8010        let m = map("# Head\n\n\n");
8011        assert_eq!(
8012            boundaries(&m),
8013            vec![(BlockClass::Heading, BlockClass::Paragraph)]
8014        );
8015    }
8016
8017    #[test]
8018    fn only_the_drawn_gap_rows_carry_a_boundary() {
8019        let m = map("one\n\ntwo\n");
8020        for row in &m.rows {
8021            assert_eq!(
8022                row.boundary.is_some(),
8023                row.decoration,
8024                "a boundary is exactly a drawn gap row: {:?}",
8025                row.glyphs.iter().map(|g| g.ch).collect::<String>()
8026            );
8027        }
8028    }
8029
8030    #[test]
8031    fn preserve_flow_labels_no_boundary() {
8032        // Every blank line is a caret home there — somewhere text can go, not a
8033        // gap between blocks — so nothing is drawn-only and nothing is labelled.
8034        // A frontend keying its spacing off `boundary` can't shrink a row the
8035        // author is about to type on.
8036        let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
8037        assert!(boundaries(&m).is_empty());
8038    }
8039
8040    #[test]
8041    fn a_list_draws_no_boundary_between_its_items() {
8042        // Tight or loose, core puts no gap row between two items of one list —
8043        // so an item↔item boundary is a shape no frontend will ever be handed,
8044        // and spacing one is spacing something that isn't there.
8045        for src in ["- one\n- two\n", "- one\n\n- two\n"] {
8046            let m = map(src);
8047            assert!(
8048                boundaries(&m).is_empty(),
8049                "no gap row inside the list of {src:?}"
8050            );
8051        }
8052        // Leaving the list is an ordinary boundary, and the list is named as
8053        // what sits above it.
8054        let m = map("- one\n- two\n\npara\n");
8055        assert_eq!(
8056            boundaries(&m),
8057            vec![(BlockClass::List, BlockClass::Paragraph)]
8058        );
8059    }
8060
8061    #[test]
8062    fn a_nested_boundary_names_the_blocks_inside_the_container() {
8063        // Two paragraphs inside a blockquote are divided by a Paragraph↔Paragraph
8064        // boundary — the quote is the container they're both in, not what the gap
8065        // separates.
8066        let m = map("> one\n>\n> two\n");
8067        assert_eq!(
8068            boundaries(&m),
8069            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
8070        );
8071    }
8072
8073    #[test]
8074    fn a_directive_container_draws_one_boundary_like_every_other_block() {
8075        // A container's rows stop at its last *child*, so without anchoring
8076        // `last_off` past the closing `:::` the separator logic counted the fence
8077        // line as a blank row of its own and drew the gap twice — one authored
8078        // blank line, two boundaries, and a frontend spacing each of them put
8079        // double margin under every fenced div. The code-block arm anchors past
8080        // its ``` for exactly this reason; compare the two here.
8081        let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
8082        assert_eq!(
8083            boundaries(&fenced),
8084            vec![(BlockClass::Directive, BlockClass::Paragraph)],
8085            "one authored gap, one boundary row"
8086        );
8087        let code = map("```\nc\n```\n\ntwo\n");
8088        assert_eq!(
8089            boundaries(&code).len(),
8090            boundaries(&fenced).len(),
8091            "a fenced div spaces like a fenced code block"
8092        );
8093        // Nesting closes several fences at once; still one gap.
8094        let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
8095        assert_eq!(
8096            boundaries(&nested),
8097            vec![(BlockClass::Directive, BlockClass::Paragraph)]
8098        );
8099    }
8100
8101    #[test]
8102    fn a_block_media_names_itself_in_the_boundaries_either_side() {
8103        use BlockClass::*;
8104        // A block image is never a node of its own — `media_only` promotes the
8105        // *paragraph* wrapping it — so classifying the node the walk stands on
8106        // called the picture `Paragraph` and left `BlockClass::Media` unreachable:
8107        // a frontend could not give a photo more air than a line of prose.
8108        // `label_media_boundaries` reads it back off the finished rows instead.
8109        let m = map("one\n\n![alt](p.png)\n\ntwo\n");
8110        assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
8111        // At the edges of the document too: the leading gap has no boundary of
8112        // its own, and the trailing one is `emit_trailing_blank_lines`'.
8113        let edges = map("![a](p.png)\n\nmid\n\n![b](q.png)\n");
8114        assert_eq!(
8115            boundaries(&edges),
8116            vec![(Media, Paragraph), (Paragraph, Media)]
8117        );
8118        // One gap spelled with several rows — the row closing the block above and
8119        // the row opening the one below, with the author's spare blank line
8120        // navigable between them — carries the same pair on every drawn row.
8121        let roomy = map("one\n\n\n\n![alt](p.png)\n");
8122        assert_eq!(
8123            boundaries(&roomy),
8124            vec![(Paragraph, Media), (Paragraph, Media)]
8125        );
8126    }
8127
8128    #[test]
8129    fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
8130        // Worse than the image case before `label_media_boundaries`: a `<video>`
8131        // arrives as twig's generic `container`, which classifies `Directive` —
8132        // the one class a frontend reads as "draw a tinted panel here". A movie
8133        // got the chrome of a fenced div.
8134        let mut doc = crate::Doc::from_source(
8135            "one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
8136            Format::Markdown,
8137        )
8138        .unwrap();
8139        doc.build_visual(80);
8140        assert_eq!(
8141            boundaries(&doc.vmap),
8142            vec![
8143                (BlockClass::Paragraph, BlockClass::Media),
8144                (BlockClass::Media, BlockClass::Paragraph),
8145            ]
8146        );
8147    }
8148
8149    #[test]
8150    fn the_incremental_walk_labels_boundaries_like_the_full_one() {
8151        // `assert_maps_eq` compares boundaries too, so this pins the two doors
8152        // into `BlockClass::from_node_kind` — a `FlatNode`'s kind on the full
8153        // build, a query match's on the cached one — against a document with one
8154        // of every boundary in it.
8155        let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
8156        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
8157        let mut cache = BlockCache::default();
8158        let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
8159        assert_maps_eq(&full, &cached, "boundary labelling");
8160        assert!(
8161            !boundaries(&full).is_empty(),
8162            "the fixture has boundaries to compare"
8163        );
8164    }
8165
8166    #[test]
8167    fn every_caret_stop_opens_a_cluster_of_its_row() {
8168        // The two ways of finding a cluster have to agree. `push_text` marks the
8169        // stops by segmenting one run of text; the column mapping segments the
8170        // whole row, decoration and all. A stop that came out as the *middle* of
8171        // some row-level cluster would be a caret with no column of its own —
8172        // drawn at the column of whatever swallowed it.
8173        let src = "# 標題\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` 你好\n\n\
8174                   - 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
8175                   | A | 值 |\n|---|---|\n| 你好 | 👩‍🚀 |\n";
8176        let m = map(src);
8177        for (r, row) in m.rows.iter().enumerate() {
8178            let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
8179            for (i, g) in row.glyphs.iter().enumerate() {
8180                assert!(
8181                    !g.stop || openers.contains(&i),
8182                    "row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
8183                     so it is drawn at another glyph's column",
8184                    g.ch
8185                );
8186            }
8187        }
8188    }
8189}