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::{
32    Align, Baseline, FaceId, FaceRef, FaceTable, FontSize, LineHeight, MarkColor, Role, Style,
33    TextColor, Token,
34};
35
36/// One rendered character plus the source byte offset it originates from.
37/// Synthetic glyphs (a list bullet, a quote gutter) point at their block's
38/// start, so clicking one lands the caret at the start of that block.
39#[derive(Clone)]
40pub struct Glyph {
41    pub ch: char,
42    pub style: Style,
43    pub src: usize,
44    /// Whether the caret may *rest* on this glyph. Decoration — a table border
45    /// or a cell's alignment padding — is visible but isn't text, so the caret
46    /// steps over it instead of into it. It also can't be a stop even in
47    /// principle: a run of decoration shares one `src`, and a caret can only
48    /// move by changing offset, so resting on it would pin horizontal motion.
49    /// A click still maps through `src`, which is why decoration points at the
50    /// text it decorates.
51    ///
52    /// Real text is a stop once per *grapheme cluster*, on the glyph that opens
53    /// it: the continuation glyphs of an emoji or an accented letter are drawn,
54    /// but standing between them is standing inside a character.
55    pub stop: bool,
56}
57
58/// One visual line. `end_src` is the source offset a caret sits at when placed
59/// at the line's end (past its last glyph) — the anchor for end-of-line and
60/// click-past-content.
61///
62/// `Clone` so a block's rows can be cached and re-emitted at a shifted offset
63/// across an edit — see [`BlockCache`].
64#[derive(Clone)]
65pub struct VRow {
66    pub glyphs: Vec<Glyph>,
67    pub end_src: usize,
68    /// A row that is drawn but holds no caret: a table's `├───┼───┤` rules, and
69    /// the blank gap a block boundary is spelled with. Vertical motion steps
70    /// over it, `pos_of_offset` never resolves onto it, and its stops (it has
71    /// none) and `end_src` stay out of the map's stop table.
72    ///
73    /// Emptiness isn't the test — an empty paragraph is a blank row too, and a
74    /// real caret stop. The test is whether the row is somewhere text can go.
75    pub decoration: bool,
76    /// This row is one line of a fenced or indented code block. Set on every row
77    /// the `"code_block"` arm emits — including its blank lines, which carry no
78    /// glyph to tell them apart otherwise. A frontend draws its own chrome (a
79    /// border and a tinted background) around each maximal run of these, and
80    /// scrolls them horizontally instead of wrapping; see
81    /// [`VisualMap::code_blocks`]. Survives the row shuffling of [`BlockCache`]
82    /// reuse and [`build_spliced`] because it rides on the row, not on a
83    /// row-index span the way a table's picture does.
84    pub code: bool,
85    /// A fenced code block's info string (its language), carried on the *first*
86    /// row of the block so it survives row reuse the way [`code`](Self::code)
87    /// does. `None` on every other row, and on an indented block (which has no
88    /// fence to label). A frontend paints it as a small label on the block's box
89    /// and edits it through a prompt — see [`CodeBlockInfo::lang`]. It's a plain
90    /// display string, not a source slice, so it needs no offset shifting; the
91    /// label re-derives from twig on the next build.
92    pub code_lang: Option<String>,
93    /// This row belongs to a `:::name{.class}` directive container — twig's
94    /// generic fenced-div block, whose meaning is entirely up to the host app
95    /// (diaryx's `:::vis{.audience}` visibility blocks, say). Set on every row
96    /// the `"directive"` arm emits, the same way [`code`](Self::code) marks a
97    /// code block's rows, so a frontend can draw a tinted panel around each
98    /// maximal run of these.
99    pub directive: bool,
100    /// A directive container's space-joined attrs — dot-prefixed classes
101    /// (`.public .family` → `"public family"`) unioned with bare pandoc-style
102    /// words (`public family`, no leading dot — diaryx's other `:::vis{...}`
103    /// convention), carried on the block's *first* row only — the
104    /// [`code_lang`](Self::code_lang) pattern. `None` on every other row, and
105    /// when the directive carries no such attrs. A frontend paints it as a
106    /// small label on the block's panel; it's a plain display string, not a
107    /// source slice, so it rides row reuse untouched.
108    pub directive_label: Option<String>,
109    /// Set on the single placeholder row a block-level image renders to, carrying
110    /// the image's destination and alt text; `None` on every other row. The row's
111    /// glyphs are the default `🖼 alt` label (which a plain surface paints as-is);
112    /// an image-capable frontend reads this to paint the real picture instead,
113    /// skipping the row named by [`MediaInfo::rows_span`]. Like
114    /// [`code_lang`](Self::code_lang) it's plain display strings, not source
115    /// slices, so it rides row reuse and needs no offset shifting; the map's
116    /// [`media`](VisualMap::media) side-table is derived from it once the rows
117    /// are final, the same way [`code_blocks`](VisualMap::code_blocks) is.
118    pub media: Option<MediaMark>,
119    /// Set on the **first** row of a task list item, carrying whether its box is
120    /// ticked; `None` on every other row, including a plain `list_item`'s. The
121    /// row's glyphs already draw the box as `☐ `/`☑ ` in the marker's place, so a
122    /// plain surface needs nothing further; a GUI reads this to paint a real
123    /// checkbox widget and to know which way it is facing.
124    ///
125    /// A `bool` rather than a source span, for the reason
126    /// [`code_lang`](Self::code_lang) is a plain string: it rides [`BlockCache`]
127    /// reuse and [`build_spliced`] untouched, needing no offset shifting. To
128    /// *toggle* the box, a frontend maps its click to a source offset the way it
129    /// maps any other — the marker's glyphs carry the item's own `src` — and
130    /// hands that to [`crate::Doc::toggle_task_at`].
131    pub task: Option<bool>,
132    /// Set on the single placeholder row a **leaf** directive (`::name{…}`)
133    /// renders to, carrying its name and attributes; `None` on every other row.
134    /// The container form isn't this — it wraps real blocks and marks each of
135    /// them [`directive`](Self::directive) instead. Like [`media`](Self::media)
136    /// it's plain display strings, so it rides row reuse untouched, and the map's
137    /// [`directives`](VisualMap::directives) side-table is derived from it once
138    /// the rows are final.
139    pub leaf_directive: Option<DirectiveMark>,
140    /// The heading level (1–6) of the block this row belongs to, on every row a
141    /// `heading` emits (a long one wraps to several) and `None` everywhere else.
142    ///
143    /// A frontend that sizes a whole line — a proportional renderer giving the
144    /// row a bigger line box — needs the level *per row*, and the glyphs can't
145    /// always supply it: an empty heading (`# ` with nothing typed after it,
146    /// which is what the toolbar's H1 leaves on a blank line) has no glyph to
147    /// carry a [`Role::Heading`] at all, so a glyph scan called it body text and
148    /// the line drew at body height until the first character landed. Riding the
149    /// row says it once, for the empty case and the wrapped case alike.
150    ///
151    /// Per-*glyph* styling still comes from [`Role::Heading`] on the glyphs; this
152    /// is the row-level fact, and the two agree wherever a heading has content —
153    /// same `u8` level, clamped the same way [`heading_style`] clamps it.
154    pub heading: Option<u8>,
155    /// How this row's block is aligned across the measure — the author's
156    /// `class="center"`, on every row the block emits and `None` for the
157    /// theme's default, which is left.
158    ///
159    /// A *row* fact and not a glyph one for [`heading`](Self::heading)'s reason,
160    /// and more sharply: alignment is a property of the *line*, not of the
161    /// letters on it, so an empty paragraph the author has just centred has to
162    /// carry it with no glyph to hang it on. It rides the row like a plain
163    /// `Copy` flag, so [`BlockCache`] reuse and [`build_spliced`] carry it
164    /// untouched.
165    ///
166    /// Read from the paragraph's or heading's own attributes and from those of
167    /// every `div` around it, the nearest winning — so `<div class="center">`
168    /// around three paragraphs centres all three, which is what the author of
169    /// that HTML meant.
170    pub align: Option<Align>,
171    /// How far apart this row's block sets its lines, as a multiple of the
172    /// theme's own line height — the author's `data-line-height`, on every row
173    /// the block emits and `None` for the theme's spacing.
174    ///
175    /// A frontend that lays rows out in pixels scales the row's height by
176    /// [`LineHeight::as_f32`]; one that draws a row per terminal line ignores
177    /// it, the way it ignores a heading's size. Read at the same two levels
178    /// [`align`](Self::align) is, and one of the menu's three names or the
179    /// exact ratio the author asked for.
180    pub line_height: Option<LineHeight>,
181    /// What this row divides, on the blank rows a block boundary is *drawn* with
182    /// and `None` on every other row — including the navigable blank lines of
183    /// preserve-soft flow, which are somewhere text can go rather than a gap
184    /// between blocks. So `boundary.is_some()` is exactly "this row is a drawn
185    /// block boundary", the [`decoration`](Self::decoration) rows that come from
186    /// [`Builder::emit_separators_before`].
187    ///
188    /// It exists because a boundary's *height* is a frontend decision but its
189    /// *kind* is not. Typography spaces a boundary by what it separates — the
190    /// margin above a heading is wider than the one between two paragraphs, so
191    /// the heading groups with the text it introduces — and a frontend that has
192    /// only rows to look at has to re-derive the structure by sniffing glyph
193    /// roles. Three frontends sniffing separately is three chances to disagree
194    /// about the same document. Core already knows, having just walked the AST
195    /// to emit this row, so it says so once here and each frontend multiplies by
196    /// its own spacing.
197    pub boundary: Option<Boundary>,
198    /// The offsets on this row where an inline mark's *content* ends under a
199    /// hidden closing delimiter — the end of the `d` in `**bold**`, one byte
200    /// before the `**` that draws nothing. Each is a caret stop with no glyph
201    /// of its own: the caret standing there is drawn where the next glyph is,
202    /// but typing there extends the mark, where typing past the delimiter
203    /// leaves it. See [`VisualMap::mark_ends`] for the rule.
204    ///
205    /// Source offsets, so [`shift_row`] moves them with the glyphs; empty on
206    /// decoration rows and on every row no mark closes on.
207    pub mark_ends: Vec<usize>,
208}
209
210/// What a drawn block boundary separates: the kinds of the blocks it falls
211/// between — the pair a frontend spaces by.
212#[derive(Clone, Copy, Debug, PartialEq, Eq)]
213pub struct Boundary {
214    pub above: BlockClass,
215    pub below: BlockClass,
216}
217
218/// The block kinds core tells apart when it walks a document — the vocabulary
219/// [`Boundary`] is spelled in. A statement about *structure*, not about how any
220/// of it should look: what a frontend does with "this gap sits above a heading"
221/// is entirely the frontend's.
222///
223/// `Class` rather than `Kind` because [`twig::BlockKind`] already means
224/// something else in this crate's public surface — the *command* vocabulary
225/// (`Paragraph | Heading(n)`) a toolbar passes to [`Doc::set_block`](crate::Doc::set_block).
226/// This is the reverse direction: what a block already *is*, read back off a
227/// rendered row.
228///
229/// [`BlockClass::Other`] is the honest answer for a node kind core doesn't
230/// separate out, so adding one here is additive for every frontend: nothing has
231/// to change until it wants to space that kind differently.
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233pub enum BlockClass {
234    Paragraph,
235    Heading,
236    /// A whole list. Its *items* are [`BlockClass::ListItem`]; note that core
237    /// draws no boundary row between two items of one list, tight or loose, so
238    /// an item↔item pair never reaches a frontend.
239    List,
240    ListItem,
241    Quote,
242    Code,
243    Table,
244    /// A block-level image, video, or audio.
245    ///
246    /// Never reached through [`from_node_kind`](BlockClass::from_node_kind): a
247    /// block picture is not a node of its own — [`Builder::media_only`] promotes
248    /// the paragraph (or `<picture>`/`<video>` container) wrapping it — so the
249    /// walk only ever sees the wrapper's kind. [`label_media_boundaries`] reads
250    /// it back off the finished rows instead, after the fact.
251    Media,
252    /// A `:::name{.class}` directive container.
253    Directive,
254    Rule,
255    Footnote,
256    Other,
257}
258
259impl BlockClass {
260    /// Classify a twig node kind — the same vocabulary [`Builder::block`]
261    /// matches on, so the two can't drift about what a block is. Both the
262    /// whole-arena walk (which has [`FlatNode`]s) and the incremental top-level
263    /// walk (which has only a query match's kind) reach it by this one door.
264    pub fn from_node_kind(kind: &Kind) -> BlockClass {
265        match kind {
266            Kind::Para => BlockClass::Paragraph,
267            Kind::Heading => BlockClass::Heading,
268            Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
269            Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
270            Kind::BlockQuote => BlockClass::Quote,
271            Kind::CodeBlock => BlockClass::Code,
272            Kind::Table => BlockClass::Table,
273            Kind::Image => BlockClass::Media,
274            // twig 2.8 folded `div`/`span`/`directive`/`element` into one
275            // `container` kind, so a `:::note` panel and a promoted `<video>`
276            // arrive here indistinguishable — telling them apart needs the
277            // node's `origin`, and the incremental walk has only this kind.
278            // `Directive` is the right answer for the case that motivates the
279            // class (nothing else draws a tinted panel) and a harmless one for
280            // the rest: `BlockClass` is descriptive and core never branches on
281            // it. The one case where it was actively wrong — a promoted
282            // `<video>`, which would have been handed to a frontend as something
283            // to draw a fenced-div panel around — is corrected by
284            // [`label_media_boundaries`] once the rows are final, along the same
285            // door as a block image. Anything else that must be exact reads
286            // [`container_is_directive`] off a real node.
287            Kind::Container => BlockClass::Directive,
288            Kind::ThematicBreak => BlockClass::Rule,
289            Kind::Footnote => BlockClass::Footnote,
290            _ => BlockClass::Other,
291        }
292    }
293}
294
295/// The name and attributes a leaf directive's placeholder row carries, so a
296/// frontend that knows the host app's vocabulary can paint the real thing —
297/// an embedded page for diaryx's `::embed{src=…}`, a generated table of
298/// contents for a `::toc`, and the plain `⧉ name` label for one it doesn't
299/// know. The peer of [`MediaMark`], and plain strings for the same reason: they
300/// survive the row shuffling of [`BlockCache`] reuse and [`build_spliced`].
301#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct DirectiveMark {
303    /// The directive's type — `embed`, `toc`, `vis` — with no leading colons.
304    /// Core is agnostic of what it means: the vocabulary is the host app's.
305    pub name: String,
306    /// Its `{…}` attributes as `(key, value)` pairs in source order. A bare
307    /// attribute (`{public}`) has a `None` value, the way twig reports it.
308    pub attrs: Vec<(String, Option<String>)>,
309    /// The directive's `[label]` text, flattened from its inline children, or
310    /// empty when it has none. Also what the placeholder label shows.
311    pub label: String,
312    /// How many visual rows this directive reserves — the label row plus blank
313    /// filler rows below it, so a frontend painting something real has the
314    /// vertical room. `1` is the bare placeholder, and the only value core
315    /// produces today: unlike an image (whose height a terminal frontend
316    /// measures and reports back), nothing has told core how tall an embed is.
317    /// A pixel-laid-out GUI sets its own height regardless.
318    pub rows: usize,
319}
320
321/// What a block-level media placeholder actually is, so a frontend knows which
322/// widget to build over the reserved rows: a raster, a movie player, or a
323/// transport with no picture at all. Core classifies and stops there — it opens
324/// nothing, so this is a statement about the *markup*, not about a file it has
325/// verified exists or can decode.
326#[derive(Clone, Copy, Debug, PartialEq, Eq)]
327pub enum MediaKind {
328    /// A `![](…)` / `<img>` / `<picture>` — a still picture.
329    Image,
330    /// An HTML `<video>`. Markdown and Djot spell no video of their own, so this
331    /// only ever arrives through `html_elements` promotion (or a `::video{…}`
332    /// directive a host app maps itself, which core reports as a directive).
333    Video,
334    /// An HTML `<audio>` — a transport with no picture, so a frontend gives it a
335    /// fixed control height rather than measuring an aspect ratio.
336    Audio,
337}
338
339/// Which of the two caret homes a block media has — see
340/// [`VisualMap::block_media_stop`].
341#[derive(Clone, Copy, Debug, PartialEq, Eq)]
342pub enum MediaStop {
343    /// The stop in front of the picture. What is typed here belongs above it.
344    Before,
345    /// The stop just past it. What is typed here belongs below it.
346    After,
347}
348
349impl MediaKind {
350    /// The emoji a plain surface prefixes the placeholder label with — the
351    /// `🖼`/`🎬`/`🔊` that makes the row read as *a thing* rather than as text.
352    fn sigil(self) -> char {
353        match self {
354            MediaKind::Image => '🖼',
355            MediaKind::Video => '🎬',
356            MediaKind::Audio => '🔊',
357        }
358    }
359}
360
361/// The destination and label a block-level media placeholder row carries, so a
362/// capable frontend can resolve and paint the real thing. Plain strings (no
363/// source offsets), so they survive the row shuffling of [`BlockCache`] reuse
364/// and [`build_spliced`] untouched — see [`VRow::media`].
365#[derive(Clone, Debug, PartialEq, Eq)]
366pub struct MediaMark {
367    /// Whether this is a picture, a movie, or a sound — which widget the
368    /// frontend builds over the reserved rows.
369    pub kind: MediaKind,
370    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
371    /// the AST. A frontend resolves a relative path against the document's
372    /// directory itself; core holds no I/O.
373    ///
374    /// Empty is possible and legal for a `<video>`/`<audio>`, which may carry no
375    /// `src` of its own and name its candidates in child `<source>`s instead —
376    /// unlike an `<img>`, whose `src` *is* the picture. A frontend with an empty
377    /// destination takes its URL from [`sources`](MediaMark::sources).
378    pub destination: String,
379    /// A `<picture>`'s theme/media alternatives, in document order, when this
380    /// block image came from one; empty for a plain `![](…)` / bare `<img>`. Each
381    /// is a `<source>`'s media query + candidate URL(s); a frontend that knows its
382    /// theme picks the first whose media matches and falls back to [`destination`]
383    /// (the `<img>`). Core keeps them verbatim and picks nothing — it has no theme.
384    ///
385    /// [`destination`]: MediaMark::destination
386    pub sources: Vec<MediaSource>,
387    /// The media's alt text (its rendered inline children, flattened), or empty
388    /// when it has none. Also what the placeholder label shows. For a `<video>`/
389    /// `<audio>` this is the element's own text content — the "your browser does
390    /// not support…" fallback, which doubles as its accessible name.
391    pub alt: String,
392    /// A `<video poster="…">`'s still frame, verbatim, or empty when there is
393    /// none (and always empty for an image or audio). It is an *image*
394    /// destination, so a frontend already able to draw a picture can show it
395    /// before the movie loads — or in place of one it can't play at all.
396    pub poster: String,
397    /// How many visual rows this media reserves — the placeholder label row plus
398    /// the blank filler rows below it, so a frontend that paints a real raster has
399    /// the vertical room to draw it. `1` is the bare placeholder (a frontend that
400    /// can't draw pictures, or an image it couldn't resolve). A terminal frontend
401    /// asks for as many rows as the fitted picture is tall; the pixel-laid-out GUI
402    /// ignores this and sets its own row height, so it always leaves it `1`. The
403    /// count comes from the frontend (via [`crate::Doc::set_media_rows`]) because
404    /// core does no I/O and can't measure the image itself. See [`VRow::media`].
405    pub rows: usize,
406}
407
408/// One `<source>` under a `<picture>`, `<video>`, or `<audio>`: a candidate URL
409/// plus whichever of the two things HTML lets a `<source>` be chosen by — a
410/// media query (`<picture>`) or a MIME type (`<video>`/`<audio>`). Verbatim from
411/// the AST: core carries the alternatives and resolves none of them, having
412/// neither a theme nor a codec list to judge them by.
413///
414/// The two spellings are normalised onto one field. `<picture>` writes
415/// `srcset`, `<video>`/`<audio>` write `src`; both land in
416/// [`srcset`](MediaSource::srcset), since a frontend wants the URL either way
417/// and only `<picture>` ever uses the descriptor syntax.
418#[derive(Clone, Debug, PartialEq, Eq)]
419pub struct MediaSource {
420    /// The `<source media="…">` query, verbatim (`"(prefers-color-scheme: dark)"`),
421    /// or empty for a `<source>` with no `media` (an unconditional override, and
422    /// the norm for `<video>`/`<audio>`, which pick by codec rather than theme).
423    pub media: String,
424    /// The candidate URL(s): a `<picture>`'s `srcset` verbatim — one URL, or a
425    /// comma-separated candidate list with `1x`/`2x`/width descriptors — or a
426    /// `<video>`/`<audio>` `<source>`'s plain `src`. A frontend takes the first
427    /// URL token; the theme and codec cases both only ever need that.
428    pub srcset: String,
429    /// The `<source type="…">` MIME type (`"video/webm"`), verbatim, or empty
430    /// when the `<source>` declares none. How a `<video>`/`<audio>` frontend
431    /// picks a candidate it can actually decode; a `<picture>`'s sources
432    /// normally leave it empty and are chosen by [`media`](MediaSource::media).
433    pub mime: String,
434}
435
436/// The rendered document plus the offset⇄position mapping the caret rides on.
437#[derive(Clone, Default)]
438pub struct VisualMap {
439    /// The document's **default monospace rendering** — one [`VRow`] of glyphs
440    /// per visual line, tables spelled with box-drawing borders (`│ ─ ┌┬┐…`) and
441    /// cells padded to whole character-cell columns. Any monospace surface can
442    /// draw these verbatim, so a consumer gets a working view for free: the TUI
443    /// paints them as-is, and a five-line plain-text dump would too.
444    ///
445    /// It's a *default*, not the only truth. A frontend with its own geometry —
446    /// a proportional GUI — lays text out in its own units, and for a table
447    /// skips the box-drawn rows named by [`TableInfo::rows_span`] and draws from
448    /// the structural [`TableInfo`] instead. The box glyphs live here rather than
449    /// in a frontend precisely because they *are* a renderable default: unlike a
450    /// colour (a role each surface must map to its own palette — see
451    /// [`crate::style`]), `┌─┐` is finished text that needs no interpretation.
452    pub rows: Vec<VRow>,
453    /// The first source offset that is actually rendered — the caret floor for
454    /// the WYSIWYG view. Non-zero when a leading `metadata` block (YAML/TOML
455    /// frontmatter) is skipped: the frontmatter is preserved in the source and
456    /// editable in the source view, but hidden and unreachable here, so the
457    /// caret and selection can't wander into it (and copy won't grab it).
458    pub content_start: usize,
459    /// Every offset the caret may rest at, ascending and deduplicated: each
460    /// row's stop glyphs plus the row's own end (the "after the last character"
461    /// spot every line needs). Decoration contributes nothing.
462    ///
463    /// Left/Right read this instead of walking the grid, because the grid isn't
464    /// laid out in offset order: a table with wrapped cells puts column 1's
465    /// second line *below* column 2's first, so "the next stop rightward" and
466    /// "the next stop in the document" part ways. Following the document is what
467    /// a caret means — and on every row that *is* in order the two agree anyway,
468    /// so nothing else has to change.
469    stops: Vec<usize>,
470    /// The caret's second home at the end of every hidden inline mark: the
471    /// offset where the mark's content ends, one byte before its closing
472    /// delimiter — ascending and deduplicated, from every row's
473    /// [`VRow::mark_ends`].
474    ///
475    /// With delimiters hidden, `**bold** tail` draws one spot after the `d`
476    /// and the source has two offsets for it: the content end (inside the
477    /// mark, where typing extends the bold) and the byte past the `**` (where
478    /// typing leaves it). Only the second is a glyph's offset, so only it was
479    /// a stop, and a caret asked to rest at the first was snapped a whole
480    /// character back onto the `d` — a drag over `bold` came back one letter
481    /// short. The delete and backspace paths already settle the caret on the
482    /// content end as its natural home there
483    /// ([`crate::Doc::settle_inside_close_delims`]); this makes it one the
484    /// caret can be placed at and step onto too.
485    ///
486    /// Kept apart from [`stops`](Self::stops) rather than merged in, because
487    /// the two lists answer different questions. A stop with no glyph is
488    /// invisible to a walk that pairs stops with characters — a system text
489    /// input counting `position(from:offset:)` steps against the text it was
490    /// shown would drift a character at every mark — and to word motion, which
491    /// classifies a stop by the source byte under it (a `*`). So
492    /// [`stop_after`](Self::stop_after) and its kin walk the glyph stops alone,
493    /// and only the places a caret *rests* — snapping, resting checks, and
494    /// Left/Right — read both.
495    mark_ends: Vec<usize>,
496    /// Every table in the document, in order, described structurally rather than
497    /// drawn — see [`TableInfo`] for why both exist.
498    pub tables: Vec<TableInfo>,
499    /// Every fenced/indented code block, in order, as the range of [`rows`] it
500    /// occupies — a frontend draws one bordered, tinted box around each and
501    /// scrolls it horizontally rather than wrapping. Derived from the per-row
502    /// [`VRow::code`] flag once the rows are final (so it survives incremental
503    /// row reuse), the same way [`collect_stops`] derives the stop table.
504    ///
505    /// [`rows`]: VisualMap::rows
506    pub code_blocks: Vec<CodeBlockInfo>,
507    /// Every block-level image in the document, in order — one per placeholder
508    /// row a frontend replaces with a real picture. Derived from the per-row
509    /// [`VRow::media`] mark once the rows are final (so it survives incremental
510    /// row reuse), the same way [`code_blocks`](VisualMap::code_blocks) is
511    /// derived from [`VRow::code`].
512    pub media: Vec<MediaInfo>,
513    /// Every **leaf** directive in the document, in order — one per placeholder
514    /// row a frontend may replace with whatever the host app's vocabulary makes
515    /// of it. Derived from the per-row [`VRow::leaf_directive`] mark once the
516    /// rows are final, exactly as [`media`](VisualMap::media) is.
517    pub directives: Vec<DirectiveInfo>,
518    /// Every named font family this map's glyphs are set in, by the
519    /// [`FaceId`] they carry — the side table that lets [`Style`] stay `Copy`
520    /// while a family name stays a `String`.
521    ///
522    /// Not derived from the rows the way [`code_blocks`](Self::code_blocks) is,
523    /// because the name is not on the rows: it is interned as the walker meets
524    /// the attribute. So each of the three build paths assembles it from what
525    /// it actually walked — a fresh build from its own walk, a cached build
526    /// from each block's walk or the names its cache entry stored, a splice
527    /// from the previous map's table plus the one block it re-rendered. A
528    /// [`FaceId`] is derived from the name rather than being an index, which is
529    /// what makes those three agree glyph for glyph; see the type's note.
530    faces: FaceTable,
531}
532
533impl VisualMap {
534    pub fn num_rows(&self) -> usize {
535        self.rows.len()
536    }
537
538    /// The family name a glyph's [`FaceRef::Named`] stands for, or `None` for
539    /// an id from another map — which a frontend draws in the theme's body
540    /// face, as it draws a family it cannot resolve.
541    pub fn face_name(&self, id: FaceId) -> Option<&str> {
542        self.faces.name(id)
543    }
544
545    /// Every named family this map draws — how a frontend warms a font cache
546    /// before it lays a frame out. See [`faces`](Self::faces).
547    ///
548    /// [`faces`]: VisualMap::faces
549    pub fn faces(&self) -> &FaceTable {
550        &self.faces
551    }
552
553    /// The width of `row` in display columns — the rightmost column its caret
554    /// can occupy, and so what a goal column is clamped to on the way in.
555    pub fn row_width(&self, row: usize) -> usize {
556        self.rows.get(row).map_or(0, |r| r.width())
557    }
558
559    /// The screen `(row, col)` for a source offset — where to draw the caret:
560    /// the *nearest* stop at or past `off`. Snaps a hidden offset (inside a
561    /// delimiter) to the next visible glyph, and never resolves onto decoration
562    /// (a table border, a cell's padding), which is drawn but holds no caret.
563    ///
564    /// "Nearest" rather than "the first one found" because a table's wrapped
565    /// cells put rows slightly out of offset order: scanning top to bottom, the
566    /// second line of column 1 comes *after* the first line of column 2 but
567    /// holds smaller offsets. Where rows are in order the two rules agree.
568    ///
569    /// A soft wrap is the one place two rows want the same offset: the row above
570    /// ends where the row below opens, the space the wrap ate being drawn on the
571    /// row above and the offset past it being the row below's first character.
572    /// It resolves *downstream*, to the row that character is on — the row
573    /// above's last column is a phantom, a place the caret can be drawn but
574    /// never sent, and resolving upstream into it is what pinned Down at the
575    /// first wrap of a paragraph: it aimed at the row below's column 0, landed
576    /// on the offset it already had, and read that back as the row above's end.
577    pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
578        let mut best: Option<(usize, usize, usize)> = None; // (src, row, col)
579        for (r, row) in self.rows.iter().enumerate() {
580            if row.decoration {
581                continue;
582            }
583            // Offsets ascend *within* a row, so its first stop at or past `off`
584            // is the best this row has to offer.
585            let cand = row
586                .glyphs
587                .iter()
588                .enumerate()
589                .find(|(_, g)| g.stop && g.src >= off)
590                .map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
591                .or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
592            if let Some(c) = cand {
593                // `<=`, so a tie goes to the later row: the only offset two rows
594                // both hold is a wrap boundary, and it belongs to the row below.
595                if best.is_none_or(|b| c.0 <= b.0) {
596                    best = Some(c);
597                }
598            }
599            // A row's *first* stop never decreases from one row to the next —
600            // true even across a table's wrapped cells, since a cell's lines run
601            // downward. So once a row opens past the best found so far, no later
602            // row can beat it and the scan stays proportional to `off`.
603            if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
604                && first.src > b.0
605            {
606                break;
607            }
608        }
609        match best {
610            Some((_, r, c)) => (r, c),
611            None => {
612                let r = self.last_stop_row();
613                (r, self.row_width(r))
614            }
615        }
616    }
617
618    /// The rows a source range occupies, inclusive: `(first, last)`.
619    ///
620    /// A *different question* from [`pos_of_offset`](Self::pos_of_offset), which
621    /// is why it can't be spelled with two calls to it. That one answers "where
622    /// does the caret go", and for a caret its forward snap is right — an offset
623    /// inside a hidden delimiter has no column of its own, so the caret belongs
624    /// at the next visible glyph, wherever that turns out to be. This one asks
625    /// "which rows does this block cover", and there the snap is a trap: a
626    /// footnote whose body *ends* in a link (`[^2]: [title](url)`) has a last
627    /// byte inside the hidden destination, so `pos_of_offset(end - 1)` walked
628    /// clean off the note's row and landed on the next note's — and a peek
629    /// slicing `first..=last` out of the frame drew two notes where the reader
630    /// asked for one. Every block ending in a link, an image, or any trailing
631    /// hidden markup had the same fault; only a block ending in visible text
632    /// (which is what the tests happened to use) did not.
633    ///
634    /// `row.end_src` is no help either: it is where the *rendered* text of a row
635    /// ends, not how far into the source the block reaches, and redefining it
636    /// would move every end-of-line caret.
637    ///
638    /// So the last row is found by asking which rows *open* before the range
639    /// does, rather than by mapping its last byte: a row belongs to the range
640    /// when its first caret stop lies before `range.end`. Decoration is skipped
641    /// (a drawn gap between blocks is not part of either), and the answer is
642    /// never shorter than one row — a range whose every byte is hidden still
643    /// covers the row it started on.
644    pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
645        if self.rows.is_empty() {
646            return (0, 0);
647        }
648        let first = self.pos_of_offset(range.start).0;
649        let mut last = first;
650        for (r, row) in self.rows.iter().enumerate().skip(first) {
651            if row.decoration {
652                continue;
653            }
654            let open = row
655                .glyphs
656                .iter()
657                .find(|g| g.stop)
658                .map_or(row.end_src, |g| g.src);
659            if open >= range.end {
660                // A row's first stop never decreases from one row to the next —
661                // the invariant `pos_of_offset` breaks on, true even across a
662                // table's wrapped cells — so nothing below can be in range.
663                break;
664            }
665            last = r;
666        }
667        (first, last)
668    }
669
670    /// The source offset of the task checkbox drawn at `(row, col)`, or `None`
671    /// when that cell holds no box — the hit-test a frontend runs on a click
672    /// before treating it as a tick rather than a caret placement.
673    ///
674    /// Only the box's own cells answer. Clicking an item's *text* places the
675    /// caret like any other click, so the box is a target aimed at rather than
676    /// something tripped over while editing — which is also why this is a
677    /// separate question from [`offset_of_pos`](Self::offset_of_pos) instead of
678    /// a flag on the offset it returns.
679    pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
680        let r = self.rows.get(row)?;
681        self.task_box_at_glyph(row, r.glyph_at_col(col)?)
682    }
683
684    /// [`task_box_at`](Self::task_box_at) keyed by glyph index rather than
685    /// display column — for a frontend that shapes its own rows (the GUI) and so
686    /// resolves a click to a glyph before it ever has a column.
687    pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
688        let r = self.rows.get(row)?;
689        r.task?;
690        let g = r.glyphs.get(glyph)?;
691        (g.style.role == Role::ListMarker).then_some(g.src)
692    }
693
694    /// The source offset for a screen `(row, col)` — where a click or a
695    /// visual-space move lands the caret. Clicking decoration maps through its
696    /// `src`, which points at the text it decorates, so a click on a border or
697    /// on a cell's padding lands in that cell.
698    ///
699    /// The inverse of [`pos_of_offset`](Self::pos_of_offset), which it has to
700    /// agree with: `col` is a display column, and the one it names may be the
701    /// far cell of a wide glyph — [`VRow::glyph_at_col`] is where that lands.
702    pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
703        let Some(r) = self.rows.get(row) else {
704            // A click or drag below the last row — a short document with empty
705            // space under it, dragged into to extend a selection. Land on the
706            // document's last caret stop (its end), not offset 0: jumping the
707            // caret to the top is the wrong direction, and 0 isn't even a stop
708            // when the document opens on hidden frontmatter or a `# ` marker, so
709            // returning it would leave the caret where it draws in one place and
710            // types in another (`move_to` would then clamp it onto the unhomeable
711            // frontmatter floor). `None` only for a document with no stops at all
712            // (empty), where the caret has nowhere to be but 0.
713            return self.stops.last().copied().unwrap_or(0);
714        };
715        match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
716            // A glyph that holds no caret is clickable, but where it points
717            // isn't always somewhere the caret can be: the blank gap between two
718            // paragraphs stands at an offset that belongs to neither of them,
719            // and the tail of a grapheme cluster stands inside a character.
720            // Land on the nearest real stop instead of handing back an offset
721            // that looks like the gap but types into the paragraph above.
722            Some(g) if !g.stop => self.nearest_stop(g.src),
723            Some(g) => g.src,
724            // A row's end is a stop by construction — unless the row is
725            // decoration, which contributes none.
726            None if r.decoration => self.nearest_stop(r.end_src),
727            None => r.end_src,
728        }
729    }
730
731    /// Which of a block media's two caret homes `off` is, or `None` for every
732    /// other offset in the document.
733    ///
734    /// [`block_media`](Builder::block_media) gives a block-level image, video, or
735    /// audio exactly two stops — one in front of it and one just past it — and
736    /// nothing inside the markup. Both are ordinary offsets to everything else in
737    /// core, but they are the two places where inserting text would *dissolve the
738    /// picture*: `![](p.png)` with anything typed against it is no longer a block
739    /// image but a paragraph with an inline one, and the frontend that was
740    /// painting a photo there paints a text run instead. A caller that is about to
741    /// insert asks this so it can open a paragraph first — see
742    /// [`Doc::insert`](crate::Doc::insert).
743    ///
744    /// An *inline* image reports `None`: it has no placeholder row and no stops of
745    /// its own, and typing beside one is ordinary editing.
746    ///
747    /// Answers with the media's own source span as well, since a caller that has
748    /// to keep the picture whole usually has to address it — [`Doc::backspace`]
749    /// takes the picture out in one piece rather than nibbling a byte off its
750    /// markup, which is the same dissolution from the other side.
751    ///
752    /// [`Doc::backspace`]: crate::Doc::backspace
753    pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
754        for m in &self.media {
755            let Some(row) = self.rows.get(m.rows_span.start) else {
756                continue;
757            };
758            // Every glyph of the `🖼 alt` label maps to the media's start offset;
759            // the row's end is past its markup. Read the start off the label
760            // rather than the first glyph, which on a quoted or listed picture is
761            // the block prefix and points at the gutter.
762            let Some(start) = row
763                .glyphs
764                .iter()
765                .find(|g| g.style.role == Role::Image)
766                .map(|g| g.src)
767            else {
768                continue;
769            };
770            if off == start {
771                return Some((MediaStop::Before, start..row.end_src));
772            }
773            if off == row.end_src {
774                return Some((MediaStop::After, start..row.end_src));
775            }
776        }
777        None
778    }
779
780    /// Whether `off` is a table's trailing caret stop — the one home past a
781    /// table's last cell, at the block's own end ([`TableInfo::end_src`]).
782    ///
783    /// The table's peer of [`block_media_stop`](Self::block_media_stop)'s
784    /// `After`: text inserted at that offset joins the table's last source
785    /// line, and a line glued under a table is a row of it (`| 1 | 2 |x`), so
786    /// a caller about to insert there opens a paragraph first — see
787    /// [`Doc::insert`](crate::Doc::insert). Nothing else about the offset is
788    /// special: it is where Down from the last row lands and where a click in
789    /// the blank space under a trailing table lands.
790    pub fn table_end_stop(&self, off: usize) -> bool {
791        self.tables.iter().any(|t| t.end_src == off)
792    }
793
794    /// Snap `off` to the nearest caret stop — the funnel a frontend that
795    /// hit-tests pixels straight to a source offset must run its result through.
796    /// A click or drag can land in the blank gap a paragraph break is drawn with,
797    /// or inside a hidden delimiter; both are offsets the caret can't rest at, so
798    /// resting there would draw the caret in one place and type in another. This
799    /// settles it on a real caret home instead. Idempotent on an offset that is
800    /// already a stop — the `(row, col)` click path already snaps this way inside
801    /// [`offset_of_pos`](Self::offset_of_pos), and this gives the pixel path the
802    /// same guarantee. Returns `off` unchanged only for an empty document (no
803    /// stops at all).
804    pub fn snap_to_stop(&self, off: usize) -> usize {
805        self.nearest_stop(off)
806    }
807
808    /// The caret stop nearest `off`, preferring the one before it when `off`
809    /// falls exactly between two. Returns `off` unchanged if there are no stops
810    /// at all (an empty document). A mark's content end counts: it is a place
811    /// the caret rests, and the one a drag ending on a marked word means.
812    fn nearest_stop(&self, off: usize) -> usize {
813        let before = Self::last_at_or_before(&self.stops, off)
814            .max(Self::last_at_or_before(&self.mark_ends, off));
815        let after = match (
816            Self::first_at_or_after(&self.stops, off),
817            Self::first_at_or_after(&self.mark_ends, off),
818        ) {
819            (Some(a), Some(b)) => Some(a.min(b)),
820            (a, b) => a.or(b),
821        };
822        match (before, after) {
823            (Some(b), Some(a)) if off - b <= a - off => b,
824            (_, Some(a)) => a,
825            (Some(b), None) => b,
826            (None, None) => off,
827        }
828    }
829
830    /// The glyph stop nearest `off` — [`nearest_stop`](Self::nearest_stop)
831    /// for a walk that pairs stops with characters, which a mark's content
832    /// end has none of. A caret resting on one resolves to the glyph stop
833    /// drawn at the same spot, the one just past the hidden delimiter, so the
834    /// text a system input is shown from there and the steps it counts agree.
835    pub fn snap_to_glyph_stop(&self, off: usize) -> usize {
836        if self.mark_ends.binary_search(&off).is_ok()
837            && let Some(next) = Self::first_at_or_after(&self.stops, off)
838        {
839            return next;
840        }
841        let before = Self::last_at_or_before(&self.stops, off);
842        let after = Self::first_at_or_after(&self.stops, off);
843        match (before, after) {
844            (Some(b), Some(a)) if off - b <= a - off => b,
845            (_, Some(a)) => a,
846            (Some(b), None) => b,
847            (None, None) => off,
848        }
849    }
850
851    /// The last of `sorted` at or before `off`, if any.
852    fn last_at_or_before(sorted: &[usize], off: usize) -> Option<usize> {
853        let i = sorted.partition_point(|&s| s <= off);
854        i.checked_sub(1).map(|i| sorted[i])
855    }
856
857    /// The first of `sorted` at or after `off`, if any.
858    fn first_at_or_after(sorted: &[usize], off: usize) -> Option<usize> {
859        let i = sorted.partition_point(|&s| s < off);
860        sorted.get(i).copied()
861    }
862
863    /// The next place the caret rests past `off` — the next glyph stop or the
864    /// next mark's content end, whichever comes first. What Right walks:
865    /// leaving `**bold**` from the `d` is two presses, one onto the end of the
866    /// bold (still bold, the toolbar lit) and one past its delimiter, at the
867    /// same spot on screen. [`stop_after`](Self::stop_after) is the walk that
868    /// skips the first, for every caller that pairs stops with characters.
869    pub fn caret_stop_after(&self, off: usize) -> Option<usize> {
870        match (
871            self.stop_after(off),
872            Self::first_at_or_after(&self.mark_ends, off + 1),
873        ) {
874            (Some(a), Some(b)) => Some(a.min(b)),
875            (a, b) => a.or(b),
876        }
877    }
878
879    /// The previous place the caret rests before `off` — the mirror of
880    /// [`caret_stop_after`](Self::caret_stop_after), what Left walks.
881    pub fn caret_stop_before(&self, off: usize) -> Option<usize> {
882        self.stop_before(off).max(
883            off.checked_sub(1)
884                .and_then(|o| Self::last_at_or_before(&self.mark_ends, o)),
885        )
886    }
887
888    /// Whether the caret can occupy `row` at all: decoration rows (a table's
889    /// border rules) are stepped over by vertical motion.
890    pub fn row_is_navigable(&self, row: usize) -> bool {
891        self.rows.get(row).is_some_and(|r| !r.decoration)
892    }
893
894    /// The first offset the caret can rest at on `row` — its first stop, or the
895    /// row's own end when it holds no text (an empty paragraph). `None` for a
896    /// decoration row, which holds no caret at all.
897    ///
898    /// Not `offset_of_pos(row, 0)`: column 0 of a quoted or listed row is the
899    /// gutter, and a gutter's `src` points at the *block* it opens, so the stop
900    /// nearest it is the one on the block's first row rather than on this one.
901    /// Which is right for a click — the gutter decorates the whole block — and
902    /// wrong for Home, whose whole question is where *this* row starts.
903    pub fn row_start(&self, row: usize) -> Option<usize> {
904        let r = self.rows.get(row).filter(|r| !r.decoration)?;
905        Some(
906            r.glyphs
907                .iter()
908                .find(|g| g.stop)
909                .map_or(r.end_src, |g| g.src),
910        )
911    }
912
913    /// The last row the caret can rest on — the fallback when an offset is past
914    /// everything rendered (a table's bottom border must not swallow the caret).
915    fn last_stop_row(&self) -> usize {
916        (0..self.rows.len())
917            .rev()
918            .find(|&r| self.row_is_navigable(r))
919            .unwrap_or(0)
920    }
921
922    /// The nearest row above `row` the caret can occupy, skipping decoration.
923    pub fn navigable_above(&self, row: usize) -> Option<usize> {
924        (0..row.min(self.rows.len()))
925            .rev()
926            .find(|&r| self.row_is_navigable(r))
927    }
928
929    /// The nearest row below `row` the caret can occupy, skipping decoration.
930    pub fn navigable_below(&self, row: usize) -> Option<usize> {
931        ((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
932    }
933
934    /// The caret stop just before `off` — one press of Left. `None` at the
935    /// first stop in the document.
936    ///
937    /// Runs of decoration (a table border, a cell's alignment padding) are
938    /// stepped over in a single press: they hold no stop, so they aren't in the
939    /// table to land on.
940    pub fn stop_before(&self, off: usize) -> Option<usize> {
941        let i = self.stops.partition_point(|&s| s < off);
942        i.checked_sub(1).map(|i| self.stops[i])
943    }
944
945    /// The caret stop just after `off` — one press of Right. `None` at the last
946    /// stop in the document.
947    pub fn stop_after(&self, off: usize) -> Option<usize> {
948        let i = self.stops.partition_point(|&s| s <= off);
949        self.stops.get(i).copied()
950    }
951
952    /// The first caret stop at or past `off` — where the caret at a hidden
953    /// offset is *drawn*, and so where a rightward walk over the rendered text
954    /// starts from.
955    pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
956        let i = self.stops.partition_point(|&s| s < off);
957        self.stops.get(i).copied()
958    }
959
960    /// The last caret stop at or before `off` — where a leftward walk starts
961    /// from. Snapping the way the walk is headed, rather than always forward,
962    /// is what keeps a leftward motion from ever moving the caret right.
963    pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
964        let i = self.stops.partition_point(|&s| s <= off);
965        i.checked_sub(1).map(|i| self.stops[i])
966    }
967
968    /// Whether the caret may rest at `off` — the invariant every motion in this
969    /// view has to leave standing. A glyph stop, a row's end, or a hidden
970    /// mark's content end ([`mark_ends`](Self::mark_ends)).
971    pub fn is_stop(&self, off: usize) -> bool {
972        self.stops.binary_search(&off).is_ok() || self.mark_ends.binary_search(&off).is_ok()
973    }
974
975    /// The visible text a caret crosses walking rightward from `from` up to
976    /// (but not including) `to` — `UITextInput.text(in:)`'s `[from, to)` in
977    /// *this* view. A hidden inline-mark delimiter (`**`, `` ` ``, `_`, an
978    /// escape backslash) never got a glyph in the first place — see
979    /// [`push_text`]/[`synth`] — so it contributes nothing; what's left is
980    /// what's drawn on screen for that span, one character per caret stop.
981    ///
982    /// **Exactly one character per stop** is the contract, and it is the
983    /// system text input's, not a nicety: `UITextInput`'s tokenizer reads a
984    /// window of this text around a tap, indexes into it by the integer
985    /// `offset(from:to:)` reports (`distance_offset` in `leaf-ffi`, a count of
986    /// [`stop_after`](Self::stop_after) hops), finds a word boundary at some
987    /// character index, and hands the delta back through
988    /// `position(from:offset:)`, which hops stops again. If the text ever
989    /// spends a character on something that is not a stop, or a stop on
990    /// nothing, every index past that point is off by one and the word the
991    /// reader double-tapped comes back shifted — into the header row of a
992    /// table, or one letter short. So a stop that draws a glyph is spelled
993    /// as that glyph, and a stop that draws none is spelled `'\n'`:
994    ///
995    /// - a row's own end stop ([`VRow::end_src`]) — the caret home past a
996    ///   paragraph's, heading's, list item's, or code line's last glyph. This
997    ///   is also what keeps two blocks' words apart: without it the last word
998    ///   of one paragraph and the first of the next read as one run of
999    ///   letters (`"…edb\n\nhello\n"` came back as `"edbhello"`), and the
1000    ///   tokenizer selected across the boundary. A list item's end is a
1001    ///   row end like any other, though no blank gap row follows it.
1002    /// - a table cell's end, which [`push_table_row`] draws as the gutter
1003    ///   space before the next `│` so the caret has somewhere to stand past
1004    ///   the cell's last character. To a reader of *this* text a cell ends a
1005    ///   line: spelled as a space, a touch surface that lands a tap at a
1006    ///   word's end past the space that follows it stepped into the next
1007    ///   cell — or the next row, from the last column.
1008    ///
1009    /// A hidden mark's content end ([`mark_ends`](Self::mark_ends)) is a place
1010    /// the caret rests but not a stop the walks above count, so it has no
1011    /// character here either; `from` is snapped to the glyph stop drawn at
1012    /// the same spot first, exactly as [`snap_to_glyph_stop`] does for those
1013    /// walks. `to` is left as given, so a stop landing exactly on it is still
1014    /// excluded — the same half-open range `distance_offset`'s loop counts.
1015    ///
1016    /// [`push_table_row`]: Builder::push_table_row
1017    /// [`snap_to_glyph_stop`]: Self::snap_to_glyph_stop
1018    pub fn visible_text(&self, from: usize, to: usize) -> String {
1019        self.visible_items(from, to)
1020            .into_iter()
1021            .map(|(_, ch)| ch.unwrap_or('\n'))
1022            .collect()
1023    }
1024
1025    /// The UTF-16 length of `visible_text(from, to)` — what an `NSRange`
1026    /// location into that text is, without building the string.
1027    ///
1028    /// AppKit's `NSTextInputClient` and `NSAccessibility` speak in UTF-16
1029    /// units of *the text as the system sees it*, which for leaf is the visible
1030    /// text — delimiters hidden. A frontend reporting its selection to the
1031    /// system converts each end with this and gets back an index into the
1032    /// string `visible_text(0, end)` returns, which is exactly what the system
1033    /// will index into.
1034    pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
1035        self.visible_items(from, to)
1036            .into_iter()
1037            .map(|(_, ch)| ch.map_or(1, char::len_utf16))
1038            .sum()
1039    }
1040
1041    /// The inverse of `visible_utf16_len(0, ·)`: the source offset of the
1042    /// visible character a UTF-16 index into `visible_text(0, to)` lands on.
1043    ///
1044    /// An index inside a surrogate pair resolves to the character that owns
1045    /// it; one at or past the end of the text returns `None`, so a caller can
1046    /// substitute the document's end stop. The `\n` a row's or a cell's end
1047    /// is spelled with resolves to that end stop — a caret home, so a caller
1048    /// placing a caret there needs no snap.
1049    pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
1050        let mut seen = 0usize;
1051        for (src, ch) in self.visible_items(0, to) {
1052            let len = ch.map_or(1, char::len_utf16);
1053            if index < seen + len {
1054                return Some(src);
1055            }
1056            seen += len;
1057        }
1058        None
1059    }
1060
1061    /// The items `visible_text` spells, in order — one per caret stop in
1062    /// `[from, to)`, keyed by the stop's source offset: the glyph it draws
1063    /// (`Some`), or `None` for a stop with no character of its own, which the
1064    /// text spells `'\n'`. See [`visible_text`](Self::visible_text) for which
1065    /// stops those are and why.
1066    fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
1067        let from = self.snap_to_glyph_stop(from);
1068        let lo = self.stops.partition_point(|&s| s < from);
1069        // The document's last stop is the end of the text, not a character in
1070        // it: `distance_offset` has no hop past it to pair one with.
1071        let last = self.stops.len().saturating_sub(1);
1072        let hi = self.stops.partition_point(|&s| s < to).min(last).max(lo);
1073        let stops = &self.stops[lo..hi];
1074
1075        // The glyph each stop draws — the first at its offset in row order,
1076        // since a media row's label glyphs all share the media's offset and a
1077        // wrapped line's end is the next line's first glyph. Sorted because
1078        // row order only follows source order outside a table's wrapped
1079        // cells (see `pos_of_offset`); the sort is stable, so "first" holds.
1080        let mut glyphs: Vec<(usize, char)> = self
1081            .rows
1082            .iter()
1083            .filter(|r| !r.decoration)
1084            .flat_map(|r| r.glyphs.iter())
1085            .filter(|g| g.stop && g.src >= from && g.src < to)
1086            .map(|g| (g.src, g.ch))
1087            .collect();
1088        glyphs.sort_by_key(|&(src, _)| src);
1089        glyphs.dedup_by_key(|&mut (src, _)| src);
1090
1091        // A cell's end stop has a glyph (the gutter space) but is spelled as
1092        // a line end; the structural grid is where the cells' offsets live.
1093        let mut cell_ends: Vec<usize> = self
1094            .tables
1095            .iter()
1096            .flat_map(|t| t.grid.iter())
1097            .flat_map(|r| r.cells.iter())
1098            .map(|c| c.end)
1099            .filter(|&e| e >= from && e < to)
1100            .collect();
1101        cell_ends.sort_unstable();
1102        cell_ends.dedup();
1103
1104        let mut gi = 0;
1105        stops
1106            .iter()
1107            .map(|&s| {
1108                while gi < glyphs.len() && glyphs[gi].0 < s {
1109                    gi += 1;
1110                }
1111                let ch = match glyphs.get(gi) {
1112                    Some(&(src, ch)) if src == s && cell_ends.binary_search(&s).is_err() => {
1113                        Some(ch)
1114                    }
1115                    _ => None,
1116                };
1117                (s, ch)
1118            })
1119            .collect()
1120    }
1121}
1122
1123/// Collect the caret stops of a laid-out grid: every stop glyph's offset plus
1124/// every row's end, ascending and deduplicated. Duplicates are the norm rather
1125/// than the exception — a wrapped line's end is the same offset as the next
1126/// line's first glyph — and collapsing them is what makes one press of Left or
1127/// Right cross exactly one stop.
1128fn collect_stops(rows: &[VRow]) -> Vec<usize> {
1129    let mut stops: Vec<usize> = rows
1130        .iter()
1131        .filter(|r| !r.decoration)
1132        .flat_map(|r| {
1133            r.glyphs
1134                .iter()
1135                .filter(|g| g.stop)
1136                .map(|g| g.src)
1137                .chain(std::iter::once(r.end_src))
1138        })
1139        .collect();
1140    stops.sort_unstable();
1141    stops.dedup();
1142    stops
1143}
1144
1145/// Collect every row's [`VRow::mark_ends`] into one ascending, deduplicated
1146/// table — the peer of [`collect_stops`] for the caret's second home at the
1147/// end of a hidden mark. A mark that closes at a row's end coincides with the
1148/// row's own end stop; that offset is in both tables, and harmlessly so.
1149fn collect_mark_ends(rows: &[VRow]) -> Vec<usize> {
1150    let mut ends: Vec<usize> = rows
1151        .iter()
1152        .filter(|r| !r.decoration)
1153        .flat_map(|r| r.mark_ends.iter().copied())
1154        .collect();
1155    ends.sort_unstable();
1156    ends.dedup();
1157    ends
1158}
1159
1160/// Group the rows tagged [`VRow::code`] into one [`CodeBlockInfo`] per maximal
1161/// run — the block-level view a frontend needs to box and scroll each code
1162/// block. Two code blocks are always parted by the blank separator row a block
1163/// boundary is spelled with (never itself a code row), so a contiguous run is
1164/// exactly one block. Derived from the final rows rather than tracked through
1165/// the builder so it comes out right no matter how [`build_cached`] and
1166/// [`build_spliced`] shuffle rows around.
1167fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
1168    let mut blocks = Vec::new();
1169    let mut start: Option<usize> = None;
1170    for (i, row) in rows.iter().enumerate() {
1171        match (row.code, start) {
1172            (true, None) => start = Some(i),
1173            (false, Some(s)) => {
1174                blocks.push(CodeBlockInfo {
1175                    rows_span: s..i,
1176                    lang: rows[s].code_lang.clone(),
1177                });
1178                start = None;
1179            }
1180            _ => {}
1181        }
1182    }
1183    if let Some(s) = start {
1184        blocks.push(CodeBlockInfo {
1185            rows_span: s..rows.len(),
1186            lang: rows[s].code_lang.clone(),
1187        });
1188    }
1189    blocks
1190}
1191
1192/// The value of `node`'s `key` attribute, if it carries one *with* a value. A
1193/// bare attribute (`controls`, `muted`) has a `None` value and so reads as
1194/// absent here — a caller wanting presence-not-value tests the list directly.
1195/// Shared by the media element and `<source>` readers.
1196fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
1197    node.attrs
1198        .iter()
1199        .find(|(k, _)| k == key)
1200        .and_then(|(_, v)| v.clone())
1201}
1202
1203/// Collect one [`MediaInfo`] per row carrying a [`VRow::media`] mark — the
1204/// block-level view a frontend needs to replace each placeholder row with a real
1205/// picture. The mark rides the block's *first* row and names how many rows the
1206/// media reserves ([`MediaMark::rows`]); the rows below it are blank
1207/// [`decoration`](VRow::decoration) fillers that hold the vertical space and no
1208/// caret. So the span runs from the marked row across those fillers. Derived from
1209/// the final rows rather than tracked through the builder so it survives however
1210/// [`build_cached`] and [`build_spliced`] shuffle rows around.
1211fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
1212    rows.iter()
1213        .enumerate()
1214        .filter_map(|(i, row)| {
1215            row.media.as_ref().map(|m| MediaInfo {
1216                rows_span: i..i + m.rows.max(1),
1217                kind: m.kind,
1218                destination: m.destination.clone(),
1219                sources: m.sources.clone(),
1220                alt: m.alt.clone(),
1221                poster: m.poster.clone(),
1222            })
1223        })
1224        .collect()
1225}
1226
1227/// Re-label the drawn block boundaries either side of a block-level media
1228/// placeholder, so the pair a frontend spaces by names the picture.
1229///
1230/// [`BlockClass::from_node_kind`] classifies the node the walk is standing on,
1231/// and a block image is never a node of its own: [`Builder::media_only`] promotes
1232/// its *wrapper* — a `paragraph`, or the `container` a `<video>`/`<audio>`
1233/// arrives as — so the gap above a picture reported [`BlockClass::Paragraph`] and
1234/// the gap above a movie reported [`BlockClass::Directive`], the class a frontend
1235/// paints a tinted panel for. [`BlockClass::Media`] was unreachable in
1236/// consequence: the vocabulary named a kind no frontend could ever be told about.
1237///
1238/// Done as a pass over the finished rows rather than inside the walk because
1239/// only the rows know. The incremental top-level walk carries no node arena at
1240/// all (`nodes: &[]`) and can classify by kind alone, so teaching the wrapper
1241/// promotion to the whole-arena walk would label the full and incremental builds
1242/// differently — the exact drift that walk's own comment forbids. Both builds
1243/// emit the same [`VRow::media`] marks, so both reach the same answer here. The
1244/// [`media_spans`] / [`code_block_spans`] pattern.
1245///
1246/// One gap can be spelled with several rows — [`Builder::emit_separators_before`]
1247/// draws the row that closes the block above and the row that opens the block
1248/// below, with any extra blank source lines navigable between them — and gives
1249/// every one of them the same [`Boundary`]. So the walk crosses those navigable
1250/// blanks and relabels the whole run, stopping at the first row that is neither.
1251fn label_media_boundaries(rows: &mut [VRow]) {
1252    let spans: Vec<Range<usize>> = rows
1253        .iter()
1254        .enumerate()
1255        .filter_map(|(i, row)| row.media.as_ref().map(|m| i..i + m.rows.max(1)))
1256        .collect();
1257    // A row inside one gap: a drawn boundary to relabel, or one of the navigable
1258    // blank lines sitting between two drawn ones. Anything else ends the run.
1259    fn in_gap(row: &VRow) -> bool {
1260        row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
1261    }
1262    for span in spans {
1263        for i in (0..span.start).rev() {
1264            if !in_gap(&rows[i]) {
1265                break;
1266            }
1267            if let Some(b) = rows[i].boundary.as_mut() {
1268                b.below = BlockClass::Media;
1269            }
1270        }
1271        for row in rows.iter_mut().skip(span.end) {
1272            if !in_gap(row) {
1273                break;
1274            }
1275            if let Some(b) = row.boundary.as_mut() {
1276                b.above = BlockClass::Media;
1277            }
1278        }
1279    }
1280}
1281
1282/// Collect one [`DirectiveInfo`] per row carrying a [`VRow::leaf_directive`]
1283/// mark — the block-level view a frontend needs to replace each placeholder row
1284/// with whatever the directive means to it. The peer of [`media_spans`], derived
1285/// from the final rows for the same reason: it survives however [`build_cached`]
1286/// and [`build_spliced`] shuffle rows around.
1287fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
1288    rows.iter()
1289        .enumerate()
1290        .filter_map(|(i, row)| {
1291            row.leaf_directive.as_ref().map(|m| DirectiveInfo {
1292                rows_span: i..i + m.rows.max(1),
1293                name: m.name.clone(),
1294                attrs: m.attrs.clone(),
1295                label: m.label.clone(),
1296            })
1297        })
1298        .collect()
1299}
1300
1301/// The source range of a fenced code block's info string — everything on the
1302/// opening line past the fence (`` ```rust `` → the `rust`). `block_start` is the
1303/// code block node's `span.start`. `None` for an indented code block, which
1304/// opens with no fence to carry one. The range is empty for a fence written
1305/// bare (`` ``` `` alone), which is exactly where a language would be inserted.
1306///
1307/// Shared by the WYSIWYG builder (to label the box) and [`crate::Doc`] (to edit
1308/// the label through a prompt), so the two agree on where the language lives.
1309pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
1310    let rest = source.get(block_start..)?;
1311    let line_len = rest.find('\n').unwrap_or(rest.len());
1312    let line = &rest[..line_len];
1313    // A fence may be indented up to three spaces; past that it opens with a run
1314    // of the same fence character.
1315    let indent = line.len() - line.trim_start().len();
1316    if indent > 3 {
1317        return None;
1318    }
1319    let fence = line[indent..].chars().next()?;
1320    if fence != '`' && fence != '~' {
1321        return None; // an indented block, not a fenced one
1322    }
1323    let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
1324    let info_start = block_start + indent + fence_len;
1325    Some(info_start..block_start + line_len)
1326}
1327
1328/// A fenced code block's language for display: its info string, trimmed, or
1329/// `None` when there's no fence or the fence carries no language. The trimmed
1330/// text is what a frontend labels the box with; [`code_info_span`] is what an
1331/// edit replaces.
1332pub fn code_language(source: &str, block_start: usize) -> Option<String> {
1333    let span = code_info_span(source, block_start)?;
1334    let text = source.get(span)?.trim();
1335    (!text.is_empty()).then(|| text.to_string())
1336}
1337
1338/// A horizontal rule's dash count when the map isn't wrapping to a column grid
1339/// (the GUI, which wraps at pixel width): a fixed, sane width the frontend can
1340/// paint or re-wrap, instead of a runaway count from an unbounded wrap width.
1341const UNWRAPPED_RULE_WIDTH: usize = 40;
1342
1343/// Render the document to a [`VisualMap`]. `wrap` is the column budget for
1344/// word-wrapping (`Some` for the monospace TUI), or `None` to emit one row per
1345/// block — the GUI does its own proportional pixel wrapping over these rows.
1346/// Text and offsets come from the AST (`str` nodes carry the verbatim source
1347/// slice and an exact span), so the original source string isn't needed here.
1348pub fn build(
1349    nodes: &[FlatNode],
1350    source: &str,
1351    wrap: Option<usize>,
1352    preserve_soft: bool,
1353    media_rows: &HashMap<String, usize>,
1354    reveal: Option<Range<usize>>,
1355) -> VisualMap {
1356    let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
1357        return VisualMap::default();
1358    };
1359    let top = top_level(nodes, doc);
1360    let mut b = Builder {
1361        nodes,
1362        source,
1363        wrap: wrap.map(|w| w.max(8)),
1364        rows: Vec::new(),
1365        tables: Vec::new(),
1366        last_off: 0,
1367        stepped_over: 0,
1368        media_rows,
1369        break_glyph: Cell::new(' '),
1370        preserve_soft,
1371        reveal: reveal.clone(),
1372        pending_mark_ends: RefCell::new(Vec::new()),
1373        presentation: Presentation::default(),
1374        faces: RefCell::new(FaceTable::default()),
1375    };
1376    let last_drawn = b.top_blocks(&top);
1377    // The hidden frontmatter's end is the baseline for both the trailing blank
1378    // rows and the caret floor — see [`hidden_prefix_end`]. `top_level` has
1379    // already dropped every `metadata` child, so read it off the arena.
1380    let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
1381    b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
1382    let content_start = top.first().map_or(hidden_end, |&i| nodes[i].span.start);
1383    let stops = collect_stops(&b.rows);
1384    let mark_ends = collect_mark_ends(&b.rows);
1385    label_media_boundaries(&mut b.rows);
1386    let code_blocks = code_block_spans(&b.rows);
1387    let media = media_spans(&b.rows);
1388    let directives = directive_spans(&b.rows);
1389    VisualMap {
1390        rows: b.rows,
1391        content_start,
1392        stops,
1393        mark_ends,
1394        tables: b.tables,
1395        code_blocks,
1396        media,
1397        directives,
1398        faces: b.faces.into_inner(),
1399    }
1400}
1401
1402/// Like [`build`], but reuses a persistent [`BlockCache`] so an edit re-renders
1403/// only the top-level blocks whose source bytes changed *and* marshals only
1404/// those blocks from twig instead of the whole arena.
1405///
1406/// `top` is the document's top-level blocks — twig's `child_spans` of the doc
1407/// root: `(node_id, kind, span)` for each, in order. `fetch_subtree(node_id)`
1408/// marshals one block's subtree (local-indexed, root at 0) and is called *only*
1409/// for a block that missed the cache, i.e. one that actually changed. So a
1410/// keystroke marshals one small subtree, not ~20k nodes. The result is
1411/// byte-for-byte identical to [`build`] on the same document (the
1412/// `build_cached_matches_build` test pins this); [`build`] stays the cache-free,
1413/// whole-arena reference. This is the entry point [`crate::Doc`] uses.
1414// One builder, and every one of these is a distinct input to the same layout
1415// pass — a struct of them would be built at the one call site and unpacked
1416// here, which is the same arguments with an extra name in the way.
1417#[allow(clippy::too_many_arguments)]
1418pub fn build_cached(
1419    top: &[QueryMatch],
1420    source: &str,
1421    wrap: Option<usize>,
1422    preserve_soft: bool,
1423    media_rows: &HashMap<String, usize>,
1424    reveal: Option<Range<usize>>,
1425    cache: &mut BlockCache,
1426    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1427) -> VisualMap {
1428    let wrap = wrap.map(|w| w.max(8));
1429
1430    // Wrapping is a function of the width, so a width change makes every cached
1431    // row's wrap wrong: start the cache over.
1432    if cache.wrap != Some(wrap) {
1433        cache.entries.clear();
1434        cache.wrap = Some(wrap);
1435    }
1436    cache.generation = cache.generation.wrapping_add(1);
1437
1438    // Frontmatter (a leading `metadata` block) is document metadata, not prose:
1439    // hidden in the rich view exactly as [`Builder::blocks`] skips it.
1440    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1441
1442    // The outer builder only accumulates rows/tables and spells block boundaries
1443    // — both a function of the source and `last_off`, never of a node array — so
1444    // it carries an empty `nodes`. Each changed block is rendered by a *fresh*
1445    // builder over that block's subtree.
1446    let mut b = Builder {
1447        nodes: &[],
1448        source,
1449        wrap,
1450        rows: Vec::new(),
1451        tables: Vec::new(),
1452        last_off: 0,
1453        stepped_over: 0,
1454        media_rows,
1455        break_glyph: Cell::new(' '),
1456        preserve_soft,
1457        reveal: reveal.clone(),
1458        pending_mark_ends: RefCell::new(Vec::new()),
1459        presentation: Presentation::default(),
1460        faces: RefCell::new(FaceTable::default()),
1461    };
1462
1463    // Record the per-block row decomposition as we go, so a later
1464    // [`build_spliced`] can patch one block without rebuilding the map.
1465    let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
1466    // The document's face table, assembled block by block: from the walk on a
1467    // miss, from what the entry stored on a hit. The outer builder walks no
1468    // attributes of its own (it spells boundaries), so it never adds to it.
1469    let mut faces = FaceTable::default();
1470    let mut all_shift_safe = true;
1471    // The class of the last block that drew anything: what the next separator
1472    // closes, and what the trailing blank lines close at the end. A hidden block
1473    // (a comment) never becomes it — see [`Builder::block_or_hidden`], whose
1474    // step-over this loop repeats for the incremental walk.
1475    let mut above: Option<BlockClass> = None;
1476    for block in &blocks {
1477        let start = block.span.start;
1478        let before_sep = b.rows.len();
1479        if let Some(above) = above {
1480            // This walker has no node arena at all (see the `nodes: &[]` above),
1481            // but a top-level query match carries its kind — the same string
1482            // `BlockClass::from_node_kind` classifies for the whole-arena walk, so
1483            // the incremental and full builds label a boundary identically.
1484            b.emit_separators_before(
1485                start,
1486                &[],
1487                true,
1488                Boundary {
1489                    above,
1490                    below: BlockClass::from_node_kind(&block.kind),
1491                },
1492            );
1493        }
1494        let after_sep = b.rows.len();
1495        let bytes = block_bytes(source, &block.span);
1496        let hash = block_hash(bytes);
1497        // How this block meets the reveal line, if at all — part of its cache
1498        // key, since the same bytes render differently on the caret's line.
1499        let rkey = reveal_key(&reveal, &block.span);
1500
1501        // Hit: clone the block's rows shifted to its current offset and restore
1502        // the (shifted) `last_off` so the next separator lands right — no marshal.
1503        // Only shift-safe blocks are ever cached, so a hit is safe by construction.
1504        if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
1505            faces.merge(&hit.faces);
1506            let delta = start as isize - hit.built_start as isize;
1507            for row in &hit.rows {
1508                b.rows.push(shift_row(row, delta));
1509            }
1510            b.last_off = (hit.last_off as isize + delta) as usize;
1511            // `0` is a block that stepped over nothing, and no answer to shift.
1512            if hit.stepped_over > 0 {
1513                let stepped = (hit.stepped_over as isize + delta) as usize;
1514                b.stepped_over = b.stepped_over.max(stepped);
1515            }
1516        } else {
1517            // Miss: marshal just this block's subtree and render it. A subtree is
1518            // self-contained with local ids (root at 0) and absolute spans, so a
1519            // fresh builder over it produces the same rows the whole-arena path
1520            // would. An empty subtree (twig couldn't hand it back) renders nothing.
1521            let subtree = fetch_subtree(block.node_id);
1522            if !subtree.is_empty() {
1523                let mut sub = Builder {
1524                    nodes: &subtree,
1525                    source,
1526                    wrap,
1527                    rows: Vec::new(),
1528                    tables: Vec::new(),
1529                    last_off: 0,
1530                    stepped_over: 0,
1531                    media_rows,
1532                    break_glyph: Cell::new(' '),
1533                    preserve_soft,
1534                    reveal: reveal.clone(),
1535                    pending_mark_ends: RefCell::new(Vec::new()),
1536                    presentation: Presentation::default(),
1537                    faces: RefCell::new(FaceTable::default()),
1538                };
1539                sub.block(0, &[], &[]);
1540                // A block that drew nothing is stepped over, not stood on: its
1541                // `last_off` is its own end, so the separator after it counts
1542                // from there. The sub-builder started at 0 and never moved, and
1543                // 0 is where the next separator would otherwise count from —
1544                // every line of the document, as a blank row each.
1545                let last_off = if sub.rows.is_empty() {
1546                    block.span.end
1547                } else {
1548                    sub.last_off
1549                };
1550                let stepped_over = sub.stepped_over;
1551                b.stepped_over = b.stepped_over.max(stepped_over);
1552                // The names this block's glyph ids stand for. They go into the
1553                // document's table *and* into the cache entry, because a hit
1554                // re-emits these rows without walking an attribute again.
1555                let block_faces = sub.faces.into_inner();
1556                faces.merge(&block_faces);
1557                // Cache only a block that is table-free AND renders inside its own
1558                // span: those two are the conditions for reuse-by-shift to be
1559                // correct. A block failing either is re-rendered every build (a
1560                // fresh render always matches a fresh whole-document build).
1561                if sub.tables.is_empty() {
1562                    if rows_within(&sub.rows, &block.span) {
1563                        cache.store(
1564                            hash,
1565                            bytes,
1566                            start,
1567                            sub.rows.clone(),
1568                            last_off,
1569                            stepped_over,
1570                            rkey,
1571                            block_faces,
1572                        );
1573                    }
1574                    b.rows.extend(sub.rows);
1575                } else {
1576                    // A table block is never cached; rebase its row-index
1577                    // bookkeeping onto the combined row vector and append.
1578                    let base = b.rows.len();
1579                    for t in &mut sub.tables {
1580                        t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
1581                    }
1582                    b.rows.extend(sub.rows);
1583                    b.tables.extend(sub.tables);
1584                }
1585                b.last_off = last_off;
1586            }
1587        }
1588        let content_rows = b.rows.len() - after_sep;
1589        let sep_rows = if content_rows == 0 {
1590            // Hidden: take back the separator drawn for it, so what stands
1591            // either side meets across one boundary. Its layout entry stays, at
1592            // no rows, so the splice arithmetic still counts one entry per block.
1593            b.rows.truncate(before_sep);
1594            // A cache hit restored the stored `last_off` above; an empty subtree
1595            // (twig couldn't hand it back) restored nothing. Either way the walk
1596            // stands past the block.
1597            b.last_off = b.last_off.max(block.span.end);
1598            b.stepped_over = b.stepped_over.max(block.span.end);
1599            0
1600        } else {
1601            above = Some(BlockClass::from_node_kind(&block.kind));
1602            all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
1603            after_sep - before_sep
1604        };
1605        layout_blocks.push(BlockLayout {
1606            span: block.span.clone(),
1607            kind: block.kind.clone(),
1608            sep_rows,
1609            content_rows,
1610        });
1611    }
1612
1613    let before_trailing = b.rows.len();
1614    let hidden_end = hidden_prefix_end(
1615        source,
1616        top.iter()
1617            .filter(|m| m.kind == Kind::Metadata)
1618            .map(|m| m.span.end)
1619            .next_back(),
1620    );
1621    b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
1622    let trailing_rows = b.rows.len() - before_trailing;
1623
1624    // Evict every entry no block reused this build, so the cache tracks the
1625    // current document instead of growing without bound over a session.
1626    let g = cache.generation;
1627    cache.entries.retain(|_, bucket| {
1628        bucket.retain(|e| e.generation == g);
1629        !bucket.is_empty()
1630    });
1631
1632    cache.layout = Layout {
1633        blocks: layout_blocks,
1634        trailing_rows,
1635        built_len: source.len(),
1636        has_tables: !b.tables.is_empty(),
1637        all_shift_safe,
1638        reveal: reveal.clone(),
1639    };
1640
1641    // The first rendered offset is the first non-metadata block's start — the
1642    // analogue of [`first_content_offset`] for the top-level list. With nothing
1643    // but frontmatter it's the end of that frontmatter, and 0 for an empty
1644    // document ([`hidden_prefix_end`]).
1645    let content_start = blocks.first().map_or(hidden_end, |m| m.span.start);
1646    let stops = collect_stops(&b.rows);
1647    let mark_ends = collect_mark_ends(&b.rows);
1648    label_media_boundaries(&mut b.rows);
1649    let code_blocks = code_block_spans(&b.rows);
1650    let media = media_spans(&b.rows);
1651    let directives = directive_spans(&b.rows);
1652    VisualMap {
1653        rows: b.rows,
1654        content_start,
1655        stops,
1656        mark_ends,
1657        tables: b.tables,
1658        code_blocks,
1659        media,
1660        directives,
1661        faces,
1662    }
1663}
1664
1665/// The fast path for a single-block edit: patch the previous [`VisualMap`] in
1666/// place rather than reassembling it. Returns `Some(new_map)` when it applies,
1667/// or `None` to tell the caller to fall back to [`build_cached`] (always
1668/// correct). Consumes `prev` either way — on `None` the caller rebuilds from
1669/// scratch and doesn't need it.
1670///
1671/// It applies only when `dirty` (twig's dirty byte range) falls inside exactly
1672/// one top-level block AND the block structure around it is unchanged — verified
1673/// by matching the new `top` list against the previous [`Layout`] block for
1674/// block: kinds unchanged, spans before the edit identical, spans after it
1675/// shifted by the byte delta, count unchanged. Any deviation — a block split or
1676/// merged, a fence opened to swallow later blocks, a table anywhere, a
1677/// multi-block edit — fails the match and returns `None`. That check is what
1678/// makes the byte-range trustworthy: twig's dirty range is exact about *bytes*
1679/// but silent about *reparse*, and the structural match catches the reparse
1680/// effects it can't see.
1681///
1682/// When it applies, the unchanged prefix rows move verbatim, the suffix rows
1683/// shift by the delta *in place* (integer adds, no glyph copy), and only the one
1684/// dirty block is re-marshalled and re-rendered; stops splice the same way by
1685/// offset. So the cost is O(rows after the edit), and nothing before the edit is
1686/// touched. The hash-keyed entry cache is left alone — a later [`build_cached`]
1687/// will miss on the changed block, re-render it, and evict the stale entry, so
1688/// chained splices neither corrupt nor grow it.
1689// One builder, and every one of these is a distinct input to the same layout
1690// pass — a struct of them would be built at the one call site and unpacked
1691// here, which is the same arguments with an extra name in the way.
1692#[allow(clippy::too_many_arguments)]
1693pub fn build_spliced(
1694    prev: VisualMap,
1695    source: &str,
1696    wrap: Option<usize>,
1697    preserve_soft: bool,
1698    top: &[QueryMatch],
1699    dirty: Range<usize>,
1700    media_rows: &HashMap<String, usize>,
1701    reveal: Option<Range<usize>>,
1702    cache: &mut BlockCache,
1703    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1704) -> Option<VisualMap> {
1705    let wrap = wrap.map(|w| w.max(8));
1706    // A width change invalidates every cached row — a full rebuild's job.
1707    if cache.wrap != Some(wrap) {
1708        return None;
1709    }
1710    // So does a moved reveal line, and for the same reason: this path reuses
1711    // every row outside the dirty block, and those rows encode which line was
1712    // showing its raw markup when they were built. Typing almost always moves
1713    // the caret, so under `MarkupMode::Full` this bails to `build_cached` on
1714    // most keystrokes — still block-cached, so only the edited block and the
1715    // revealed one actually re-render.
1716    if cache.layout.reveal != reveal {
1717        return None;
1718    }
1719    // Take the previous layout; on any bail below the caller rebuilds it (and the
1720    // map) via `build_cached`, so leaving it empty is fine. A table or a block
1721    // that renders outside its span (a degenerate inline span) makes shifting
1722    // unsound, so those force the full-rebuild path.
1723    let prev_layout = std::mem::take(&mut cache.layout);
1724    if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
1725        return None;
1726    }
1727    // The layout addresses `prev` by row index, so it is only usable against the
1728    // map it was built from. A frontend is free to hold the map it was handed and
1729    // present it differently — leaf-ratatui splices blank filler rows under an
1730    // oversized heading so the raster has somewhere to stand — and if one of those
1731    // comes back here the row arithmetic below lands on the wrong rows: the
1732    // re-rendered block is laid over a filler and the rows it really occupied
1733    // survive into the suffix, stranding a stale copy of the edited line and
1734    // pushing everything after it one row down, once per keystroke. A row count
1735    // that doesn't match what this layout describes is the tell, and the honest
1736    // answer is the full rebuild.
1737    let described_rows = prev_layout
1738        .blocks
1739        .iter()
1740        .map(|pl| pl.sep_rows + pl.content_rows)
1741        .sum::<usize>()
1742        + prev_layout.trailing_rows;
1743    if described_rows != prev.rows.len() {
1744        return None;
1745    }
1746
1747    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1748    if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
1749        return None;
1750    }
1751    let delta = source.len() as isize - prev_layout.built_len as isize;
1752
1753    // The single block whose NEW span contains the whole dirty range. A dirty
1754    // range straddling a block boundary (or a separator) finds none → bail.
1755    let k = blocks
1756        .iter()
1757        .position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
1758
1759    // Structural match: every OTHER block is unchanged — same kind throughout,
1760    // span identical before the edit and shifted by `delta` after it. A mismatch
1761    // means the reparse reshaped the block structure, which only a full rebuild
1762    // renders correctly.
1763    for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
1764        if m.kind != pl.kind {
1765            return None;
1766        }
1767        if i == k {
1768            continue;
1769        }
1770        let want = if i < k {
1771            pl.span.clone()
1772        } else {
1773            (pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
1774        };
1775        if m.span != want {
1776            return None;
1777        }
1778    }
1779    // The dirty block itself: start unchanged (the edit is inside it, past its
1780    // start), end moved by exactly the delta.
1781    let pk_start = prev_layout.blocks[k].span.start;
1782    let pk_end = prev_layout.blocks[k].span.end;
1783    let pk_sep = prev_layout.blocks[k].sep_rows;
1784    let pk_content = prev_layout.blocks[k].content_rows;
1785    if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
1786    {
1787        return None;
1788    }
1789
1790    // Re-render the dirty block from its subtree. A table makes the splice
1791    // bookkeeping unsafe, so bail if one appears.
1792    let subtree = fetch_subtree(blocks[k].node_id);
1793    if subtree.is_empty() {
1794        return None;
1795    }
1796    let mut sub = Builder {
1797        nodes: &subtree,
1798        source,
1799        wrap,
1800        rows: Vec::new(),
1801        tables: Vec::new(),
1802        last_off: 0,
1803        stepped_over: 0,
1804        media_rows,
1805        break_glyph: Cell::new(' '),
1806        preserve_soft,
1807        reveal: reveal.clone(),
1808        pending_mark_ends: RefCell::new(Vec::new()),
1809        presentation: Presentation::default(),
1810        faces: RefCell::new(FaceTable::default()),
1811    };
1812    sub.block(0, &[], &[]);
1813    // A table, or content that renders outside the block's span (a degenerate
1814    // inline span), makes the shift bookkeeping unsound — fall back.
1815    if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
1816        return None;
1817    }
1818    // The face table starts from the previous map's, because every row this
1819    // path keeps was built against it and its glyphs' ids still mean what they
1820    // meant. The re-rendered block adds whatever it met. An id the edit took
1821    // the last glyph of stays in the table, naming nothing — the price of not
1822    // walking the rows this path exists to avoid walking.
1823    let mut faces = prev.faces;
1824    faces.merge(&sub.faces.into_inner());
1825    let new_content = sub.rows;
1826    let new_content_len = new_content.len();
1827    let new_stops = collect_stops(&new_content);
1828    let new_mark_ends = collect_mark_ends(&new_content);
1829
1830    // Row span of the dirty block's CONTENT. Its leading separator stays in the
1831    // prefix: the gap before block k is unchanged, since k's start didn't move.
1832    let content_start_row: usize = prev_layout.blocks[..k]
1833        .iter()
1834        .map(|pl| pl.sep_rows + pl.content_rows)
1835        .sum::<usize>()
1836        + pk_sep;
1837    let content_end_row = content_start_row + pk_content;
1838
1839    // Splice rows: [prefix | new content | suffix + delta]. The prefix moves
1840    // untouched; the suffix shifts in place — integer adds, no glyph copy.
1841    let mut rows = prev.rows;
1842    let mut suffix = rows.split_off(content_end_row);
1843    rows.truncate(content_start_row);
1844    for row in &mut suffix {
1845        shift_row_in_place(row, delta);
1846    }
1847    rows.reserve(new_content_len + suffix.len());
1848    rows.extend(new_content);
1849    rows.extend(suffix);
1850
1851    // Splice stops by offset. The old dirty block covered `[pk_start, pk_end]`:
1852    // prefix stops fall below it, suffix stops above it (shift by delta), the new
1853    // content supplies the middle. The three ranges stay disjoint and ascending,
1854    // so the result needs no re-sort.
1855    let p1 = prev.stops.partition_point(|&s| s < pk_start);
1856    let p2 = prev.stops.partition_point(|&s| s <= pk_end);
1857    let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
1858    stops.extend_from_slice(&prev.stops[..p1]);
1859    stops.extend(new_stops);
1860    for &s in &prev.stops[p2..] {
1861        stops.push((s as isize + delta) as usize);
1862    }
1863    // The mark ends splice the same way: they are offsets in the same
1864    // coordinates, cut at the same block.
1865    let m1 = prev.mark_ends.partition_point(|&s| s < pk_start);
1866    let m2 = prev.mark_ends.partition_point(|&s| s <= pk_end);
1867    let mut mark_ends = Vec::with_capacity(m1 + new_mark_ends.len() + (prev.mark_ends.len() - m2));
1868    mark_ends.extend_from_slice(&prev.mark_ends[..m1]);
1869    mark_ends.extend(new_mark_ends);
1870    for &s in &prev.mark_ends[m2..] {
1871        mark_ends.push((s as isize + delta) as usize);
1872    }
1873
1874    // Record the patched layout for the next splice: spans move to the new
1875    // coordinates, and the dirty block takes its new content-row count.
1876    let mut new_blocks = prev_layout.blocks;
1877    for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
1878        pl.span = m.span.clone();
1879    }
1880    new_blocks[k].content_rows = new_content_len;
1881    cache.layout = Layout {
1882        blocks: new_blocks,
1883        trailing_rows: prev_layout.trailing_rows,
1884        built_len: source.len(),
1885        has_tables: false,
1886        // Every prefix/suffix block was shift-safe last build (we bailed
1887        // otherwise) and the re-rendered block was just checked, so the patched
1888        // document is still entirely shift-safe.
1889        all_shift_safe: true,
1890        reveal,
1891    };
1892
1893    label_media_boundaries(&mut rows);
1894    let code_blocks = code_block_spans(&rows);
1895    let media = media_spans(&rows);
1896    let directives = directive_spans(&rows);
1897    Some(VisualMap {
1898        rows,
1899        content_start: blocks[0].span.start,
1900        stops,
1901        mark_ends,
1902        tables: Vec::new(),
1903        code_blocks,
1904        media,
1905        directives,
1906        faces,
1907    })
1908}
1909
1910/// A persistent, content-keyed cache of the rows each top-level block renders
1911/// to — the [`VisualMap`] analogue of the GUI's ShapedLine cache, one level
1912/// down. Held by a [`crate::Doc`] and threaded into [`build_cached`], it is what
1913/// makes a rebuild after a keystroke cost "re-render the edited block + shift
1914/// the rest" instead of re-rendering the whole document.
1915///
1916/// A top-level block's rows are a pure function of its source bytes and the wrap
1917/// width, so an unchanged block's rows are cloned and their source offsets
1918/// shifted by the edit's byte delta rather than rebuilt glyph by glyph. Two
1919/// things make that purity hold: at the top level the render prefix is always
1920/// empty (nesting prefixes — a quote gutter, a list indent — exist only *inside*
1921/// a top-level block, within its cached unit), and a block's output never reads
1922/// the incoming `last_off` (it writes `last_off` from its own content before any
1923/// nested separator reads it). So the only thing that differs between two
1924/// positions of an unchanged block is a uniform offset shift. Keyed by a fast
1925/// hash of the block's bytes with the bytes kept for a verify-on-hit — exactly
1926/// the shape cache's weak-hash-then-compare, so a collision costs a re-render,
1927/// never a wrong row.
1928///
1929/// Tables are never cached (a block that emits any table row is always rebuilt):
1930/// their rows are cross-referenced from the map's `tables` side-table by row
1931/// index, which a blind offset-shift wouldn't fix up, and they are rare enough
1932/// that the simplicity beats the reuse.
1933#[derive(Default)]
1934pub struct BlockCache {
1935    /// The wrap width every entry was built at; a change invalidates all of
1936    /// them. `None` before the first build (distinct from `Some(None)`, the
1937    /// unwrapped GUI width).
1938    wrap: Option<Option<usize>>,
1939    /// Bumped once per [`build_cached`]. An entry reused or inserted this build
1940    /// carries the current value; stale entries are dropped at the end of it.
1941    generation: u64,
1942    /// `hash(bytes)` → the block(s) sharing that hash — a bucket because
1943    /// distinct blocks can collide, while two *identical* blocks share one entry
1944    /// (free dedup).
1945    entries: HashMap<u64, Vec<CachedBlock>>,
1946    /// The row/stop decomposition of the last build, which [`build_spliced`]
1947    /// patches in place for a single-block edit. Kept in step with whatever
1948    /// [`VisualMap`] was last produced; empty before the first build.
1949    layout: Layout,
1950}
1951
1952/// How the last build's [`VisualMap`] decomposes into top-level blocks — the
1953/// bookkeeping [`build_spliced`] needs to splice one block's rows and stops
1954/// without rebuilding the whole map. Every field describes the *previous* build,
1955/// in that build's coordinates.
1956#[derive(Default)]
1957struct Layout {
1958    /// One entry per rendered (metadata-filtered) top-level block, in order.
1959    blocks: Vec<BlockLayout>,
1960    /// Trailing blank rows past the last block (from `emit_trailing_blank_lines`).
1961    trailing_rows: usize,
1962    /// The source length this layout was built at — the reference for the edit's
1963    /// byte delta.
1964    built_len: usize,
1965    /// Whether the last build drew any table. A table's cross-referenced row
1966    /// indices don't survive a blind splice, so their presence makes
1967    /// [`build_spliced`] bail to a full rebuild.
1968    has_tables: bool,
1969    /// Whether every block rendered strictly inside its own span (see
1970    /// [`rows_within`]). A block that doesn't — a malformed Markdown inline node
1971    /// that twig leaves with a degenerate `0..0` span renders at a fixed offset
1972    /// outside its block — can't be shifted correctly, so its presence makes
1973    /// [`build_spliced`] bail to a full rebuild.
1974    all_shift_safe: bool,
1975    /// The reveal line this layout was built under (see [`Builder::reveal`]).
1976    /// A splice reuses every row it isn't re-rendering, so a reveal line that
1977    /// has moved would leave the old line still showing its delimiters and the
1978    /// new one still hiding them — [`build_spliced`] bails when this changes.
1979    reveal: Option<Range<usize>>,
1980}
1981
1982/// One top-level block's contribution to the last build: its span and kind (for
1983/// the structural match that proves only one block changed) and how many
1984/// separator and content rows it emitted (to locate its slice of the row
1985/// vector).
1986struct BlockLayout {
1987    span: Range<usize>,
1988    kind: Kind,
1989    sep_rows: usize,
1990    content_rows: usize,
1991}
1992
1993/// One cached block: the rows it rendered to, plus what a reuse at a new
1994/// position needs to shift them. Offsets are stored absolute (as built) and
1995/// shifted by `new_start - built_start` on reuse.
1996struct CachedBlock {
1997    /// The block's exact source bytes, compared on a hash hit so a collision
1998    /// can never hand back another block's rows.
1999    bytes: Box<[u8]>,
2000    /// The offset the rows were built at (the block's `span.start`).
2001    built_start: usize,
2002    /// The block's rows, offsets absolute as built.
2003    rows: Vec<VRow>,
2004    /// `last_off` after this block was emitted, absolute as built — restored
2005    /// (shifted) on reuse so the following separator lands correctly.
2006    last_off: usize,
2007    /// `stepped_over` after this block was emitted, absolute as built — the
2008    /// hidden tail a block ends with (a `</div>`), restored with `last_off`
2009    /// so the trailing blank lines are counted from past it on a hit too.
2010    stepped_over: usize,
2011    /// Where the reveal line fell *within this block* when the rows were built,
2012    /// as a block-relative byte range — see [`reveal_key`]. Compared alongside
2013    /// `bytes` on a hit, because identical source renders to different rows
2014    /// depending on whether the caret's line is inside it: the same `*em*`
2015    /// shows its asterisks on the revealed line and hides them everywhere else.
2016    ///
2017    /// Block-relative rather than absolute so an unaffected block still hits
2018    /// after an edit shifts it, and `None` for the overwhelmingly common
2019    /// no-reveal case — which is why an entry stored under `MarkupMode::None`
2020    /// keeps hitting for every block that isn't the caret's.
2021    reveal: Option<Range<usize>>,
2022    /// The named families this block's glyphs are set in — see
2023    /// [`VisualMap::faces`]. Stored with the rows because a hit re-emits them
2024    /// without walking a `data-font` again, and the map still has to be able to
2025    /// say what the id on a reused glyph names. Empty for every block that
2026    /// names no family, which is nearly all of them.
2027    faces: FaceTable,
2028    /// The build that last reused or inserted this entry (see `generation`).
2029    generation: u64,
2030}
2031
2032/// Where `reveal` falls inside a block, in block-relative bytes — the extra key
2033/// a cached block is stored and matched under.
2034///
2035/// `None` when the block doesn't meet the reveal line at all, which is every
2036/// block on every build in the two hidden modes, and all but one of them under
2037/// [`crate::MarkupMode::Full`]. So the cache keeps its hit rate as the caret
2038/// moves: only the line the caret leaves and the line it arrives at re-render.
2039fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
2040    let r = reveal.as_ref()?;
2041    // The same generous intersection test `Builder::revealed` uses, so a block
2042    // is keyed as revealed exactly when its glyphs will be built that way.
2043    (span.start <= r.end && r.start <= span.end).then(|| {
2044        let start = r.start.max(span.start) - span.start;
2045        let end = r.end.min(span.end) - span.start;
2046        start..end
2047    })
2048}
2049
2050impl BlockCache {
2051    /// Look up a block by hash, verify its bytes and reveal key, and on a hit
2052    /// stamp it used this build and hand back a borrow to shift-and-clone from.
2053    /// `None` on a miss (unknown hash, a collision whose bytes differ, or the
2054    /// same bytes built under a different reveal).
2055    fn reuse(
2056        &mut self,
2057        hash: u64,
2058        bytes: &[u8],
2059        reveal: &Option<Range<usize>>,
2060    ) -> Option<&CachedBlock> {
2061        let g = self.generation;
2062        let bucket = self.entries.get_mut(&hash)?;
2063        let e = bucket
2064            .iter_mut()
2065            .find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
2066        e.generation = g;
2067        Some(&*e)
2068    }
2069
2070    /// Cache the rows a freshly-rendered block produced (or refresh an existing
2071    /// entry for the same bytes and reveal — an identical block elsewhere, or a
2072    /// re-render).
2073    #[allow(clippy::too_many_arguments)]
2074    fn store(
2075        &mut self,
2076        hash: u64,
2077        bytes: &[u8],
2078        built_start: usize,
2079        rows: Vec<VRow>,
2080        last_off: usize,
2081        stepped_over: usize,
2082        reveal: Option<Range<usize>>,
2083        faces: FaceTable,
2084    ) {
2085        let g = self.generation;
2086        let bucket = self.entries.entry(hash).or_default();
2087        if let Some(e) = bucket
2088            .iter_mut()
2089            .find(|e| &*e.bytes == bytes && e.reveal == reveal)
2090        {
2091            e.built_start = built_start;
2092            e.rows = rows;
2093            e.last_off = last_off;
2094            e.stepped_over = stepped_over;
2095            e.faces = faces;
2096            e.generation = g;
2097        } else {
2098            bucket.push(CachedBlock {
2099                bytes: bytes.into(),
2100                built_start,
2101                rows,
2102                last_off,
2103                stepped_over,
2104                reveal,
2105                faces,
2106                generation: g,
2107            });
2108        }
2109    }
2110}
2111
2112/// The source bytes a top-level block covers — the block cache's key material.
2113///
2114/// Clamped to the source rather than sliced by the span as twig gives it,
2115/// because that span can end *past* the last byte: the final block of a document
2116/// with no trailing newline is closed on the virtual newline the parser supplies
2117/// at EOF, so its `span.end` is `source.len() + 1`. Slicing by such a range
2118/// yields `None`, and the obvious `unwrap_or(&[])` reads that as *this block has
2119/// no bytes* — the wrong answer twice over.
2120///
2121/// Two blocks whose spans both overrun then key alike, and the second is served
2122/// the first one's rows. That is not hypothetical: a footnote definition is a
2123/// root beside `doc` merged back into the top level by [`top_blocks`], while the
2124/// `section` above it spans the definition's bytes too, so both end at EOF —
2125/// and a document ending in `[^note]: …` renders that definition as a second
2126/// copy of the heading. Even alone, a block that keeps hashing empty as the user
2127/// types in it is served the stale rows built before the edit.
2128///
2129/// Clamping hands back the bytes the block really covers, which tells both cases
2130/// apart, and costs nothing for a span that was in range to begin with.
2131fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
2132    let bytes = source.as_bytes();
2133    let start = span.start.min(bytes.len());
2134    &bytes[start..span.end.clamp(start, bytes.len())]
2135}
2136
2137/// A fast, allocation-free content hash (FNV-1a) for a block's bytes. Weak by
2138/// design — the bytes are compared on a hit — so its only job is to spread
2139/// blocks across buckets cheaply. SipHash over every block's bytes on every
2140/// keystroke would cost more than it saves, the same lesson the shape cache
2141/// learned when it stopped hashing through the standard hasher.
2142fn block_hash(bytes: &[u8]) -> u64 {
2143    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2144    for &x in bytes {
2145        h ^= x as u64;
2146        h = h.wrapping_mul(0x0000_0100_0000_01b3);
2147    }
2148    h
2149}
2150
2151/// Clone a cached row with every source offset advanced by `delta` — the whole
2152/// cost of reusing an unchanged block: integer adds where a rebuild would
2153/// re-shape every glyph.
2154fn shift_row(row: &VRow, delta: isize) -> VRow {
2155    let shift = |off: usize| (off as isize + delta) as usize;
2156    VRow {
2157        glyphs: row
2158            .glyphs
2159            .iter()
2160            .map(|g| Glyph {
2161                ch: g.ch,
2162                style: g.style,
2163                src: shift(g.src),
2164                stop: g.stop,
2165            })
2166            .collect(),
2167        end_src: shift(row.end_src),
2168        decoration: row.decoration,
2169        code: row.code,
2170        code_lang: row.code_lang.clone(),
2171        directive: row.directive,
2172        directive_label: row.directive_label.clone(),
2173        media: row.media.clone(),
2174        // A tick, not an offset — reuse carries it as-is, like `code_lang`.
2175        task: row.task,
2176        leaf_directive: row.leaf_directive.clone(),
2177        heading: row.heading,
2178        // Presentation, not offsets: names the author wrote, which a shifted
2179        // block still wears — like `code_lang`.
2180        align: row.align,
2181        line_height: row.line_height,
2182        // Structure, not offsets: a reused block's rows divide the same blocks
2183        // wherever the edit above moved them to.
2184        boundary: row.boundary,
2185        mark_ends: row.mark_ends.iter().map(|&o| shift(o)).collect(),
2186    }
2187}
2188
2189/// Advance a row's source offsets by `delta` in place — the suffix half of
2190/// [`build_spliced`], where the rows are already owned and only need shifting,
2191/// not copying.
2192fn shift_row_in_place(row: &mut VRow, delta: isize) {
2193    for g in &mut row.glyphs {
2194        g.src = (g.src as isize + delta) as usize;
2195    }
2196    row.end_src = (row.end_src as isize + delta) as usize;
2197    for o in &mut row.mark_ends {
2198        *o = (*o as isize + delta) as usize;
2199    }
2200}
2201
2202/// Whether every source offset a block's rows carry falls inside the block's own
2203/// span — the precondition for reusing the block by a uniform offset shift. It
2204/// holds for well-formed blocks (their glyphs and row ends address bytes within
2205/// the block, synthetic glyphs point at the block start). It fails when a node
2206/// renders *outside* its block, which today means a malformed Markdown inline
2207/// node twig leaves with a degenerate `0..0` span: that content lands at a fixed
2208/// offset that doesn't move with the block. Such a block is re-rendered every
2209/// build instead of shifted, so the incremental map still matches a fresh one —
2210/// see [`build_cached`] and [`build_spliced`].
2211fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
2212    rows.iter().all(|r| {
2213        r.end_src >= span.start
2214            && r.end_src <= span.end
2215            && r.glyphs
2216                .iter()
2217                .all(|g| g.src >= span.start && g.src <= span.end)
2218    })
2219}
2220
2221/// Where the rendered document begins when a leading `metadata` block is all
2222/// there is — the end of that hidden frontmatter, past the newline that closes
2223/// its last line so the floor sits at the start of the (empty) body rather than
2224/// on the closing `---`.
2225///
2226/// With a real block after it the frontmatter's end is never needed: the floor
2227/// is that block's start, and the rows begin there. With nothing after it, both
2228/// the caret floor and the trailing-blank-line count would otherwise fall back
2229/// to offset 0 — inside the hidden frontmatter — which put the caret *before*
2230/// the metadata and made typing land ahead of the opening `---`.
2231fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
2232    let Some(end) = meta_end else { return 0 };
2233    let end = end.min(source.len());
2234    let rest = &source[end..];
2235    if rest.starts_with("\r\n") {
2236        end + 2
2237    } else if rest.starts_with('\n') {
2238        end + 1
2239    } else {
2240        end
2241    }
2242}
2243
2244/// The end of the document's hidden frontmatter: the last `metadata` child of
2245/// `doc`, which is what [`top_level`] and [`Builder::blocks`] drop. `None` when
2246/// there is none.
2247fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
2248    let mut end = None;
2249    let mut child = nodes[doc].first_child;
2250    while let Some(cid) = child {
2251        let n = &nodes[cid.0 as usize];
2252        if n.kind == Kind::Metadata {
2253            end = Some(n.span.end);
2254        }
2255        child = n.next_sibling;
2256    }
2257    end
2258}
2259
2260/// The document's rendered top-level blocks, as node indices in source order.
2261///
2262/// Not simply `doc`'s children, for two reasons. Frontmatter (a leading
2263/// `metadata` block) is document metadata rather than prose and is dropped, the
2264/// way [`Builder::blocks`] drops it. And a **footnote definition** (`[^1]: …`)
2265/// is not a child of `doc` at all: twig parses it as a root of its own, a
2266/// *sibling* of the document node with `parent == None`. A walk that starts at
2267/// `doc` therefore never reaches one, which is why a definition — and every
2268/// byte of its body — used to render as nothing at all. Merging the roots back
2269/// in by `span.start` puts each definition on screen exactly where it was
2270/// written, which is what keeps rows, stops, and offsets monotonic.
2271///
2272/// A **link reference definition** (`[foo]: /url`) is a root of the same kind,
2273/// and is merged for the opposite reason: it draws *nothing*, and the walk has
2274/// to know where it stands to step over it. A definition closing a README —
2275/// the `[links]: …` block under the prose — left no block over its lines, so
2276/// the separator logic read them as blank lines and drew an empty paragraph
2277/// per definition. Merged in, it is a hidden block like a comment, and
2278/// [`Builder::block_or_hidden`] moves the walk past it. One with no span
2279/// (`0..0`, what twig before 3.3.3 reported for every one) has nowhere to be
2280/// merged, and is left out as before.
2281///
2282/// Only those roots are merged. twig also leaves stray orphan `str` nodes
2283/// parented to nothing (the `*` of an emphasis run, for one); those are already
2284/// rendered as part of the subtree that owns their bytes, and re-emitting them
2285/// here would double them.
2286fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
2287    let mut out = Vec::new();
2288    let mut child = nodes[doc].first_child;
2289    while let Some(cid) = child {
2290        let n = &nodes[cid.0 as usize];
2291        if n.kind != Kind::Metadata {
2292            out.push(cid.0 as usize);
2293        }
2294        child = n.next_sibling;
2295    }
2296    out.extend(
2297        nodes
2298            .iter()
2299            .enumerate()
2300            .filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
2301            .map(|(i, _)| i),
2302    );
2303    out.sort_by_key(|&i| nodes[i].span.start);
2304    out
2305}
2306
2307/// Is a parentless node of `kind` at `span` a definition the top-level walk
2308/// merges in — a footnote definition, or a link reference definition that
2309/// knows where it stands? Shared by [`top_level`] and [`top_blocks`] so the
2310/// two walks cannot disagree about what the top-level blocks are.
2311fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
2312    match *kind {
2313        Kind::Footnote => true,
2314        Kind::Reference => span.end > span.start,
2315        _ => false,
2316    }
2317}
2318
2319/// The top-level blocks to hand [`build_cached`] / [`build_spliced`] — the
2320/// incremental path's twin of [`top_level`], which the two must agree with block
2321/// for block or the render paths diverge.
2322///
2323/// `child_spans(None)` gives `doc`'s children, which is all of them for an
2324/// ordinary document. A **footnote definition** is not one: twig parses `[^1]: …`
2325/// as a root beside `doc` with no parent, and indexes it at no offset either —
2326/// `node_at` inside its bytes answers `doc`, and a `query("footnote")` selector
2327/// finds nothing. Leaf used to discover them by marshalling the whole arena with
2328/// `nodes()` — the very cost the incremental path exists to avoid — behind a
2329/// byte-scan gate that gave documents with no `[^…]:` line a substring search
2330/// instead. twig 3.0's `definitions()` asks the library the question directly,
2331/// so both the marshal and the gate are gone.
2332///
2333/// The link reference definitions `definitions()` also reports are merged on
2334/// the same terms as [`top_level`] merges them — see [`is_placed_definition`].
2335///
2336/// This is the one part of the render that needs an [`Editor`] rather than a
2337/// marshalled node array. The builders themselves stay editor-free; this only
2338/// prepares their input.
2339pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
2340    let mut top = editor.child_spans(None).unwrap_or_default();
2341    let defs: Vec<QueryMatch> = definitions(editor)
2342        .into_iter()
2343        .filter(|m| is_placed_definition(&m.kind, &m.span))
2344        .collect();
2345    if defs.is_empty() {
2346        return top;
2347    }
2348    top.extend(defs);
2349    // Source order — what every offset-keyed thing downstream (rows, stops, the
2350    // splice path's block-for-block match) is built to assume.
2351    top.sort_by_key(|m| m.span.start);
2352    top
2353}
2354
2355/// Every `[^label]: …` definition in the document, in whatever order twig
2356/// reports them.
2357///
2358/// Filtered to [`Kind::Footnote`]: `definitions()` also reports the *link*
2359/// reference definitions (`[foo]: /url`), which are [`top_blocks`]'s business
2360/// and not [`crate::Doc::footnote_at_caret`]'s.
2361///
2362/// Empty when the document can't be walked, which leaves [`top_blocks`] with
2363/// the ordinary top-level children and [`crate::Doc::footnote_at_caret`] with an
2364/// undefined reference — in both cases the same answer as a document that has
2365/// no definitions, which is the right way to degrade.
2366pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2367    definitions(editor)
2368        .into_iter()
2369        .filter(|m| m.kind == Kind::Footnote)
2370        .collect()
2371}
2372
2373/// Every definition twig resolves by label rather than by position — footnote
2374/// and link reference definitions both — or nothing when the document can't be
2375/// walked.
2376fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2377    let Ok(mut doc) = editor.document() else {
2378        return Vec::new();
2379    };
2380    doc.definitions().unwrap_or_default()
2381}
2382
2383/// The label of the footnote definition starting at `start` — the `1` in
2384/// `[^1]: …`. twig gives the `footnote` node no label of its own (no `text`, no
2385/// `name`), and the bytes that spell it belong to no child node either — the
2386/// body `para` starts its *content* past them — so the source is the only place
2387/// to read it from. `None` when what's there isn't a definition after all.
2388pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
2389    let rest = source.get(start..)?.strip_prefix("[^")?;
2390    let end = rest.find("]:")?;
2391    Some(&rest[..end])
2392}
2393
2394/// Where the body of the footnote definition spanning `span` sits in `source` —
2395/// everything past the `[^1]:` marker, which is the part a reader actually wants
2396/// when they follow a reference.
2397///
2398/// Source bytes, verbatim but for the whitespace trimmed off each end: a note
2399/// that says `see *later*` answers with the asterisks in. Rendering that body is
2400/// a frontend's business the same way painting a [`Role`] is, and a caller that
2401/// wants it laid out already has the definition on screen where it was written.
2402///
2403/// The trim is what makes the common case read right — `[^1]: text` has a space
2404/// after the colon that belongs to the marker, not the note, and a definition's
2405/// span runs to the newline ending it.
2406///
2407/// The span is taken at its word, which it has only been safe to do since twig
2408/// 3.1: a djot definition's span used to run *past* its own last line, through
2409/// the blank line separating it from the next block and into that block's first
2410/// byte, so `[^2a]: a note.` came back as `"a note.\n\n["` and the offsets named
2411/// the following note's rows as well as this one's — a reader asking about one
2412/// footnote was shown two. leaf measured the body itself to get around that, and
2413/// paid for it: the scan stopped at the first blank line, so a note with a second
2414/// indented paragraph lost it. Both halves go away with the fix, since a blank
2415/// line *inside* a definition was always interior to the span and still is.
2416///
2417/// A range rather than a slice because "go to note" needs the *position* as much
2418/// as the text, and it needs the position of the body specifically: a
2419/// definition's `[^1]:` marker is decoration the caret can't occupy (the rich
2420/// view draws it as `[1] ` and gives it no stop), so aiming a caret at the
2421/// definition's first byte lands it on the nearest real stop instead — which is
2422/// up in the paragraph *above* the note. The body's first byte is a stop, and is
2423/// where a reader following a reference wants to arrive anyway.
2424pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
2425    let rest = source.get(span.clone())?.strip_prefix("[^")?;
2426    let marker = rest.find("]:")?;
2427    // `span.start` + `[^` + the label + `]:`.
2428    let after_marker = span.start + 2 + marker + 2;
2429    let raw = source.get(after_marker..span.end)?;
2430    // Written as a start plus a length so an all-whitespace body lands on an
2431    // empty range at the end rather than an inverted one.
2432    let start = after_marker + (raw.len() - raw.trim_start().len());
2433    Some(start..start + raw.trim().len())
2434}
2435
2436/// The label of the footnote *reference* spanning `span` — the `1` in `[^1]`.
2437///
2438/// The peer of [`footnote_label`] for the other half of the pair, and needed for
2439/// the same reason: a reference whose node carries neither a `content_span` nor
2440/// a `text` still spells its label plainly in the source. `None` when the bytes
2441/// aren't a reference after all.
2442pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
2443    let rest = source.get(span)?.strip_prefix("[^")?;
2444    let end = rest.find(']')?;
2445    Some(&rest[..end])
2446}
2447
2448/// Where a heading's *content* starts — past the `#`s and the space the rich
2449/// view hides, for an ATX heading; the block's own start for a setext one (which
2450/// has no leading marker) and for a format that spells headings some other way.
2451///
2452/// Only an empty heading needs asking: with any content at all, the row ends on
2453/// its last glyph. Bounded to the heading's own first line so a marker-less
2454/// heading can't scan into the text under it.
2455fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
2456    let end = span.end.min(source.len());
2457    let Some(line) = source.get(span.start..end) else {
2458        return span.start;
2459    };
2460    let line = line.split('\n').next().unwrap_or("");
2461    let hashes = line.len() - line.trim_start_matches('#').len();
2462    if hashes == 0 {
2463        return span.start;
2464    }
2465    let after = &line[hashes..];
2466    span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
2467}
2468
2469struct Builder<'a> {
2470    nodes: &'a [FlatNode],
2471    /// The document source, consulted to place blank-line rows at the source
2472    /// offsets the caret should occupy on them (the AST drops blank lines).
2473    source: &'a str,
2474    /// The word-wrap column budget, or `None` to emit each block as a single
2475    /// unwrapped row (the frontend wraps).
2476    wrap: Option<usize>,
2477    rows: Vec<VRow>,
2478    /// Built alongside `rows`, never instead of them — see [`TableInfo`].
2479    tables: Vec<TableInfo>,
2480    /// The end offset of the last content emitted — the anchor for blank
2481    /// separator rows so the caret never snaps onto one.
2482    last_off: usize,
2483    /// The end of the last block the walk stepped over without drawing — a
2484    /// comment, which the rich view hides. `last_off` moves past it too, for the
2485    /// separators; this is kept apart so the trailing blank lines can be counted
2486    /// from it without also being counted from a code block's closing fence,
2487    /// which `last_off` likewise ends after. `0` until a hidden block is met.
2488    stepped_over: usize,
2489    /// How many rows each block image reserves, keyed by its destination — the
2490    /// frontend's per-image height, threaded in from [`crate::Doc::set_media_rows`]
2491    /// so [`Builder::block_media`] can size the placeholder without core doing any
2492    /// I/O. A destination absent from the map (or a `0`/`1` entry) reserves the
2493    /// bare one-row placeholder, which is the whole-document default and what
2494    /// every existing test — passing an empty map — still gets.
2495    media_rows: &'a HashMap<String, usize>,
2496    /// The glyph a hard break renders as while the current inline run is built:
2497    /// a space in prose (a break folds into the flow the frontend wraps), but a
2498    /// newline (`\n`) inside a table cell, where a row is one source line and the
2499    /// only break it can carry is an explicit one that must show as a line of its
2500    /// own. Set around [`Builder::row_cells`] and otherwise left at `' '`.
2501    break_glyph: Cell<char>,
2502    /// Render a soft break (a bare newline inside a paragraph) as a line break
2503    /// where it was written, rather than folding it into the reflowed paragraph
2504    /// — the `LineFlow::Preserve` behaviour. A soft break emits a `'\n'` glyph
2505    /// (like a hard break in a cell), which [`Builder::emit_wrapped`] turns into
2506    /// a fresh visual row. `false` is the flowing-prose default. Inside a table
2507    /// cell (where `break_glyph` is already `'\n'`) it has no effect: a cell is
2508    /// one line and folds its own soft breaks regardless.
2509    preserve_soft: bool,
2510    /// The source byte range of the one line that should render its markup
2511    /// *raw* — the caret's line under `MarkupMode::Full` (see
2512    /// [`crate::Doc::reveal_line`]). `None` in every other mode and view, which
2513    /// is the delimiters-always-hidden behaviour every build had before the
2514    /// preference existed.
2515    ///
2516    /// Read only by [`Builder::revealed`], which every delimiter-bearing arm of
2517    /// [`Builder::inline`] consults. A range rather than a bare caret offset
2518    /// because the decision is per-*node*, not per-caret: a node is revealed
2519    /// when its span meets this line, so `*em*` shows both its asterisks even
2520    /// with the caret at one end of it.
2521    reveal: Option<Range<usize>>,
2522    /// The content ends of the hidden marks rendered since the last row was
2523    /// pushed — recorded as the inline walk meets each mark, and drained onto
2524    /// the rows as they are emitted (see [`Builder::take_mark_ends`]). A cell
2525    /// rather than a `&mut`, for the reason `break_glyph` is: the inline walk
2526    /// borrows the builder shared.
2527    pending_mark_ends: RefCell<Vec<usize>>,
2528    /// The presentation vocabulary in force at the block being walked — the
2529    /// keys the `div`s around it carry, folded together with the nearest
2530    /// winning, and [`Presentation::default`] at the top level.
2531    ///
2532    /// Saved and restored around each `div` in [`Builder::block`], so a block
2533    /// reads its own attributes over whatever its containers said and nothing
2534    /// leaks sideways to the block after it. It is per-*build* state rather
2535    /// than a parameter because every one of the dozen call sites of `block`
2536    /// would otherwise thread a value none of them care about.
2537    presentation: Presentation,
2538    /// The named families this walk has met, by the id its glyphs carry — see
2539    /// [`VisualMap::faces`]. A `RefCell` for [`pending_mark_ends`]'s reason:
2540    /// the inline walk borrows the builder shared, and a span's `data-font` is
2541    /// read from inside it.
2542    ///
2543    /// [`pending_mark_ends`]: Builder::pending_mark_ends
2544    faces: RefCell<FaceTable>,
2545}
2546
2547/// The six presentation keys as the walker carries them down a block tree —
2548/// the two that are the block's ([`Align`], [`LineHeight`]) and the three that
2549/// are a run's but may be written on the block ([`FontSize`], [`FaceRef`],
2550/// [`TextColor`]).
2551///
2552/// `Copy` and five `Option`s, because folding is the whole of what it does:
2553/// [`under`](Presentation::under) reads a container's attributes over an
2554/// existing set and a key the container does not name keeps the value it had.
2555/// That is the "nearest wins" rule stated once, rather than at each of the
2556/// three levels a key can be written at. A name and a value fold alike: the
2557/// nearer node wins whichever of the two forms either of them wrote.
2558#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2559struct Presentation {
2560    align: Option<Align>,
2561    line_height: Option<LineHeight>,
2562    size: Option<FontSize>,
2563    font: Option<FaceRef>,
2564    color: Option<TextColor>,
2565}
2566
2567impl Presentation {
2568    /// This set with whatever `attrs` names written over it — the nearer node's
2569    /// answer where it has one, the outer node's where it hasn't.
2570    ///
2571    /// `faces` is the build's intern table, which a named family is recorded in
2572    /// on the way past: the glyph carries the id and the table carries the
2573    /// string. Shared rather than `&mut` because the inline walk this feeds
2574    /// borrows the builder shared, the way `pending_mark_ends` does.
2575    fn under(self, attrs: &[(String, Option<String>)], faces: &RefCell<FaceTable>) -> Self {
2576        Self {
2577            align: Align::from_attrs(attrs).or(self.align),
2578            line_height: LineHeight::from_attrs(attrs).or(self.line_height),
2579            size: FontSize::from_attrs(attrs).or(self.size),
2580            font: faces.borrow_mut().face_from_attrs(attrs).or(self.font),
2581            color: TextColor::from_attrs(attrs).or(self.color),
2582        }
2583    }
2584
2585    /// `base` carrying the three run-level keys — the style a block's glyphs
2586    /// start from, which an attributed span inside it then writes over.
2587    fn over(self, base: Style) -> Style {
2588        base.size(self.size).font(self.font).color(self.color)
2589    }
2590}
2591
2592impl Builder<'_> {
2593    /// Note that the mark `id` closes with a hidden delimiter, so its content
2594    /// end is a caret home — unless the mark is empty, where the end is the
2595    /// start and there is nothing to extend.
2596    fn note_mark_end(&self, id: usize) {
2597        let node = &self.nodes[id];
2598        if let Some(content) = &node.content_span
2599            && content.end < node.span.end
2600            && !content.is_empty()
2601        {
2602            self.pending_mark_ends.borrow_mut().push(content.end);
2603        }
2604    }
2605
2606    /// The pending mark ends at or before `end_src`, for the row ending there
2607    /// — every mark rendered so far that closes on it. A mark's end never
2608    /// exceeds the end of the row its last glyph is on, so the leftovers are
2609    /// those of rows still to come.
2610    fn take_mark_ends(&self, end_src: usize) -> Vec<usize> {
2611        let mut pending = self.pending_mark_ends.borrow_mut();
2612        let (taken, kept): (Vec<usize>, Vec<usize>) =
2613            pending.drain(..).partition(|&o| o <= end_src);
2614        *pending = kept;
2615        taken
2616    }
2617    /// Whether `span` belongs to the line that is showing its raw markup. True
2618    /// only when a reveal line is set (`MarkupMode::Full`) and the two ranges
2619    /// actually meet.
2620    ///
2621    /// Touching at an endpoint counts: an emphasis ending exactly where the line
2622    /// does is on that line, and a zero-length reveal range (the caret alone on
2623    /// a blank line) still meets a node that starts there. The test is
2624    /// deliberately generous — the failure it avoids is revealing one delimiter
2625    /// of a pair while hiding the other, which looks like corruption rather than
2626    /// like markup.
2627    fn revealed(&self, span: &Range<usize>) -> bool {
2628        self.reveal
2629            .as_ref()
2630            .is_some_and(|r| span.start <= r.end && r.start <= span.end)
2631    }
2632
2633    /// The `(opening, closing)` source byte ranges of a node's delimiters — the
2634    /// bytes its `span` holds that its `content_span` doesn't.
2635    ///
2636    /// This is how *every* inline delimiter is recovered, rather than a table of
2637    /// spellings per kind: twig gives `*em*` a span of `13..17` and a content
2638    /// span of `14..16`, so the gaps at each end are the delimiters, whatever
2639    /// they happen to be. That matters because one kind has many spellings —
2640    /// `*em*` and `_em_` are both emphasis, `` `x` `` and ``` ``x`` ``` both
2641    /// verbatim — and re-deriving the text from the source is the only way to
2642    /// show back what the author actually typed. It also gets a link's
2643    /// asymmetric `[` / `](dest)` right for free.
2644    ///
2645    /// `None` when the node has no content span, or when content and span
2646    /// coincide (nothing was elided, so there is nothing to reveal).
2647    fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
2648        let node = &self.nodes[id];
2649        let content = node.content_span.clone()?;
2650        let span = node.span.clone();
2651        // A content span that escapes its own node's span means the two are
2652        // describing different things; reveal nothing rather than slice wildly.
2653        if content.start < span.start || content.end > span.end {
2654            return None;
2655        }
2656        let (open, close) = (span.start..content.start, content.end..span.end);
2657        // A delimiter that spans a newline isn't this line's to reveal — a setext
2658        // heading's `\n=====` underline is the case that arises in practice. It
2659        // would also inject a `'\n'` glyph, which `emit_wrapped` reads as a hard
2660        // row break, so the row would split where the author wrote no break.
2661        let multiline =
2662            |r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
2663        if multiline(&open) || multiline(&close) {
2664            return None;
2665        }
2666        (!open.is_empty() || !close.is_empty()).then_some((open, close))
2667    }
2668
2669    /// Emit the source bytes of `range` as revealed markup — real glyphs, each
2670    /// mapped to its own source byte and each a caret stop, so a delimiter shown
2671    /// is a delimiter that can be selected, edited and deleted like any other
2672    /// text. Styled [`Role::Delimiter`] on top of the run's own style, which is
2673    /// how a frontend tells scaffolding from prose and dims it.
2674    ///
2675    /// Deliberately *not* [`push_escaped_text`]: this is raw source, not parsed
2676    /// text, so there is no escape-driven drift between the two to correct.
2677    fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
2678        let Some(text) = self.source.get(range.clone()) else {
2679            return;
2680        };
2681        push_text(out, text, range.start, base.role(Role::Delimiter));
2682    }
2683
2684    /// Render an inline node's children wrapped in its raw delimiters when the
2685    /// node is on the revealed line, and bare (delimiters resolved away) when it
2686    /// isn't — the shared body of every delimiter-bearing arm of
2687    /// [`inline`](Self::inline).
2688    ///
2689    /// `style` is the resolved styling the content still gets in *both* modes:
2690    /// revealing `*em*` shows the asterisks *and* keeps the text italic, the
2691    /// live-preview behaviour. Showing the markup is not the same as turning the
2692    /// rendering off — that is what [`crate::View::Source`] is for.
2693    fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
2694        let show = self
2695            .revealed(&self.nodes[id].span)
2696            .then(|| self.delims(id))
2697            .flatten();
2698        if let Some((open, _)) = &show {
2699            self.push_delim(out, open, style);
2700        }
2701        self.recurse(id, style, out);
2702        match &show {
2703            Some((_, close)) => self.push_delim(out, close, style),
2704            // Hidden, so the content's end has no glyph after it: give the
2705            // caret its home there.
2706            None => self.note_mark_end(id),
2707        }
2708    }
2709
2710    fn children(&self, id: usize) -> Vec<usize> {
2711        let mut out = Vec::new();
2712        let mut c = self.nodes[id].first_child;
2713        while let Some(cid) = c {
2714            out.push(cid.0 as usize);
2715            c = self.nodes[cid.0 as usize].next_sibling;
2716        }
2717        out
2718    }
2719
2720    /// Render a node's block children, a blank separator between each. `tight`
2721    /// suppresses the *fabricated* separator between adjacent children that share
2722    /// a source line boundary — a tight list item and the sub-list nested in it —
2723    /// while a real blank source line between them still opens a gap.
2724    fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
2725        // Frontmatter (a leading `metadata` block) is document metadata, not
2726        // prose: hide it entirely in the rich-text view. Skipping it here means
2727        // no phantom blank rows for its lines and no separator before the first
2728        // real block — the document opens straight into its content.
2729        let kids: Vec<usize> = self
2730            .children(id)
2731            .into_iter()
2732            .filter(|&c| self.nodes[c].kind != Kind::Metadata)
2733            .collect();
2734        let mut above: Option<BlockClass> = None;
2735        for child in kids {
2736            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2737            let before_sep = self.rows.len();
2738            if let Some(above) = above {
2739                self.emit_separators_before(
2740                    self.nodes[child].span.start,
2741                    pc,
2742                    !tight,
2743                    Boundary { above, below },
2744                );
2745            }
2746            // The first *drawn* child wears the first-row prefix (a bullet, a
2747            // footnote label), not the first child: a comment opening a list
2748            // item draws nothing, and the bullet belongs to what follows it.
2749            let first = if above.is_none() { pf } else { pc };
2750            if self.block_or_hidden(child, before_sep, first, pc) {
2751                above = Some(below);
2752            }
2753        }
2754    }
2755
2756    /// Render `child` after the separator [`Builder::emit_separators_before`]
2757    /// spelled for it from row `before_sep` on, and say whether it drew
2758    /// anything.
2759    ///
2760    /// A block that draws no rows — an HTML comment, which the rich view hides
2761    /// the way it hides frontmatter — is still *there* in the source, and the
2762    /// walk has to step over it: `last_off` moves past it so the next separator
2763    /// counts the blank lines from its end, not from wherever the last drawn
2764    /// block stopped. Left where it was, the separator counted every line of the
2765    /// comment as a blank row; and the cached path, whose per-block builder
2766    /// starts at offset 0, handed back a `last_off` of 0 and counted every line
2767    /// of the *document* — one phantom blank row per source line, once per
2768    /// comment. The separator drawn for it is taken back too, so a hidden block
2769    /// leaves no gap of its own: what stands either side of it meets across one
2770    /// boundary, as if the comment were not there.
2771    fn block_or_hidden(
2772        &mut self,
2773        child: usize,
2774        before_sep: usize,
2775        pf: &[Glyph],
2776        pc: &[Glyph],
2777    ) -> bool {
2778        let after_sep = self.rows.len();
2779        self.block(child, pf, pc);
2780        if self.rows.len() > after_sep {
2781            return true;
2782        }
2783        self.rows.truncate(before_sep);
2784        let end = self.nodes[child].span.end;
2785        self.last_off = self.last_off.max(end);
2786        self.stepped_over = self.stepped_over.max(end);
2787        false
2788    }
2789
2790    /// Render an explicit, ordered list of top-level blocks — [`Builder::blocks`]
2791    /// for a walk that isn't "the children of one node". The document's top level
2792    /// no longer is: a footnote definition is a root beside `doc`, not under it,
2793    /// and [`top_level`] merges it into this list by source position.
2794    ///
2795    /// The separator between blocks is spelled by the same
2796    /// [`Builder::emit_separators_before`] the incremental top-level walk in
2797    /// [`build_cached`] uses, so the two paths can't drift on how a boundary
2798    /// looks.
2799    ///
2800    /// Returns the class of the last block that drew anything — what the
2801    /// trailing blank lines close — or `None` when nothing did.
2802    fn top_blocks(&mut self, ids: &[usize]) -> Option<BlockClass> {
2803        let mut above: Option<BlockClass> = None;
2804        for &child in ids {
2805            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2806            let before_sep = self.rows.len();
2807            if let Some(above) = above {
2808                self.emit_separators_before(
2809                    self.nodes[child].span.start,
2810                    &[],
2811                    true,
2812                    Boundary { above, below },
2813                );
2814            }
2815            if self.block_or_hidden(child, before_sep, &[], &[]) {
2816                above = Some(below);
2817            }
2818        }
2819        above
2820    }
2821
2822    /// Emit the blank separator row(s) that sit between a block ending at the
2823    /// current `last_off` and the next block starting at `next_start`, wearing
2824    /// the continuation prefix `pc`. Shared by [`Builder::blocks`] and the
2825    /// incremental top-level walk so the two can't drift on how a boundary is
2826    /// spelled.
2827    ///
2828    /// The blank line(s) between two blocks are real caret stops, each needing
2829    /// its *own* source offset — one strictly past the previous block's content,
2830    /// else it collides with that block's last row and `pos_of_offset`
2831    /// (first-match-wins) would resolve the caret onto the wrong row, pinning
2832    /// downward motion there.
2833    ///
2834    /// One row *per* blank source line, not a single collapsed separator: an
2835    /// empty paragraph opened between two blocks (Enter in the gap,
2836    /// `…\n\n\n\n…`) must be a navigable empty row, not vanish — else the caret
2837    /// in it snaps onto the *next* block's start and Enter looks like it did
2838    /// nothing.
2839    fn emit_separators_before(
2840        &mut self,
2841        next_start: usize,
2842        pc: &[Glyph],
2843        synthetic: bool,
2844        boundary: Boundary,
2845    ) {
2846        let mut offs = self.blank_rows_between(self.last_off, next_start);
2847        if offs.is_empty() {
2848            if !synthetic {
2849                // A tight list item's own text sits directly above the sub-list
2850                // nested in it — no fabricated gap. The "breathe" row belongs
2851                // between free-standing blocks, not between an item and its
2852                // child list, which the source writes on the very next line. A
2853                // real blank source line (a loose list) still lands a gap below,
2854                // because `blank_rows_between` found it and we never reach here.
2855                return;
2856            }
2857            // A tight gap with no blank line (e.g. a heading directly above its
2858            // text): keep the one conventional separator row so blocks still
2859            // breathe, as they always have.
2860            offs.push(self.blank_line_offset(self.last_off, next_start));
2861        }
2862        let last = offs.len() - 1;
2863        for (k, end_src) in offs.into_iter().enumerate() {
2864            // Only the drawn-only rows carry the boundary: the navigable blank
2865            // lines between them (and every blank line under preserve-soft flow)
2866            // are somewhere text can go, not a gap between blocks, and a frontend
2867            // that shrank one would be shrinking a line the author is typing on.
2868            let drawn = !self.preserve_soft && (k == 0 || k == last);
2869            // The blank line a boundary is *drawn* with isn't a place text can
2870            // go. The first one closes the block above and the last one opens the
2871            // block below — with a single blank line, the usual case, doing both
2872            // at once. Typing on either just continues the paragraph it abuts,
2873            // since the blank line it would need to be a paragraph of its own is
2874            // the very line being typed on. So they're a gap, like a table's
2875            // border: drawn, clickable, never a caret's home.
2876            //
2877            // The lines *between* them are the real ones. That's what Enter
2878            // opens: it inserts a paragraph break (`\n\n`), which leaves a blank
2879            // line spare on each side and the caret on the navigable line
2880            // between them.
2881            //
2882            // Preserve flow is the exception: there a bare `\n` is a visible line
2883            // break the author edits directly, so a lone blank line *is* a caret
2884            // home — typing on it makes the soft break the mode exists to show,
2885            // and Enter at a line's end lands the caret on exactly this row. So no
2886            // separator is drawn-only; every blank line is navigable.
2887            self.rows.push(VRow {
2888                glyphs: pc.to_vec(),
2889                end_src,
2890                decoration: drawn,
2891                code: false,
2892                code_lang: None,
2893                directive: false,
2894                directive_label: None,
2895                media: None,
2896                task: None,
2897                leaf_directive: None,
2898                heading: None,
2899                align: None,
2900                line_height: None,
2901                boundary: drawn.then_some(boundary),
2902                mark_ends: Vec::new(),
2903            });
2904        }
2905    }
2906
2907    /// One block, drawn under whatever presentation the containers around it
2908    /// impose.
2909    ///
2910    /// A container named `div` with `Element` origin is transparent already —
2911    /// its children draw as themselves — and it now also *contributes* its
2912    /// vocabulary keys to every block it holds. That is the reading side of
2913    /// twig's own rule for where a Markdown block's attributes live: there is
2914    /// no attribute syntax to put on the paragraph, so `set_block_attrs` writes
2915    /// a `<div …>` around it, and reading one back has to look through the div.
2916    /// `<div class="center">` around three paragraphs centres all three, which
2917    /// is what the author of that HTML meant, and around one is the sole-child
2918    /// shape twig writes.
2919    ///
2920    /// Saved and restored rather than pushed onto a stack, so a nested div
2921    /// reads its own keys over its parent's and the block *after* the div is
2922    /// unaffected.
2923    fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2924        if element_tag(&self.nodes[id]) == Some("div") {
2925            let saved = self.presentation;
2926            self.presentation = saved.under(&self.nodes[id].attrs, &self.faces);
2927            self.block_kind(id, pf, pc);
2928            self.presentation = saved;
2929            // Step the walk past the closing `</div>`, as the fenced-div arm
2930            // below anchors past its `:::`. The tag sits on a line of its own
2931            // after the last child and the blank line under it, and the rich
2932            // view draws nothing for it — so left where the last child ended,
2933            // the separator logic read the tag's line as a blank line between
2934            // the div and the block below, and drew a navigable empty row there
2935            // that the author never opened and Backspace could not close; and
2936            // at the end of the document the trailing count read it as an empty
2937            // paragraph the author had left. It is hidden markup the walk steps
2938            // over, which is what `stepped_over` records, so both counts start
2939            // past it.
2940            let end = self.nodes[id].span.end;
2941            self.last_off = self.last_off.max(end);
2942            self.stepped_over = self.stepped_over.max(end);
2943            return;
2944        }
2945        self.block_kind(id, pf, pc);
2946    }
2947
2948    fn block_kind(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2949        let node = &self.nodes[id];
2950        match node.kind.as_str() {
2951            "doc" | "section" => self.blocks(id, pf, pc, false),
2952            "heading" => {
2953                // A heading whose only visible content is a single image — a
2954                // banner set in an `<h1>` (`<h1><picture><img></picture></h1>`),
2955                // or `# ![](banner.png)` — is a block picture, not text. Render
2956                // it as one; anything with real heading text falls through.
2957                if let Some((m, kind)) = self.media_only(id) {
2958                    self.block_media(m, kind, id, pf);
2959                    return;
2960                }
2961                let level = node.level.unwrap_or(1);
2962                // A `data-size` on a heading scales the *heading's* ramp, not
2963                // the body's — the role and the step compose rather than
2964                // compete, which is the same thing a colour does to a link.
2965                let pres = self.presentation.under(&node.attrs, &self.faces);
2966                let style = pres.over(heading_style(level));
2967                let mut glyphs = Vec::new();
2968                // On the revealed line the `# ` comes back as real, editable
2969                // text in front of the heading. Only the opening marker: a
2970                // closing `#`-run (`## title ##`) is covered by the same
2971                // `delims` pair, and a setext underline is excluded there for
2972                // being on another line entirely.
2973                if let Some((open, close)) =
2974                    self.revealed(&node.span).then(|| self.delims(id)).flatten()
2975                {
2976                    self.push_delim(&mut glyphs, &open, style);
2977                    glyphs.extend(self.inline_children_with_trailing(id, style));
2978                    self.push_delim(&mut glyphs, &close, style);
2979                } else {
2980                    glyphs = self.inline_children_with_trailing(id, style);
2981                }
2982                // An *empty* heading — `# ` with nothing typed after it, which is
2983                // what the toolbar's H1 leaves on a blank line — has no glyph for
2984                // its row to end on, so the fallback below is the row's whole
2985                // extent: its only caret stop, and the offset every row after it
2986                // is measured from. The block's start is the wrong answer for
2987                // both, because it sits *in front of* the `# ` the rich view
2988                // hides: the caret drew (and typed) before the hashes, and the
2989                // rows below inherited an offset short by the marker's length,
2990                // which put the caret on one of them the moment the heading grew
2991                // text. Its content's start is where the caret belongs.
2992                let home = heading_content_start(self.source, &node.span);
2993                let first = self.rows.len();
2994                self.emit_wrapped(glyphs, home, pf, pc);
2995                // Stamp the level on every row the heading just emitted — a
2996                // wrapped heading's continuation rows as much as its first, and
2997                // an empty one's single glyphless row, which is the whole point
2998                // (see [`VRow::heading`]).
2999                for row in &mut self.rows[first..] {
3000                    row.heading = Some(level.min(255) as u8);
3001                    row.align = pres.align;
3002                    row.line_height = pres.line_height;
3003                }
3004            }
3005            "block_quote" => {
3006                let (start, end) = (node.span.start, node.span.end);
3007                let gutter = synth("│ ", Role::QuoteGutter, start);
3008                let f = concat(pf, &gutter);
3009                let c = concat(pc, &gutter);
3010                // A childless quote — a bare `> ` on an otherwise blank line,
3011                // which is what the toolbar's Quote button leaves there — has no
3012                // inner block to carry the gutter or a caret home, so `blocks`
3013                // emitted *nothing at all*: the quote didn't merely draw
3014                // unstyled, it disappeared, and a document that was only `> `
3015                // rendered zero rows with the caret nowhere to stand. Emit the
3016                // gutter row itself, ending just past the marker, exactly as an
3017                // empty `list_item` emits its bare bullet.
3018                if self.children(id).is_empty() {
3019                    self.push_row_at(f, end.min(self.source.len()));
3020                } else {
3021                    self.blocks(id, &f, &c, false);
3022                    self.emit_quote_trailing_lines(&c, end);
3023                }
3024            }
3025            // A generic `:::name{.class}` fenced-div container (twig's
3026            // `directive`, container form). Core is agnostic of `name` — it's
3027            // the host app's vocabulary (diaryx's `vis` for audience
3028            // visibility, say) and isn't available here regardless: twig only
3029            // threads an `element`'s tag name through `FlatNode::name`, not a
3030            // directive's own identifier. Every row gets marked `directive` (a
3031            // frontend draws a tinted panel around each maximal run, the
3032            // `code`/`code_block` recipe) and the first row carries a label —
3033            // the way a code fence's language rides only its first row.
3034            //
3035            // The label reads BOTH attribute conventions diaryx content
3036            // actually uses: twig's own dot-prefixed classes (`{.public
3037            // .family}`, one combined `class` attr) and bare pandoc-style
3038            // words with no leading dot (`{public family}` — the syntax
3039            // `diaryx_core::visibility`'s hand-rolled publish-time filter and
3040            // apps/web's directive serializer both write; twig parses each
3041            // bare word as its own attribute with an empty value, per
3042            // `languages/markdown/attributes.zig`). Reading only `.class`
3043            // would leave every *existing* diaryx `:::vis{...}` block
3044            // unlabeled.
3045            // Only the *container* form is the panel below. A `text` directive
3046            // is inline and never reaches the block walker (see `is_inline`); a
3047            // `leaf` one is a standalone block with no body, drawn as a
3048            // placeholder the way an image is.
3049            "container"
3050                if container_is_directive(node)
3051                    && node.directive_form == Some(DirectiveForm::Leaf) =>
3052            {
3053                self.block_directive(id, pf);
3054            }
3055            // djot has no *leaf* directive form. `insert_directive` spells the
3056            // same document as an empty `::: page-break` fence — a container
3057            // with nothing in it — and the name comes back as the fence's one
3058            // class rather than as the node's name, because djot's div is
3059            // anonymous. Draw it as the placeholder Markdown's `::page-break`
3060            // gets, so a frontend that paginates on a `page-break`
3061            // [`DirectiveMark`] cannot tell which format the file is in.
3062            //
3063            // Narrow on purpose: only an *anonymous* empty fence. A Markdown
3064            // `:::note` with nothing in it keeps the reading it has, because
3065            // its name is its own and nothing about it says "a block with no
3066            // body" the way djot's spelling of a leaf directive does.
3067            "container"
3068                if container_is_directive(node)
3069                    && node.directive_form == Some(DirectiveForm::Container)
3070                    && node.name.as_deref().unwrap_or_default().is_empty()
3071                    && self.children(id).is_empty()
3072                    && !leaf_directive_identity(node).0.is_empty() =>
3073            {
3074                self.block_directive(id, pf);
3075            }
3076            "container" if container_is_directive(node) => {
3077                let label = directive_attr_label(&node.attrs);
3078                let start_row = self.rows.len();
3079                self.blocks(id, pf, pc, false);
3080                for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
3081                    row.directive = true;
3082                    if i == 0 {
3083                        row.directive_label = label.clone();
3084                    }
3085                }
3086                // Anchor the block's end past its closing `:::` fence, exactly as
3087                // the code-block arm anchors past its ```` ``` ````. A container's
3088                // last content row ends at its last *child*, before the fence and
3089                // the blank line under it, so the separator logic counted the
3090                // fence line as a blank row of its own and drew a second boundary
3091                // — one gap's worth of margin twice, under every fenced div.
3092                self.last_off = node.span.end;
3093            }
3094            "bullet_list" | "ordered_list" | "task_list" => {
3095                let ordered = node.kind == Kind::OrderedList;
3096                let mut item_no = 0usize;
3097                let kids = self.children(id);
3098                for (i, child) in kids.iter().copied().enumerate() {
3099                    let kind = &self.nodes[child].kind;
3100                    if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
3101                        let start = self.nodes[child].span.start;
3102                        item_no += 1;
3103                        // A task item's box replaces the bullet rather than
3104                        // joining it. The `[ ] ` that spells it is markup twig
3105                        // has already consumed — the item's paragraph *content*
3106                        // starts past it — so without a drawn box a task item
3107                        // was indistinguishable from a plain bullet, ticked or
3108                        // not. `☐`/`☑` is the marker for the same reason `•` is:
3109                        // it stands where the source's own marker stands. Which
3110                        // way it faces is `checked`, straight off the node.
3111                        let checked = self.nodes[child].checked;
3112                        let marker = match (checked, ordered) {
3113                            (Some(true), _) => "☑ ".to_string(),
3114                            (Some(false), _) => "☐ ".to_string(),
3115                            (None, true) => format!("{item_no}. "),
3116                            (None, false) => "• ".to_string(),
3117                        };
3118                        let bullet = synth(&marker, Role::ListMarker, start);
3119                        let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
3120                        let first_row = self.rows.len();
3121                        self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
3122                        // On the item's first row, the way `code_lang` rides the
3123                        // first row of its block.
3124                        if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
3125                            row.task = Some(c);
3126                        }
3127                    } else {
3128                        // twig can nest a *following* top-level block as a direct
3129                        // child of the list rather than a sibling of it — e.g.
3130                        // `- item\n\n> quote` parses the block quote under the
3131                        // `bullet_list`. It isn't a list item, so render it de-nested:
3132                        // no bullet, at the list's own prefix, with the usual block
3133                        // separator — never `• │ quote`.
3134                        if i > 0 {
3135                            self.emit_separators_before(
3136                                self.nodes[child].span.start,
3137                                pc,
3138                                true,
3139                                Boundary {
3140                                    above: BlockClass::from_node_kind(
3141                                        &self.nodes[kids[i - 1]].kind,
3142                                    ),
3143                                    below: BlockClass::from_node_kind(&self.nodes[child].kind),
3144                                },
3145                            );
3146                        }
3147                        self.block(child, pc, pc);
3148                    }
3149                }
3150            }
3151            "list_item" | "task_list_item" => {
3152                // A childless item — the empty bullet you get the instant you
3153                // press Enter to open a new one — has no inner block to carry the
3154                // marker prefix or a caret home, so `blocks` would emit nothing
3155                // and the new bullet simply wouldn't appear until something was
3156                // typed into it. Emit the prefixed row itself, ending at a caret
3157                // stop just past the marker (the item's `span.end`), the way an
3158                // empty paragraph emits its one prefixed row via `emit_wrapped`.
3159                if self.children(id).is_empty() {
3160                    let home = self.nodes[id].span.end.min(self.source.len());
3161                    self.push_row_at(pf.to_vec(), home);
3162                } else {
3163                    // Tight: an item's text and the list nested under it butt
3164                    // together (`• a` / `  • b`), no fabricated blank row between —
3165                    // a loose item's real blank line still parts them.
3166                    self.blocks(id, pf, pc, true);
3167                }
3168            }
3169            // A footnote *definition* (`[^1]: the note`). It reaches this walker
3170            // only because [`top_level`] merges it back in — twig hangs it off no
3171            // parent at all, so a walk from `doc` never sees one and every byte
3172            // of its body used to render as nothing.
3173            //
3174            // Drawn as a hanging-indent item, the way a list item is: the marker
3175            // reads `[1] `, matching the `[1]` its references render as, so the
3176            // two can be paired by eye, and the body wraps under it. The marker
3177            // is synthetic decoration (one shared offset, never a caret stop) —
3178            // the `[^1]: ` that spells it in the source is markup, hidden like a
3179            // heading's `# `.
3180            "footnote" => {
3181                let (start, end) = (node.span.start, node.span.end);
3182                let source = self.source;
3183                let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
3184                let indent = " ".repeat(text_width(&marker));
3185                let f = concat(pf, &synth(&marker, Role::ListMarker, start));
3186                let c = concat(pc, &synth(&indent, Role::Body, start));
3187                if self.children(id).is_empty() {
3188                    // A definition with no body yet — the instant `[^1]: ` has
3189                    // been typed and nothing after it. `blocks` would emit
3190                    // nothing and the definition simply wouldn't appear, so emit
3191                    // the marker row itself with a caret home just past it,
3192                    // exactly as an empty list item does.
3193                    self.push_row_at(f, end.min(source.len()));
3194                } else {
3195                    self.blocks(id, &f, &c, false);
3196                }
3197            }
3198            // A link reference definition (`[foo]: /url`): resolved by label
3199            // into the links that use it, and drawn nowhere — the rich view has
3200            // no more use for its line than for a comment's. It is walked at all
3201            // (see [`top_level`]) so [`Builder::block_or_hidden`] can step the
3202            // walk past its bytes rather than count them as blank lines.
3203            "reference" => {}
3204            "table" => self.table(id, pf, pc),
3205            "code_block" => {
3206                let style = Style::default().role(Role::Code);
3207                let text = node.text.clone().unwrap_or_default();
3208                // Cut the block's *terminator*, not every trailing newline. A
3209                // block whose last line is empty spells that as a second `\n`,
3210                // and `trim_end_matches` ate it along with the terminator: the
3211                // Return that made the line got no row, so the caret placed on
3212                // it fell through to the paragraph below and typing landed
3213                // outside the block. twig's `content_span` is `text` less
3214                // exactly this one newline, so cutting one and no more is also
3215                // what keeps `code_line_offsets` lined up.
3216                let lines: Vec<&str> = text
3217                    .strip_suffix('\n')
3218                    .unwrap_or(text.as_str())
3219                    .split('\n')
3220                    .collect();
3221                // Each line at its own source offset, so the caret can walk the
3222                // code a character at a time like any other text. Where the
3223                // lines can't be lined up with the source there's no honest
3224                // offset to give, so the block maps coarsely to its start (and
3225                // stays a source-view job, as all of it once was).
3226                let offs = node
3227                    .content_span
3228                    .as_ref()
3229                    .and_then(|c| self.code_line_offsets(c, &lines));
3230                // The fence's info string, carried on the block's first row as
3231                // its language label (`None` for an indented block or a bare
3232                // fence). Kept on the row so it rides the block cache.
3233                let lang = code_language(self.source, node.span.start);
3234                // The block's syntax highlighting, a token per byte range of
3235                // each line — `None` unless the fence names a language the
3236                // grammars know (and unless the `syntax` feature is on). Done
3237                // here, once per build of the block, because the rows it
3238                // colours ride the block cache: an edit elsewhere in the
3239                // document reuses them, tokens and all.
3240                let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
3241                for (i, raw) in lines.iter().enumerate() {
3242                    let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
3243                    // No gutter glyph: the block is set apart by the border and
3244                    // tint a frontend draws around the whole run of `code` rows,
3245                    // not by a per-line mark. Just the block prefix (a list
3246                    // indent, a quote gutter) and the code text.
3247                    let mut glyphs: Vec<Glyph> = pf.to_vec();
3248                    match tokens.as_ref().and_then(|t| t.get(i)) {
3249                        Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
3250                        None => push_text(&mut glyphs, raw, at, style),
3251                    }
3252                    // Explicitly past the line's *text*: a blank code line has no
3253                    // glyph, and any prefix's offset would put the row's end
3254                    // inside the next line.
3255                    self.push_row_at(glyphs, at + raw.len());
3256                    if let Some(row) = self.rows.last_mut() {
3257                        row.code = true;
3258                        if i == 0 {
3259                            row.code_lang = lang.clone();
3260                        }
3261                    }
3262                }
3263                // Anchor the block's end past its closing fence. Its last content
3264                // row ends at the last code line, before the ``` and the blank
3265                // line under it; without this the separator logic would count the
3266                // closing-fence line as its own blank row and open a phantom
3267                // second gap below the block.
3268                self.last_off = node.span.end;
3269            }
3270            "thematic_break" => {
3271                let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
3272                let w = full.saturating_sub(prefix_width(pf)).max(4);
3273                let mut glyphs = pf.to_vec();
3274                for _ in 0..w {
3275                    glyphs.push(Glyph {
3276                        ch: '─',
3277                        style: Style::default().role(Role::Rule),
3278                        src: node.span.start,
3279                        // A rule is a block the caret can sit on, as it always
3280                        // has; it maps coarsely to the block's start.
3281                        stop: true,
3282                    });
3283                }
3284                // The dashes share one caret home in front of the atomic block,
3285                // while the row's end is the second home just past its source.
3286                // Without that trailing stop a final rule made the document end
3287                // unreachable: Right could not cross it and a click in the
3288                // empty space below it snapped back before the rule.
3289                let after_line = node.span.end
3290                    + self.source[node.span.end..]
3291                        .strip_prefix("\r\n")
3292                        .map_or_else(
3293                            || usize::from(self.source[node.span.end..].starts_with('\n')),
3294                            |_| 2,
3295                        );
3296                self.push_row_at(glyphs, after_line);
3297            }
3298            // A block-level image node with no wrapping paragraph — a promoted
3299            // top-level HTML `<img>` lands as a direct `doc` child like this
3300            // (a Markdown `![](…)` comes wrapped in a `para`, handled below).
3301            "image" => self.block_media(id, MediaKind::Image, id, pf),
3302            // The same case for a promoted top-level `<video>`/`<audio>`, which
3303            // arrives as a generic `container` rather than a node kind of its
3304            // own. It can't be found by the `media_only` scan below the way a
3305            // wrapped one is: that scan looks at a wrapper's *children*, and here
3306            // the media element is itself the block.
3307            "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3308                let kind = match element_tag(node) {
3309                    Some("audio") => MediaKind::Audio,
3310                    _ => MediaKind::Video,
3311                };
3312                self.block_media(id, kind, id, pf);
3313            }
3314            _ => {
3315                // A container of blocks, or an inline-bearing paragraph.
3316                let kids = self.children(id);
3317                // A block-level image: a paragraph (or other wrapper — a
3318                // `<picture>`, an `<h1>` banner) whose only visible content is a
3319                // single `image` node. Render it as a placeholder row + record an
3320                // [`MediaInfo`] a capable frontend replaces. An image mixed with
3321                // real text or other images on the line isn't block-level and
3322                // falls through to the inline path below, still as its alt text.
3323                if let Some((m, kind)) = self.media_only(id) {
3324                    self.block_media(m, kind, id, pf);
3325                    return;
3326                }
3327                let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
3328                if inline || kids.is_empty() {
3329                    // The block's own attributes over its containers' — the
3330                    // three run-level keys become the style its glyphs start
3331                    // from, and the two line-level ones ride every row it
3332                    // emits, a wrapped paragraph's continuations included.
3333                    let pres = self.presentation.under(&node.attrs, &self.faces);
3334                    let glyphs =
3335                        self.inline_children_with_trailing(id, pres.over(Style::default()));
3336                    if !glyphs.is_empty() {
3337                        let first = self.rows.len();
3338                        self.emit_wrapped(glyphs, node.span.start, pf, pc);
3339                        for row in &mut self.rows[first..] {
3340                            row.align = pres.align;
3341                            row.line_height = pres.line_height;
3342                        }
3343                    }
3344                } else {
3345                    self.blocks(id, pf, pc, false);
3346                }
3347            }
3348        }
3349    }
3350
3351    /// Render a table as a box-drawn grid: every column as wide as its widest
3352    /// cell, the header bold and ruled off, each cell padded to its column's
3353    /// alignment. This is the *default* monospace rendering (see
3354    /// [`VisualMap::rows`]); the same cells are also published structurally as
3355    /// [`TableInfo`], so a frontend that lays the grid out in its own units draws
3356    /// from there and skips the picture built here.
3357    ///
3358    /// The alignment comes from twig's `cell.alignment` — the delimiter row
3359    /// (`|:--|--:|`) that spells it out is consumed by the parser and leaves no
3360    /// node, so the snapshot is the only source for it.
3361    ///
3362    /// Borders and padding are *decoration*: they carry the source offset of the
3363    /// text they surround, so a click lands in that cell, but they're never
3364    /// caret stops — the caret steps cell-to-cell instead of into the box art.
3365    fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
3366        let node_end = self.nodes[id].span.end;
3367        // twig's shape is `[caption, row, row, …]`: the caption is always
3368        // present (usually empty in Markdown) and is not part of the grid.
3369        let row_ids: Vec<usize> = self
3370            .children(id)
3371            .into_iter()
3372            .filter(|&c| self.nodes[c].kind == Kind::Row)
3373            .collect();
3374        if row_ids.is_empty() {
3375            return;
3376        }
3377        // Lay every cell out first — the column widths depend on all of them.
3378        let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
3379        let heads: Vec<bool> = row_ids
3380            .iter()
3381            .map(|&r| self.nodes[r].head.unwrap_or(false))
3382            .collect();
3383        let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
3384        if cols == 0 {
3385            return;
3386        }
3387        let mut widths = vec![0usize; cols];
3388        for row in &grid {
3389            for (c, cell) in row.iter().enumerate() {
3390                widths[c] = widths[c].max(cell_width(&cell.glyphs));
3391            }
3392        }
3393        // Every column at its widest cell is only the *wish*; a grid wider than
3394        // the surface has its far side hanging off the edge where no amount of
3395        // caret motion can reach it. Cut it down to what's actually there, and
3396        // let the cells wrap into the space they're given.
3397        if let Some(w) = self.wrap {
3398            fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
3399        }
3400
3401        // Where the picture starts, so a frontend drawing its own grid knows
3402        // which rows to skip. Recorded before the first border goes down.
3403        let rows_start = self.rows.len();
3404
3405        let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
3406        self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
3407        for (ri, row) in grid.iter().enumerate() {
3408            self.push_table_row(row, &widths, pc);
3409            // The rule under the header: only where the head actually ends.
3410            let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
3411            if ends_head {
3412                let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
3413                self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
3414            }
3415        }
3416        // The bottom border is the one rule the caret can rest on: its end is
3417        // the table's trailing stop, the caret home just past the block — the
3418        // peer of a block picture's second stop, and of a rule's row end. Without
3419        // it a document ending in a table ended *inside* it: nothing after the
3420        // last cell was a stop, so Right could not leave the table, and a click
3421        // in the blank space under it snapped back into the last cell — or, on a
3422        // surface that resolved the click onto the border row, to the table's
3423        // first cell, since a decoration row's only stop is the nearest one.
3424        // Typing at the stop opens a paragraph first, as at a picture's — see
3425        // `Doc::open_paragraph_at_block_edge`. The glyphs stay non-stops at
3426        // `node_end`, so a click anywhere on the border lands past the table.
3427        self.push_rule_with_home(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
3428
3429        // The same cells the picture above was drawn from, published unwrapped
3430        // and unpadded for a frontend that lays them out in pixels.
3431        self.tables.push(TableInfo {
3432            rows_span: rows_start..self.rows.len(),
3433            end_src: node_end,
3434            // The *continuation* prefix: `pf` opens the block and only its first
3435            // row wears it, but every row of a grid is a continuation of the
3436            // block the table sits in.
3437            prefix: pc.to_vec(),
3438            grid: grid
3439                .into_iter()
3440                .zip(heads)
3441                .map(|(cells, head)| TableRow { head, cells })
3442                .collect(),
3443        });
3444        // The table's own end anchors whatever separator follows it; the border
3445        // rows deliberately don't move `last_off` (they hold no content).
3446        self.last_off = node_end;
3447    }
3448
3449    /// One row of laid-out cells, in column order.
3450    fn row_cells(&self, row: usize) -> Vec<TableCell> {
3451        // A cell is one source line, so a break within it is an explicit line
3452        // break (an inline `<br>`) that must render as a line of its own — not the
3453        // flow-folding space a break is in prose.
3454        self.break_glyph.set('\n');
3455        let cells = self
3456            .children(row)
3457            .into_iter()
3458            .filter(|&c| self.nodes[c].kind == Kind::Cell)
3459            .enumerate()
3460            .map(|(col, c)| {
3461                let n = &self.nodes[c];
3462                let style = if n.head.unwrap_or(false) {
3463                    Style::default().bold()
3464                } else {
3465                    Style::default()
3466                };
3467                // Only `content_span` bounds a cell's text, and an EMPTY cell
3468                // has none at all — twig records no interior for it — so both
3469                // offsets would fall back to the cell's `span.start`: on the
3470                // pipe that opens it, or (under a twig that gave every cell
3471                // the whole row's span) the row's start, where every empty
3472                // cell collapses onto one spot before the first `│` and a
3473                // caret there types *before* the table. Derive the interior
3474                // from the span's own pipes and this cell's column instead,
3475                // so each empty cell has a distinct, editable caret home.
3476                let span = n.content_span.clone().unwrap_or_else(|| {
3477                    let off = empty_cell_offset(
3478                        &self.source[n.span.start.min(self.source.len())
3479                            ..n.span.end.min(self.source.len())],
3480                        n.span.start,
3481                        col,
3482                    );
3483                    off..off
3484                });
3485                TableCell {
3486                    glyphs: self.inline_children(c, style),
3487                    start: span.start,
3488                    end: span.end,
3489                    align: n.alignment.unwrap_or(Alignment::Default),
3490                }
3491            })
3492            .collect();
3493        self.break_glyph.set(' ');
3494        cells
3495    }
3496
3497    /// A horizontal rule between/around rows — entirely decoration.
3498    fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3499        self.push_rule_row(text, src, prefix, true);
3500    }
3501
3502    /// A table's bottom border: drawn like the other rules, but a row the caret
3503    /// can rest on, its end (`src`) being the table's trailing stop.
3504    fn push_rule_with_home(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3505        self.push_rule_row(text, src, prefix, false);
3506    }
3507
3508    fn push_rule_row(&mut self, text: &str, src: usize, prefix: &[Glyph], decoration: bool) {
3509        let glyphs = concat(prefix, &synth(text, Role::Rule, src));
3510        self.rows.push(VRow {
3511            glyphs,
3512            end_src: src,
3513            decoration,
3514            code: false,
3515            code_lang: None,
3516            directive: false,
3517            directive_label: None,
3518            media: None,
3519            task: None,
3520            leaf_directive: None,
3521            heading: None,
3522            align: None,
3523            line_height: None,
3524            boundary: None,
3525            mark_ends: Vec::new(),
3526        });
3527    }
3528
3529    /// One `│ a │ b │` row of the grid: real cell text between decoration.
3530    ///
3531    /// A row of cells is not a row of the screen — a cell wrapped to its column
3532    /// spans several, each one `│`-divided across the full width so the grid
3533    /// stays square. Cells in the same row are laid out independently and run
3534    /// out at their own heights; a column that has run dry pads out as
3535    /// decoration while its neighbours keep going.
3536    fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
3537        let fallback = cells.last().map(|c| c.end).unwrap_or(0);
3538        let laid: Vec<Vec<Vec<Glyph>>> = cells
3539            .iter()
3540            .enumerate()
3541            .map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
3542            .collect();
3543        let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
3544
3545        for j in 0..height {
3546            let mut glyphs = prefix.to_vec();
3547            for (ci, &w) in widths.iter().enumerate() {
3548                let cell = cells.get(ci);
3549                let line = laid.get(ci).and_then(|l| l.get(j));
3550                // The divider before this column belongs to the cell it
3551                // introduces, so clicking it lands in that cell — on this line
3552                // of it, which is what's next to the divider being clicked.
3553                let at = line
3554                    .and_then(|l| l.first().map(|g| g.src))
3555                    .or_else(|| cell.map(|c| c.start))
3556                    .unwrap_or(fallback);
3557                glyphs.extend(synth("│", Role::Rule, at));
3558                match (cell, line) {
3559                    (Some(cell), Some(line)) => {
3560                        let pad = w.saturating_sub(glyphs_width(line));
3561                        let (lead, trail) = match cell.align {
3562                            Alignment::Right => (pad, 0),
3563                            Alignment::Center => (pad / 2, pad - pad / 2),
3564                            Alignment::Left | Alignment::Default => (0, pad),
3565                        };
3566                        // Every line renders at least one space after its text
3567                        // (the gutter before `│`), so there is always somewhere
3568                        // to put the "after the last character" caret a line
3569                        // needs. It's the one padding glyph that is a stop: on
3570                        // the cell's last line that's the cell's end, and on any
3571                        // other it's the space the wrap consumed.
3572                        let last = laid[ci].len() == j + 1;
3573                        let end = match last {
3574                            true => cell.end,
3575                            false => line
3576                                .last()
3577                                .map(|g| g.src + g.ch.len_utf8())
3578                                .unwrap_or(cell.end),
3579                        };
3580                        glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
3581                        glyphs.extend(line.iter().cloned());
3582                        glyphs.push(Glyph {
3583                            ch: ' ',
3584                            style: Style::default(),
3585                            src: end,
3586                            stop: true,
3587                        });
3588                        glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
3589                    }
3590                    // A ragged row, or a column whose cell ended higher up: pad
3591                    // it out so the grid stays square.
3592                    _ => {
3593                        let at = cell.map(|c| c.end).unwrap_or(fallback);
3594                        glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
3595                    }
3596                }
3597            }
3598            glyphs.extend(synth("│", Role::Rule, fallback));
3599            // The row ends where its last stop does. A table row has no gap
3600            // between its final cell and the border, so inventing an end past
3601            // that would be a stop with nothing under it.
3602            let end_src = glyphs
3603                .iter()
3604                .rev()
3605                .find(|g| g.stop)
3606                .map_or(fallback, |g| g.src);
3607            let mark_ends = self.take_mark_ends(end_src);
3608            self.rows.push(VRow {
3609                glyphs,
3610                end_src,
3611                decoration: false,
3612                code: false,
3613                code_lang: None,
3614                directive: false,
3615                directive_label: None,
3616                media: None,
3617                task: None,
3618                leaf_directive: None,
3619                heading: None,
3620                align: None,
3621                line_height: None,
3622                boundary: None,
3623                mark_ends,
3624            });
3625        }
3626    }
3627
3628    /// Render a block-level image, video, or audio as one placeholder row: the
3629    /// `🖼 alt` / `🎬 alt` / `🔊 alt` label styled [`Role::Image`], every glyph
3630    /// mapped to the media's start offset and a caret stop there (they share the
3631    /// offset, so the stop table dedups them to a single home in front of it, as
3632    /// a rule's dashes do), and the row's end stop set past it so the caret can
3633    /// also rest after it. The row carries a [`MediaMark`] so [`media_spans`]
3634    /// publishes it as a [`MediaInfo`] a capable frontend replaces with the real
3635    /// picture or player; a plain surface paints the label as-is. `pf` is the
3636    /// block prefix (a list indent, a quote gutter) the row opens with, exactly
3637    /// as every other block honours it.
3638    fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
3639        let node = &self.nodes[img];
3640        let start = node.span.start;
3641        let end = node.span.end;
3642        // An `image`'s URL is twig's `destination`; a `<video>`/`<audio>` is a
3643        // generic element, so its URL is the `src` attribute — and may be absent
3644        // entirely, the element naming its candidates in child `<source>`s.
3645        let destination = match kind {
3646            MediaKind::Image => node.destination.clone().unwrap_or_default(),
3647            MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
3648        };
3649        let poster = match kind {
3650            MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
3651            MediaKind::Image | MediaKind::Audio => String::new(),
3652        };
3653        // The `<source>`s under the media element itself, not under `wrapper`: a
3654        // `<video>` is its own container, unlike an `<img>`, whose `<picture>`
3655        // alternatives are its *siblings* and so only reachable from the wrapper.
3656        let sources = match kind {
3657            MediaKind::Image => self.media_sources(wrapper),
3658            MediaKind::Video | MediaKind::Audio => self.media_sources(img),
3659        };
3660        let alt = self.image_alt(img);
3661        let sigil = kind.sigil();
3662        let label = if alt.is_empty() {
3663            // With no alt, name the file — but a `<video>` with neither `src` nor
3664            // alt has only its `<source>`s to be named by, so fall back to the
3665            // first candidate rather than labelling the row a bare sigil.
3666            let named = if destination.is_empty() {
3667                sources
3668                    .first()
3669                    .map(|s| s.srcset.as_str())
3670                    .unwrap_or_default()
3671            } else {
3672                &destination
3673            };
3674            format!("{sigil} {}", media_label(named))
3675        } else {
3676            format!("{sigil} {alt}")
3677        };
3678        let style = Style::default().role(Role::Image);
3679        let mut glyphs = pf.to_vec();
3680        for ch in label.chars() {
3681            glyphs.push(Glyph {
3682                ch,
3683                style,
3684                src: start,
3685                stop: true,
3686            });
3687        }
3688        // How many rows the frontend wants for this picture: the label row plus
3689        // the blank fillers below it. Absent (a GUI that lays images out in
3690        // pixels, an image that didn't resolve, or a plain surface) means the
3691        // bare one-row placeholder.
3692        let rows = self
3693            .media_rows
3694            .get(&destination)
3695            .copied()
3696            .unwrap_or(1)
3697            .max(1);
3698        // End past the image so the caret has a stop after it: the last glyph's
3699        // offset is the image *start*, not its extent, so `push_row`'s
3700        // last-glyph rule would strand the end stop inside the markup.
3701        self.push_row_at(glyphs, end);
3702        if let Some(row) = self.rows.last_mut() {
3703            row.media = Some(MediaMark {
3704                kind,
3705                destination,
3706                sources,
3707                alt,
3708                poster,
3709                rows,
3710            });
3711        }
3712        // Reserve the picture's remaining height as blank `decoration` rows: drawn
3713        // (so the frontend has the vertical room to paint the raster over them),
3714        // but holding no caret and contributing no stops — vertical motion steps
3715        // over them and the caret's only homes stay the stop in front of the image
3716        // and the one just past it, both on the label row above. They anchor at the
3717        // image's end offset so a click on the picture's lower half lands after it,
3718        // the nearest caret home. Mirrors how a table's box-rule rows reserve space
3719        // without ever holding the caret.
3720        for _ in 1..rows {
3721            self.rows.push(VRow {
3722                glyphs: Vec::new(),
3723                end_src: end,
3724                decoration: true,
3725                code: false,
3726                code_lang: None,
3727                directive: false,
3728                directive_label: None,
3729                media: None,
3730                task: None,
3731                leaf_directive: None,
3732                heading: None,
3733                align: None,
3734                line_height: None,
3735                boundary: None,
3736                mark_ends: Vec::new(),
3737            });
3738        }
3739        self.last_off = end;
3740    }
3741
3742    /// The `<picture>` alternatives inside block-image `wrapper`, in document
3743    /// order — every `<source>` element in its subtree. Empty when there's no
3744    /// `<picture>`. Each is a `<source>`'s `media` + `srcset`; core keeps them
3745    /// verbatim and picks none (see [`MediaSource`]). A `<source>` with no
3746    /// `srcset` is dropped (nothing to load); its `media` may be empty (an
3747    /// unconditional override), which a frontend treats as always-matching.
3748    ///
3749    /// It scans the wrapper's whole subtree (via the forward `first_child` /
3750    /// `next_sibling` links, the reliable ones) rather than the `<img>`'s parent,
3751    /// for two reasons. A `<picture>` reaches core in two shapes: twig promotes a
3752    /// block `<picture>` to an `element(picture)` wrapping `[source, img]`, but
3753    /// leaves an inline one's tags as raw siblings — `[raw "<picture>", source,
3754    /// img, raw "</picture>"]` — so the `<source>`s sit at different depths in
3755    /// the two. And the editor's flat arena leaves a promoted inline node's
3756    /// `parent` back-pointer dangling on a phantom root, so only the wrapper
3757    /// (known at the call site) is a trustworthy anchor. A block image is the
3758    /// sole visible content of its wrapper, so every `<source>` under it is its
3759    /// picture's.
3760    fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
3761        let mut out = Vec::new();
3762        self.collect_sources(wrapper, &mut out);
3763        out
3764    }
3765
3766    fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
3767        for c in self.children(id) {
3768            let node = &self.nodes[c];
3769            if node.name.as_deref() == Some("source") {
3770                // `<picture>` spells its candidate `srcset`, `<video>`/`<audio>`
3771                // spell it `src`. Both mean "the URL to load", so they normalise
3772                // onto one field; `srcset` wins where (illegally) both appear.
3773                let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
3774                if let Some(srcset) = url {
3775                    out.push(MediaSource {
3776                        media: attr_of(node, "media").unwrap_or_default(),
3777                        srcset,
3778                        mime: attr_of(node, "type").unwrap_or_default(),
3779                    });
3780                }
3781            }
3782            self.collect_sources(c, out);
3783        }
3784    }
3785
3786    /// The single block-level media `id`'s subtree resolves to, or `None`.
3787    ///
3788    /// A wrapper is a block picture when the only *visible* thing under it is one
3789    /// image: whitespace-only text and structure-only elements (a `<picture>`'s
3790    /// `<source>`, which declares an alternate but paints nothing) don't count,
3791    /// and the search descends through wrapping elements (`<picture>`, a linking
3792    /// `<a>`). This is what makes `<p><img></p>`, a bare `<img>`, and
3793    /// `<h1><picture>…<img></picture></h1>` all render as one framed picture.
3794    /// Any real text, or a second image, means it isn't image-only — it falls
3795    /// back to inline rendering, where the image still shows as its alt text.
3796    ///
3797    /// [`FlatNode`]'s snapshot doesn't carry an element's tag name, so a
3798    /// `<source>` can't be skipped by name — but it needs no special case:
3799    /// contributing no image and no text, it's simply invisible to the scan.
3800    fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
3801        let mut found = None;
3802        let mut count = 0usize;
3803        let mut has_text = false;
3804        self.scan_visual(id, &mut found, &mut count, &mut has_text);
3805        (count == 1 && !has_text).then(|| found.unwrap())
3806    }
3807
3808    /// Walk `id`'s subtree tallying visible leaves for [`media_only`]: each
3809    /// image, `<video>`, or `<audio>` (remembering the last, counting the total)
3810    /// and whether any non-whitespace text appears. Media isn't descended into —
3811    /// an image's inline children are alt text, and a `<video>`'s are its
3812    /// no-support fallback and its `<source>` declarations, none of which is
3813    /// document content.
3814    ///
3815    /// [`media_only`]: Self::media_only
3816    fn scan_visual(
3817        &self,
3818        id: usize,
3819        found: &mut Option<(usize, MediaKind)>,
3820        count: &mut usize,
3821        has_text: &mut bool,
3822    ) {
3823        for c in self.children(id) {
3824            let node = &self.nodes[c];
3825            match node.kind.as_str() {
3826                "image" => {
3827                    *found = Some((c, MediaKind::Image));
3828                    *count += 1;
3829                }
3830                // A `<video>`/`<audio>` reaches core as a generic `container`
3831                // (twig gives neither a semantic node, so `html_elements`
3832                // promotion leaves the tag name on `name`). Counted as media and
3833                // *not* descended into, so its `<source>` children and its
3834                // "your browser does not support…" fallback text neither add a
3835                // second count nor make the block look like text.
3836                "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3837                    let kind = match element_tag(node) {
3838                        Some("audio") => MediaKind::Audio,
3839                        _ => MediaKind::Video,
3840                    };
3841                    *found = Some((c, kind));
3842                    *count += 1;
3843                }
3844                // Text leaves: only non-whitespace counts as visible content.
3845                // (Twig keeps the whitespace `str`s between HTML tags — the
3846                // newlines and indentation inside a `<picture>` — as real nodes.)
3847                "str" | "smart_punctuation" | "verbatim" | "inline_math" => {
3848                    if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
3849                        *has_text = true;
3850                    }
3851                }
3852                // Structural breaks carry no visible glyph of their own.
3853                "soft_break" | "hard_break" | "non_breaking_space" => {}
3854                // Any other wrapper (emphasis, a link, a `<picture>`) is
3855                // transparent to the scan — descend into it.
3856                _ => self.scan_visual(c, found, count, has_text),
3857            }
3858        }
3859    }
3860
3861    /// A leaf directive (`::name{…}`) as one placeholder row — the
3862    /// [`block_media`](Self::block_media) recipe, for the same reason: it is a
3863    /// block that renders as *a thing*, not as text, and the frontend paints
3864    /// whatever the host app's vocabulary makes of it.
3865    ///
3866    /// The row's glyphs are a `⧉ label` (or `⧉ name`) stand-in a plain surface
3867    /// paints as-is, every glyph anchored at the directive's start with a caret
3868    /// stop there, and the row ending past it so the caret can also rest after
3869    /// it. It carries a [`DirectiveMark`] for [`directive_spans`], and is marked
3870    /// [`directive`](VRow::directive) so a frontend already drawing the
3871    /// container form's panel frames this one identically for free.
3872    ///
3873    /// Before this, a leaf directive emitted no rows at all: it was invisible,
3874    /// held no caret, and vertical motion crossed a void where it stood.
3875    fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
3876        let node = &self.nodes[id];
3877        let (start, end) = (node.span.start, node.span.end);
3878        let (name, attrs) = leaf_directive_identity(node);
3879        let label = self.image_alt(id); // its `[label]` children, flattened
3880        let shown = if label.is_empty() { &name } else { &label };
3881        let style = Style::default().role(Role::Image);
3882        let mut glyphs = pf.to_vec();
3883        for ch in format!("⧉ {shown}").chars() {
3884            glyphs.push(Glyph {
3885                ch,
3886                style,
3887                src: start,
3888                stop: true,
3889            });
3890        }
3891        // End past the directive so the caret has a stop after it — the same
3892        // reason `block_media` anchors its row at the image's end.
3893        self.push_row_at(glyphs, end);
3894        if let Some(row) = self.rows.last_mut() {
3895            row.directive = true;
3896            row.leaf_directive = Some(DirectiveMark {
3897                name,
3898                attrs,
3899                label,
3900                rows: 1,
3901            });
3902        }
3903        self.last_off = end;
3904    }
3905
3906    /// An image's alt text: the flattened text of its inline descendants (an
3907    /// image's children *are* its alt content), empty when it has none. Also a
3908    /// leaf directive's `[label]`, which is the same shape — inline children
3909    /// standing for the block.
3910    fn image_alt(&self, id: usize) -> String {
3911        let mut out = String::new();
3912        self.collect_text(id, &mut out);
3913        out
3914    }
3915
3916    /// Append every descendant's `text` to `out`, in document order. Inline text
3917    /// (`str`) nodes are leaves, so a node never contributes both its own text and
3918    /// a child's — no double counting.
3919    fn collect_text(&self, id: usize, out: &mut String) {
3920        for c in self.children(id) {
3921            if let Some(t) = &self.nodes[c].text {
3922                out.push_str(t);
3923            }
3924            self.collect_text(c, out);
3925        }
3926    }
3927
3928    fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
3929        let mut out = Vec::new();
3930        for c in self.children(id) {
3931            self.inline(c, base, &mut out);
3932        }
3933        out
3934    }
3935
3936    /// [`inline_children`](Self::inline_children) plus any trailing whitespace the
3937    /// block carries past its inline content (see [`trailing_ws_glyphs`]). Used
3938    /// for the leaf inline blocks — paragraphs and headings — whose own `span`
3939    /// bounds exactly one line of text, so the trailing gap is theirs. *Not* for
3940    /// a table cell, whose `span` is the whole row and would swallow the
3941    /// delimiters and neighbours between it and the row's end.
3942    ///
3943    /// [`trailing_ws_glyphs`]: Self::trailing_ws_glyphs
3944    fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
3945        let mut out = self.inline_children(id, base);
3946        out.extend(self.trailing_ws_glyphs(id, base));
3947        out
3948    }
3949
3950    /// Glyphs for whatever trailing whitespace a block's source carries past its
3951    /// last inline node — the space(s) at the end of `hello ` that Markdown and
3952    /// Djot drop from the `str` node as insignificant. twig still records them:
3953    /// a block's `content_span` ends at its last meaningful character while its
3954    /// `span` runs to the end of the line's text (before the terminating
3955    /// newline), so the gap between the two *is* that trailing whitespace.
3956    ///
3957    /// Emitting it as real caret-stop glyphs is what lets the caret be drawn
3958    /// past the last visible character. Without it, typing a space at the end of
3959    /// a paragraph moved the caret in the source but not on screen — the caret
3960    /// stuck on the last glyph until the next visible character reparsed the
3961    /// space into an interior `str` node that finally carried it.
3962    ///
3963    /// Restricted to spaces: only they are safe to synthesize one-cell-per-byte,
3964    /// and only they are what the parser silently strips. Anything else in the
3965    /// gap means the span accounting isn't what this assumes, so it's left alone.
3966    fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
3967        let node = &self.nodes[id];
3968        let Some(content) = &node.content_span else {
3969            return Vec::new();
3970        };
3971        let (from, to) = (content.end, node.span.end);
3972        let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
3973            return Vec::new();
3974        };
3975        if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
3976            return Vec::new();
3977        }
3978        slice
3979            .bytes()
3980            .enumerate()
3981            .map(|(i, _)| Glyph {
3982                ch: ' ',
3983                style,
3984                src: from + i,
3985                stop: true,
3986            })
3987            .collect()
3988    }
3989
3990    fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
3991        let node = &self.nodes[id];
3992        match node.kind.as_str() {
3993            "str" | "smart_punctuation" => push_escaped_text(
3994                out,
3995                node.text.as_deref().unwrap_or(""),
3996                node.span.clone(),
3997                self.source,
3998                base,
3999            ),
4000            "soft_break" | "hard_break" | "non_breaking_space" => {
4001                // A break renders as a real, caret-navigable glyph — but twig
4002                // gives it no span of its own (`0..0`), so the offset comes from
4003                // the text in front of it: one *past* the last glyph, which is
4004                // the newline the break stands for. Past, not on: sharing the
4005                // previous glyph's offset would put two stops on one byte, and a
4006                // caret that can't change offset can't move.
4007                let src = if node.span.start != 0 {
4008                    node.span.start
4009                } else {
4010                    out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
4011                };
4012                // A *hard* break renders as this run's break glyph — a newline
4013                // inside a table cell (its own line), the same space in prose the
4014                // frontend re-wraps. A soft break normally folds into a space;
4015                // under `LineFlow::Preserve` it renders as a `'\n'` too, so the
4016                // author's line break shows where it was written. Never inside a
4017                // cell (`break_glyph` is `'\n'` there): a cell is one line and
4018                // folds its own soft breaks regardless.
4019                let ch = if node.kind == Kind::HardBreak {
4020                    self.break_glyph.get()
4021                } else if node.kind == Kind::SoftBreak
4022                    && self.preserve_soft
4023                    && self.break_glyph.get() == ' '
4024                {
4025                    '\n'
4026                } else {
4027                    ' '
4028                };
4029                out.push(Glyph {
4030                    ch,
4031                    style: base,
4032                    src,
4033                    stop: true,
4034                });
4035            }
4036            // A cell's only spelling for an in-line break is a raw `<br>`; read it
4037            // back as one (outside a cell it stays the literal text it falls to
4038            // below). The tag's bytes carry no stop of their own — the line it
4039            // ends stops just before it, the next just after.
4040            "raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
4041                out.push(Glyph {
4042                    ch: '\n',
4043                    style: base,
4044                    src: node.span.start,
4045                    stop: true,
4046                });
4047            }
4048            "emph" => self.inline_delimited(id, base.italic(), out),
4049            "strong" => self.inline_delimited(id, base.bold(), out),
4050            // A coloured highlight's emoji is spelling, not content: twig strips
4051            // it and records the colour on the node, so the glyphs are the
4052            // author's words and the colour rides the role. Revealed markup
4053            // still shows the emoji, because `delims` reads the source bytes
4054            // between the span and the content span — which is exactly the
4055            // `==🔴 ` the author typed.
4056            "mark" => {
4057                let color = MarkColor::from_attrs(&node.attrs);
4058                self.inline_delimited(id, base.role(Role::Mark(color)), out)
4059            }
4060            "insert" => self.inline_delimited(id, base.underline(), out),
4061            "delete" => self.inline_delimited(id, base.strikethrough(), out),
4062            // The one pair whose whole meaning is *where the glyphs sit*. Drawn
4063            // in the surrounding style otherwise, so `^**2**^` stays bold and a
4064            // superscript inside a heading keeps the heading's role — which is
4065            // exactly why this is a `Baseline` and not a `Role`.
4066            "superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
4067            "subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
4068            "verbatim" | "inline_math" => {
4069                // The interior begins at `content_span.start` — past however many
4070                // backticks the fence used, which `span.start + 1` only guessed
4071                // right for a single one. Fall back to that guess if it's absent.
4072                let at = node
4073                    .content_span
4074                    .as_ref()
4075                    .map_or(node.span.start + 1, |c| c.start);
4076                let style = base.role(Role::Code);
4077                // Not `inline_delimited`: verbatim has no child nodes to recurse
4078                // into — its content is its own `text` — so the fences bracket a
4079                // `push_text` instead. The fences themselves keep `Role::Code`'s
4080                // sibling treatment via `push_delim`'s role override.
4081                let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
4082                if let Some((open, _)) = &show {
4083                    self.push_delim(out, open, style);
4084                }
4085                push_text(out, node.text.as_deref().unwrap_or(""), at, style);
4086                match &show {
4087                    Some((_, close)) => self.push_delim(out, close, style),
4088                    None => self.note_mark_end(id),
4089                }
4090            }
4091            // An attributed span — the run-level half of the presentation
4092            // vocabulary. djot's `[text]{…}`, AsciiDoc's `[.a]#text#`, HTML's
4093            // and Markdown's `<span …>`: one node with a name twig hands back
4094            // for two of the four (see [`is_run_span`]), all four carrying the
4095            // author's `data-size`, `data-font` and `data-color` on the run
4096            // they cover.
4097            //
4098            // The keys are written over the surrounding style rather than
4099            // replacing it, so a span inside a block that names its own size
4100            // wins on size and keeps the block's face — the nearest-wins rule
4101            // the block walker applies through a `div`. A key the span does not
4102            // name is one the block still says.
4103            //
4104            // A `data-color` here is the text's *foreground*, where the same key
4105            // on a `mark` is a highlight's background: same vocabulary, same
4106            // enum, and no collision, because a `mark` is a `mark` and a span is
4107            // a span.
4108            //
4109            // Otherwise this is the plain `recurse` an anonymous container has
4110            // always had — no delimiters, because the `{…}` is markup and the
4111            // span's text is the author's words.
4112            "container" if is_run_span(node) && !self.children(id).is_empty() => {
4113                self.recurse(id, run_style(node, base, &self.faces), out)
4114            }
4115            // A text directive (`:name[label]{…}`) — the inline form of a generic
4116            // directive. Its `[label]` children are the visible text; the name and
4117            // the `{…}` attributes are the host app's vocabulary (diaryx's
4118            // `:vis[…]`) and stay hidden markup, exactly as a link's `](dest)` is.
4119            // Drawn in the surrounding style: a role of its own would need one
4120            // every frontend maps, and the bug this fixes is that the text was
4121            // invisible, not that it was unstyled.
4122            "container" if container_is_directive(node) && !self.children(id).is_empty() => {
4123                self.recurse(id, base, out)
4124            }
4125            // No `[label]`, so there are no children to render and recursing
4126            // emitted *nothing*: the directive's bytes vanished from the document
4127            // and left no caret stop behind. What to draw instead turns on
4128            // whether the syntax looks deliberate.
4129            //
4130            // Bare `:word` almost never is. twig matches a colon followed by any
4131            // letter-led word (`scanTextDirective`, deliberately matching remark),
4132            // so ordinary prose is full of them — `:see below`, a `:smile:`
4133            // shortcode, a stray colon before a word. Those are prose, and prose
4134            // renders as itself: every byte visible, every byte a caret stop, so a
4135            // colon typed by accident can be seen and deleted. Hiding them behind
4136            // a placeholder would be the invisible-and-unreachable failure this
4137            // arm exists to fix, just wearing a nicer glyph.
4138            "container" if container_is_directive(node) && node.attrs.is_empty() => {
4139                let span = node.span.clone();
4140                push_text(
4141                    out,
4142                    self.source.get(span.clone()).unwrap_or(""),
4143                    span.start,
4144                    base,
4145                );
4146            }
4147            // `{…}` attributes, though, are unmistakably deliberate — nobody
4148            // types `:vis{.family}` by accident, and diaryx writes exactly that
4149            // inline. So an attribute-bearing directive with no label draws as a
4150            // chip on `block_directive`'s recipe (`⧉ name attrs`, `Role::Image`),
4151            // the inline peer of the leaf form's placeholder row.
4152            //
4153            // Only the first glyph is a caret stop, and the whole chip shares the
4154            // directive's start offset: the caret treats it as one atomic thing
4155            // rather than walking hidden markup a byte at a time, and a paragraph
4156            // holding nothing but a chip still has a stop to be navigated to.
4157            "container" if container_is_directive(node) => {
4158                let start = node.span.start;
4159                let name = node.name.clone().unwrap_or_default();
4160                let shown = match directive_attr_label(&node.attrs) {
4161                    Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
4162                    Some(attrs) => format!("⧉ {attrs}"),
4163                    None => format!("⧉ {name}"),
4164                };
4165                let style = base.role(Role::Image);
4166                for (i, ch) in shown.chars().enumerate() {
4167                    out.push(Glyph {
4168                        ch,
4169                        style,
4170                        src: start,
4171                        stop: i == 0,
4172                    });
4173                }
4174            }
4175            // A footnote reference (`[^1]`). The label bracketed is what a reader
4176            // needs — bare, `note1` reads as a typo rather than a reference — so
4177            // the `^` is hidden as the spelling artefact it is (a link's
4178            // `](dest)` goes the same way) and the brackets are kept as
4179            // decoration: one shared offset, never a caret stop, like a table's
4180            // borders, so the caret walks the label alone.
4181            //
4182            // Styled `Role::Link`: a reference *is* a link to its definition, and
4183            // every frontend already paints that role. A role of its own would
4184            // need one in each of them, and what a frontend needs to tell the two
4185            // apart is not a paint colour but an answer to "what does clicking
4186            // here do" — which is [`Doc::footnote_at_caret`]'s job, not a glyph's.
4187            //
4188            // Raised, though, because that a reference is *set* differently from
4189            // the prose it interrupts is exactly what makes it read as a
4190            // reference. `[1]` at body size reads as bracketed text.
4191            "footnote_reference" => {
4192                let style = base.role(Role::Link);
4193                // Revealed, the reference is just its source bytes: the `^` that
4194                // is normally elided comes back and every byte becomes a real
4195                // stop, so the brackets stop being decoration and start being
4196                // text. That's the whole point of the mode, and it replaces the
4197                // hand-built chip below rather than decorating it — including the
4198                // raised baseline, since what's on screen there is source, and
4199                // source is set as prose.
4200                if self.revealed(&node.span) {
4201                    self.push_delim(out, &node.span, style);
4202                    return;
4203                }
4204                let style = style.baseline(Baseline::Super);
4205                // The label's own span, so its glyphs map to their true bytes.
4206                // Absent one, it starts past the `[^` that opens the reference.
4207                let (label, at) = match &node.content_span {
4208                    Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
4209                    None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
4210                };
4211                out.push(Glyph {
4212                    ch: '[',
4213                    style,
4214                    src: node.span.start,
4215                    stop: false,
4216                });
4217                push_text(out, label, at, style);
4218                out.push(Glyph {
4219                    ch: ']',
4220                    style,
4221                    src: node.span.end.saturating_sub(1),
4222                    stop: false,
4223                });
4224            }
4225            "link" | "url" | "email" => {
4226                let style = base.role(Role::Link);
4227                if self.children(id).is_empty() {
4228                    // A bare autolink (`<a@b.c>`, a naked URL): the destination
4229                    // *is* the visible text, so there is nothing elided to
4230                    // reveal and both modes draw the same thing.
4231                    push_text(
4232                        out,
4233                        node.destination
4234                            .as_deref()
4235                            .or(node.text.as_deref())
4236                            .unwrap_or("link"),
4237                        node.span.start,
4238                        style,
4239                    );
4240                } else {
4241                    // An inline link reveals asymmetrically — `[` before the
4242                    // label, `](dest)` after it — which the generic
4243                    // span-minus-content derivation already produces.
4244                    self.inline_delimited(id, style, out);
4245                }
4246            }
4247            _ => {
4248                if self.children(id).is_empty() {
4249                    if let Some(t) = &node.text {
4250                        push_text(out, t, node.span.start, base);
4251                    }
4252                } else {
4253                    self.recurse(id, base, out);
4254                }
4255            }
4256        }
4257    }
4258
4259    fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
4260        for c in self.children(id) {
4261            self.inline(c, style, out);
4262        }
4263    }
4264
4265    /// Lay a block's inline `glyphs` into visual rows, prefixing the first with
4266    /// `pf` and the rest with `pc`. A preserved soft break arrives as a `'\n'`
4267    /// glyph (see the `soft_break` arm): a hard row boundary that splits the
4268    /// glyphs so each run lays out on its own and the author's line structure
4269    /// shows on screen. The `'\n'` is dropped from the row it closes and its
4270    /// source offset becomes that row's end stop — exactly how a table cell's
4271    /// in-line `<br>` is handled — so the caret can rest at the line's end
4272    /// without a zero-width control char leaking into what the frontends render.
4273    /// With no `'\n'` present (the folding default, and every build that isn't
4274    /// `LineFlow::Preserve`) there is one run and this is byte-identical to
4275    /// laying the glyphs out directly.
4276    fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
4277        if !glyphs.iter().any(|g| g.ch == '\n') {
4278            self.emit_line(glyphs, block_start, pf, pc, None);
4279            return;
4280        }
4281        // Each run up to a '\n' is a line of its own: the first wears the block's
4282        // opening prefix, every later one the continuation prefix, and the break's
4283        // own offset ends the run's last row. The break glyph is dropped. A
4284        // trailing '\n' flushes its run and leaves nothing behind, so no spurious
4285        // blank row follows it.
4286        let mut run: Vec<Glyph> = Vec::new();
4287        let mut first = true;
4288        for g in glyphs {
4289            if g.ch == '\n' {
4290                let lead = if first { pf } else { pc };
4291                self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
4292                first = false;
4293            } else {
4294                run.push(g);
4295            }
4296        }
4297        if !run.is_empty() {
4298            let lead = if first { pf } else { pc };
4299            self.emit_line(run, block_start, lead, pc, None);
4300        }
4301    }
4302
4303    /// Word-wrap a single line of `glyphs` (no interior line breaks) to the
4304    /// available width and push the visual rows, prefixing the first with `pf`
4305    /// and the rest with `pc`. `end`, when set, is the source offset that ends
4306    /// the line's final row — the offset of the break that terminated it, which
4307    /// the caller has already stripped from `glyphs`; when `None` the row ends
4308    /// just past its last glyph, as an unbroken block's does.
4309    fn emit_line(
4310        &mut self,
4311        glyphs: Vec<Glyph>,
4312        block_start: usize,
4313        pf: &[Glyph],
4314        pc: &[Glyph],
4315        end: Option<usize>,
4316    ) {
4317        // The line's final row ends at `end` when a break gave one, else just
4318        // past its last glyph (`push_row`'s default).
4319        let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
4320            Some(e) => b.push_row_at(row, e),
4321            None => b.push_row(row, block_start),
4322        };
4323
4324        // No column budget: emit the whole line as one row and let the frontend
4325        // wrap it at its own (pixel) width.
4326        let Some(width) = self.wrap else {
4327            let row = if glyphs.is_empty() {
4328                pf.to_vec()
4329            } else {
4330                concat(pf, &glyphs)
4331            };
4332            push_last(self, row);
4333            return;
4334        };
4335
4336        // Split into words (maximal non-space runs), each carrying the space
4337        // glyph that followed it (so its source offset is preserved).
4338        let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4339        let mut word: Vec<Glyph> = Vec::new();
4340        for g in glyphs {
4341            if g.ch == ' ' {
4342                words.push((std::mem::take(&mut word), Some(g)));
4343            } else {
4344                word.push(g);
4345            }
4346        }
4347        if !word.is_empty() {
4348            words.push((word, None));
4349        }
4350        if words.is_empty() {
4351            // An empty block (or an empty preserved line) still occupies one
4352            // (prefixed) row.
4353            push_last(self, pf.to_vec());
4354            return;
4355        }
4356
4357        let mut line: Vec<Glyph> = Vec::new();
4358        let mut used = 0usize;
4359        let mut first = true;
4360        for (w, space) in words {
4361            let avail = width
4362                .saturating_sub(prefix_width(if first { pf } else { pc }))
4363                .max(1);
4364            let cells = glyphs_width(&w);
4365            if used > 0 && used + cells > avail {
4366                let row = concat(if first { pf } else { pc }, &line);
4367                self.push_row(row, block_start);
4368                line = Vec::new();
4369                used = 0;
4370                first = false;
4371            }
4372            used += cells;
4373            line.extend(w);
4374            if let Some(sp) = space {
4375                used += 1;
4376                line.push(sp);
4377            }
4378        }
4379        let row = concat(if first { pf } else { pc }, &line);
4380        push_last(self, row);
4381    }
4382
4383    /// The source offset of each line of a code block's `text`.
4384    ///
4385    /// `content` is the block's `content_span` — where twig says the body lives
4386    /// in the source, fences already excluded. Its lines run 1:1 with the
4387    /// rendered `text` lines, so no search is needed; each is anchored at the
4388    /// *end* of its source line, which places it past whatever indent `text` had
4389    /// stripped (a fenced block's fences, an indented one's leading spaces)
4390    /// without having to know how much there was.
4391    ///
4392    /// `None` when the body and the rendered lines don't line up — a coarse
4393    /// fallback the caller turns into the block's start offset.
4394    fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
4395        let mut src_lines: Vec<(usize, &str)> = Vec::new();
4396        let mut at = content.start;
4397        for l in self.source.get(content.start..content.end)?.split('\n') {
4398            src_lines.push((at, l));
4399            at += l.len() + 1;
4400        }
4401        if src_lines.len() != lines.len() {
4402            return None;
4403        }
4404        Some(
4405            lines
4406                .iter()
4407                .zip(&src_lines)
4408                .map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
4409                .collect(),
4410        )
4411    }
4412
4413    fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
4414        // Step past the character the *source* holds at the last glyph's offset,
4415        // not past the glyph's own `ch`. The two agree for ordinary text, but a
4416        // glyph is not always the character it stands on: `synth` decoration and
4417        // a substituted run (an image's `⧉ label`) share one offset by design.
4418        // Trusting `ch` there yields an offset inside a multi-byte character,
4419        // which every later slice of `source` panics on.
4420        let end_src = glyphs
4421            .last()
4422            .map(|g| {
4423                let at = g.src.min(self.source.len());
4424                at + self.source[at..].chars().next().map_or(0, char::len_utf8)
4425            })
4426            .unwrap_or(fallback);
4427        self.push_row_at(glyphs, end_src);
4428    }
4429
4430    /// Push a row with an explicit end stop, for content that knows its own
4431    /// extent better than its last glyph does.
4432    fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
4433        self.last_off = end_src;
4434        let mark_ends = self.take_mark_ends(end_src);
4435        self.rows.push(VRow {
4436            glyphs,
4437            end_src,
4438            decoration: false,
4439            code: false,
4440            code_lang: None,
4441            directive: false,
4442            directive_label: None,
4443            media: None,
4444            task: None,
4445            leaf_directive: None,
4446            heading: None,
4447            align: None,
4448            line_height: None,
4449            boundary: None,
4450            mark_ends,
4451        });
4452    }
4453
4454    /// The quote's own trailing marker lines: the `>` / `> ` lines that lie past
4455    /// its last child but inside its span, one gutter row each.
4456    ///
4457    /// Pressing Enter at the end of `> a` writes `> a\n>\n> \n` — twig's
4458    /// spelling, and the right one. Those last two lines hold no block (a
4459    /// `block_quote`'s `content_span` still stops at its last child) so the
4460    /// children walk never reaches them, and they used to fall all the way to
4461    /// the document-level [`Builder::emit_trailing_blank_lines`], which knows no
4462    /// prefix: the gutter simply stopped, and a writer adding a line to a quote
4463    /// watched it draw as plain prose.
4464    ///
4465    /// This is only answerable since twig 3.2.0, where a Markdown `block_quote`'s
4466    /// span covers its own trailing marker lines (it reported `0..3` for that
4467    /// source and now reports `0..8`). Before that the lines belonged to no node
4468    /// at any level, and the only way to draw them was to sniff `>` off the raw
4469    /// source and re-derive the nesting depth by counting markers — format
4470    /// inference this crate exists to keep out of the render path.
4471    ///
4472    /// Each row is a real caret home rather than a decoration gap: the writer
4473    /// spelled every one of these lines with a marker of its own, so each is a
4474    /// line of the quote to stand on, not the spacing between two blocks (which
4475    /// is [`Builder::emit_separators_before`]'s, and falls *between* children
4476    /// where this never looks).
4477    fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
4478        let end = end.min(self.source.len());
4479        let mut at = self.rows.last().map_or(0, |r| r.end_src);
4480        // Walk line by line from the last child's end to the quote's, taking each
4481        // line's *end* as the row's offset — the caret home at the end of a line
4482        // is where one on an empty quoted line belongs, and it keeps every row's
4483        // offset distinct from its neighbours'.
4484        while at < end {
4485            let Some(k) = self.source[at..end].find('\n') else {
4486                break;
4487            };
4488            let line_start = at + k + 1;
4489            let line_end = self.source[line_start..end]
4490                .find('\n')
4491                .map_or(end, |i| line_start + i);
4492            self.push_row_at(pc.to_vec(), line_end);
4493            at = line_end;
4494        }
4495    }
4496
4497    /// The source offset the caret rests at on the blank line separating a block
4498    /// that ends at `prev_end` from the next block starting at `next_start`:
4499    /// just past the newline that terminates the previous block, but kept
4500    /// strictly before the next block so the offset is unique to this row.
4501    fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
4502        let after_nl = self.source[prev_end..]
4503            .find('\n')
4504            .map_or(prev_end, |p| prev_end + p + 1);
4505        after_nl.min(next_start.saturating_sub(1)).max(prev_end)
4506    }
4507
4508    /// The source offset of each blank row between a block ending at `prev_end`
4509    /// and content starting at `next_start` — one per blank source line. The
4510    /// first newline terminates the previous block's line; every line it opens up
4511    /// to (but not including) the line that holds `next_start` is a blank row the
4512    /// caret can occupy. Offsets are unique and ascending so `pos_of_offset`
4513    /// resolves each to its own row. Empty when the two blocks are tight (no
4514    /// blank line between them).
4515    fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
4516        // Spans aren't always in tidy source order (e.g. a block after
4517        // frontmatter can start *before* the previous block's rendered content
4518        // ends). There's no blank line to place then — fall back to the clamped
4519        // single separator (an empty return) rather than slicing an inverted
4520        // range.
4521        if next_start <= prev_end {
4522            return Vec::new();
4523        }
4524        let gap = &self.source[prev_end..next_start];
4525        let Some(nl) = gap.find('\n') else {
4526            return Vec::new();
4527        };
4528        // The line holding `next_start` belongs to the next block; blank rows
4529        // stop before it.
4530        let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
4531        let mut offs = Vec::new();
4532        let mut start = prev_end + nl + 1;
4533        while start < next_line_start {
4534            offs.push(start);
4535            match self.source[start..next_start].find('\n') {
4536                Some(k) => start += k + 1,
4537                None => break,
4538            }
4539        }
4540        offs
4541    }
4542
4543    /// Blank lines the user typed past the end of the last block (e.g. two
4544    /// `Enter`s to open a fresh paragraph) leave no AST node, so nothing renders
4545    /// and the caret appears stuck on the old line. Reconstruct one empty row
4546    /// per extra trailing newline from the source, each at its own offset, so
4547    /// the caret rides down onto the new line the moment it's created.
4548    ///
4549    /// `above` is the class of the last block in the document — the one this gap
4550    /// closes. A document with no blocks at all has nothing above these rows, and
4551    /// [`BlockClass::Paragraph`] is the honest answer there too: what they are is
4552    /// empty paragraphs, on both sides of the gap.
4553    fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
4554        // With no rows at all the count starts past any hidden frontmatter, not
4555        // at 0: its newlines are not trailing blank lines, and counting them
4556        // opened phantom rows *inside* the metadata for a frontmatter-only file.
4557        //
4558        // Or past the last hidden block, if that is later: a closing comment
4559        // draws no row, and its lines are not blank lines the author opened.
4560        let last_end = self
4561            .rows
4562            .last()
4563            .map_or(hidden_end, |r| r.end_src)
4564            .max(self.stepped_over);
4565        if last_end >= self.source.len() {
4566            return;
4567        }
4568        // The first newline after the last content just terminates that line, so
4569        // a lone trailing `\n` (an ordinary file ending) opens no blank row. A
4570        // *second* newline opens an empty paragraph: render it the way a block
4571        // boundary is rendered — a blank spacer row, then the empty paragraph row
4572        // the caret rests on — so the just-pressed-Enter view already shows the
4573        // gap it will keep once text is typed, and typing doesn't shift the line
4574        // down. One row per trailing newline (each its own caret offset), the
4575        // last landing at the document end where the caret sits.
4576        let extra = self.source[last_end..].matches('\n').count();
4577        if extra < 2 {
4578            return;
4579        }
4580        for k in 1..=extra {
4581            self.rows.push(VRow {
4582                glyphs: Vec::new(),
4583                end_src: last_end + k,
4584                // As between two blocks: the first blank row is the gap that
4585                // closes the block above, not somewhere to type. Nothing follows
4586                // to need a gap of its own, though, so every row after it is a
4587                // real empty paragraph — the end of the document bounds the last
4588                // one the way a following block would. Preserve flow makes even
4589                // that first row navigable, as it does every blank line.
4590                decoration: !self.preserve_soft && k == 1,
4591                code: false,
4592                code_lang: None,
4593                directive: false,
4594                directive_label: None,
4595                media: None,
4596                task: None,
4597                leaf_directive: None,
4598                heading: None,
4599                align: None,
4600                line_height: None,
4601                // The one drawn row here is a block boundary like any other —
4602                // "rendered the way a block boundary is rendered" is the whole
4603                // point of it — so it says so, and a frontend spacing boundaries
4604                // spaces this one the same. The rows below it are navigable empty
4605                // paragraphs, not gaps.
4606                boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
4607                    above,
4608                    below: BlockClass::Paragraph,
4609                }),
4610                mark_ends: Vec::new(),
4611            });
4612        }
4613    }
4614}
4615
4616// ── display width ────────────────────────────────────────────────────────────
4617//
4618// Two things a row can be counted in, and they are not the same number:
4619//
4620//   *glyphs*, one per codepoint — how the text is stored here, and what an
4621//   index into `VRow::glyphs` means; and
4622//   *columns*, one per terminal cell — where the text is drawn, and what every
4623//   `col` in this crate means.
4624//
4625// `你` is one glyph in two columns. Counting columns with `glyphs.len()` (or,
4626// in the source view, `chars().count()`) is the same number only for the ASCII
4627// that most fixtures are written in, and drifts one cell per wide character
4628// everywhere else — the caret drawn a column short of the text it types into.
4629// Everything below converts between the two; nothing else should have to.
4630
4631/// The display width of `s` in terminal cells.
4632///
4633/// Measured per grapheme cluster, because that is the unit a surface advances
4634/// by: `👨‍👩‍👧` is five codepoints measuring 2 + 0 + 2 + 0 + 2 cells one at a
4635/// time, but the character they spell is drawn in 2. Both frontends already
4636/// measure it that way — ratatui asks `unicode-width` per cluster, and the GUI
4637/// asks its own text system — so the caret only lands where the text is if this
4638/// agrees with them.
4639pub fn text_width(s: &str) -> usize {
4640    UnicodeWidthStr::width(s)
4641}
4642
4643/// One grapheme cluster of a laid-out row: the glyphs that spell it, and the
4644/// cells it is drawn in.
4645///
4646/// The cluster, not the glyph, is what has a width. A row's glyphs are one per
4647/// codepoint, so an accented letter or an emoji is several of them drawn in one
4648/// character's worth of cells — the glyph that opens the cluster claims those
4649/// cells, and the ones continuing it are drawn *inside* them rather than beside
4650/// them. It's the same cluster the stop table is built on: the opening glyph is
4651/// the one a caret can rest on, and so the only one whose column it can be
4652/// drawn at.
4653struct Cluster {
4654    /// Index of the glyph that opens it.
4655    glyph: usize,
4656    /// The display column it starts at.
4657    col: usize,
4658    /// How many cells it is drawn in. Zero for a cluster with no width of its
4659    /// own (a lone joiner), which therefore sits at no column at all.
4660    cells: usize,
4661}
4662
4663/// Walk a row's glyphs as the clusters they spell, in column order.
4664fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
4665    let text: String = glyphs.iter().map(|g| g.ch).collect();
4666    let mut out = Vec::new();
4667    let (mut glyph, mut col) = (0, 0);
4668    for cluster in text.graphemes(true) {
4669        let cells = text_width(cluster);
4670        out.push(Cluster { glyph, col, cells });
4671        // One glyph per codepoint, so a cluster spans exactly its own.
4672        glyph += cluster.chars().count();
4673        col += cells;
4674    }
4675    out
4676}
4677
4678/// The display width of a run of glyphs.
4679fn glyphs_width(glyphs: &[Glyph]) -> usize {
4680    clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
4681}
4682
4683/// A cell's display width — the widest of its lines, since an in-cell `\n` break
4684/// splits it into several. Sizes the column that must hold every line.
4685fn cell_width(glyphs: &[Glyph]) -> usize {
4686    glyphs
4687        .split(|g| g.ch == '\n')
4688        .map(glyphs_width)
4689        .max()
4690        .unwrap_or(0)
4691}
4692
4693/// Whether a raw inline HTML tag is a line break (`<br>`, `<br/>`, `<br />`,
4694/// case-insensitively) — the one tag a table cell reads as an in-cell break.
4695fn is_br(text: Option<&str>) -> bool {
4696    let Some(t) = text else { return false };
4697    matches!(
4698        t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
4699        "<br>" | "<br/>"
4700    )
4701}
4702
4703impl VRow {
4704    /// The row's width in display columns — and so the column of the caret
4705    /// placed past its last glyph, which is the rightmost column it can occupy.
4706    fn width(&self) -> usize {
4707        glyphs_width(&self.glyphs)
4708    }
4709
4710    /// The display column glyph `i` is drawn at. Glyphs continuing a cluster
4711    /// report the column of the glyph that opened it, since that is where they
4712    /// are drawn; none of them is ever a stop, so no caret is placed by it.
4713    fn col_of_glyph(&self, i: usize) -> usize {
4714        clusters(&self.glyphs)
4715            .iter()
4716            .rev()
4717            .find(|c| c.glyph <= i)
4718            .map_or(0, |c| c.col)
4719    }
4720
4721    /// The glyph drawn at display column `col`, or `None` past the row's last
4722    /// cell.
4723    ///
4724    /// A column landing on the *second* cell of a wide glyph resolves to that
4725    /// glyph: half a character is not a place to be, so clicking either cell of
4726    /// `你` means `你`, and the caret comes to rest at its start — the column it
4727    /// would be drawn at anyway. That rule is what makes the mapping invertible:
4728    /// every offset has one column, and every column has one offset.
4729    fn glyph_at_col(&self, col: usize) -> Option<usize> {
4730        clusters(&self.glyphs)
4731            .into_iter()
4732            .find(|c| col < c.col + c.cells)
4733            .map(|c| c.glyph)
4734    }
4735}
4736
4737// ── helpers ──────────────────────────────────────────────────────────────────
4738
4739/// The caret home inside an *empty* table cell (`col`, 0-based) whose node
4740/// `span` is `src` starting at byte `start`. twig gives an empty cell no
4741/// `content_span`, so its interior is read from the pipes: the home is one
4742/// space past the pipe that opens the cell — mimicking the `| ` padding a
4743/// filled cell has — and never at or past the pipe that closes it. So
4744/// `|  |  |` gives the two cells distinct, editable homes instead of both
4745/// collapsing onto the row's start.
4746///
4747/// Two shapes of span are read. A twig from 3.3.3 gave every cell of a row
4748/// the *row's* span, so the cell's own pipes are the `col`-th and
4749/// `col+1`-th unescaped `|` in it; a later twig spans a cell from the pipe
4750/// that opens it to the one that closes it, exclusive, so the span holds at
4751/// most that one pipe, at its start, and the closing one is the byte past
4752/// its end. The two are told apart by the pipes the span holds — a row's
4753/// span has several, or one that is not at its start.
4754fn empty_cell_offset(src: &str, start: usize, col: usize) -> usize {
4755    let bytes = src.as_bytes();
4756    let mut pipes = Vec::new();
4757    for (i, &b) in bytes.iter().enumerate() {
4758        if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
4759            pipes.push(i);
4760        }
4761    }
4762    let whole_row = pipes.len() > 1 || pipes.first().is_some_and(|&i| i != 0);
4763    let (open, close) = if whole_row {
4764        (pipes.get(col).copied(), pipes.get(col + 1).copied())
4765    } else {
4766        (pipes.first().copied(), Some(src.len()))
4767    };
4768    match (open, close) {
4769        (Some(open), Some(close)) => {
4770            let lo = open + 1; // just inside the opening pipe
4771            let hi = close.saturating_sub(1); // just inside the closing pipe
4772            let inside = if hi < lo {
4773                lo
4774            } else {
4775                (open + 2).clamp(lo, hi)
4776            };
4777            start + inside
4778        }
4779        (Some(open), None) => start + open + 1,
4780        _ => start,
4781    }
4782}
4783
4784/// One laid-out table cell: its rendered text, the source range that text
4785/// occupies (`start`/`end` are the caret anchors decoration points at), and the
4786/// column alignment its padding honours.
4787///
4788/// `glyphs` is the cell's inline content *unwrapped* — the box-drawn rows wrap
4789/// it to a column width, but a frontend laying the grid out itself needs the
4790/// text before that decision was made.
4791#[derive(Clone)]
4792pub struct TableCell {
4793    pub glyphs: Vec<Glyph>,
4794    pub start: usize,
4795    pub end: usize,
4796    pub align: Alignment,
4797}
4798
4799/// One row of a table's grid, as the document spells it — not as it's drawn.
4800#[derive(Clone)]
4801pub struct TableRow {
4802    /// A header row: drawn bold, and ruled off from the body below it.
4803    pub head: bool,
4804    pub cells: Vec<TableCell>,
4805}
4806
4807/// A table's structure, published alongside the box-drawn rows that spell it.
4808///
4809/// The rows in [`VisualMap::rows`] are the *default monospace* picture of a
4810/// table: every border a `│`, every column a whole number of character cells.
4811/// That picture is exactly right on any monospace surface, and unfixable off one
4812/// — in a proportional font the `│`s of two rows land at different x and the grid
4813/// shears. So a frontend that draws its own geometry reads this instead: the
4814/// cells, their alignment, and which rows are the head, with no opinion about
4815/// how wide a column is or what a border looks like.
4816///
4817/// Both are always built. The TUI paints `rows` and ignores this; the GUI skips
4818/// `rows` for the span in `rows_span` and draws from here. They describe the
4819/// same cells, so the caret lands on the same offsets either way.
4820#[derive(Clone)]
4821pub struct TableInfo {
4822    /// The `VisualMap::rows` this table's picture occupies, borders included —
4823    /// what a frontend drawing its own table skips over.
4824    pub rows_span: Range<usize>,
4825    /// The end of the table node's source span, and the offset its trailing
4826    /// caret stop sits at — the one caret home past the last cell, held by the
4827    /// bottom border row's end. Typing there opens a paragraph under the table
4828    /// rather than joining the block; see `Doc::open_paragraph_at_block_edge`.
4829    pub end_src: usize,
4830    /// The block prefix every row of this table carries — a blockquote's `│ `
4831    /// gutter, a list item's indent. Empty for a table at the top level.
4832    ///
4833    /// A frontend drawing its own grid has to render this and start the table
4834    /// past it, exactly as the picture does; a table nested in a quote that
4835    /// draws flush at the left margin has left the quote.
4836    pub prefix: Vec<Glyph>,
4837    pub grid: Vec<TableRow>,
4838}
4839
4840/// A fenced or indented code block, named by the [`VisualMap::rows`] it occupies.
4841///
4842/// Unlike a table, the rows *are* the block's content — a frontend still paints
4843/// them, it just draws a border and a tinted background around the whole span
4844/// and lets the code inside scroll horizontally instead of wrapping. So this
4845/// carries only the row range; there's no structural alternative to the picture
4846/// the way [`TableInfo`] is one. Derived from [`VRow::code`] — see
4847/// [`code_block_spans`].
4848#[derive(Clone, Debug, PartialEq, Eq)]
4849pub struct CodeBlockInfo {
4850    /// The contiguous run of [`VisualMap::rows`] this code block spans, blank
4851    /// code lines included.
4852    pub rows_span: Range<usize>,
4853    /// The block's language, from a fenced block's info string — what a frontend
4854    /// paints as a small label on the box (`` ```rust `` → `Some("rust")`).
4855    /// `None` for a fence written without one, or an indented block. Editing it
4856    /// goes through [`crate::Doc::set_code_language`], which re-finds the fence
4857    /// in the AST, so this stays a display string.
4858    pub lang: Option<String>,
4859}
4860
4861/// A block-level image (`![alt](url)` on its own line), named by the single
4862/// [`VisualMap::rows`] row it occupies.
4863///
4864/// Like [`CodeBlockInfo`], the row *is* the block's default rendering — a plain
4865/// surface paints the `🖼 alt` placeholder glyphs as-is. An image-capable
4866/// frontend instead **skips the row in `rows_span`** and paints the resolved
4867/// picture there, exactly as it skips a [`TableInfo`]'s box-drawn rows. Derived
4868/// from [`VRow::media`] by [`media_spans`], so it survives the row reuse of
4869/// [`BlockCache`] and [`build_spliced`].
4870#[derive(Clone, Debug, PartialEq, Eq)]
4871pub struct MediaInfo {
4872    /// The [`VisualMap::rows`] rows this media's placeholder occupies — what a
4873    /// capable frontend replaces with the picture or player.
4874    pub rows_span: Range<usize>,
4875    /// Whether this is a picture, a movie, or a sound — which widget the
4876    /// frontend builds over [`rows_span`](MediaInfo::rows_span). A frontend that
4877    /// handles only some kinds leaves the rest as core's placeholder rows, which
4878    /// already read sensibly on their own.
4879    pub kind: MediaKind,
4880    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
4881    /// the AST. A frontend resolves a relative path against the document's own
4882    /// directory; core does no I/O. For a `<picture>` this is the `<img>`
4883    /// fallback — the source used when no [`sources`](MediaInfo::sources) media
4884    /// query matches (or the frontend has no theme). Empty when a `<video>`/
4885    /// `<audio>` carries no `src` and names its candidates in `<source>`s
4886    /// instead; [`resolve`](MediaInfo::resolve) already accounts for that.
4887    pub destination: String,
4888    /// The `<source>` alternatives in document order, or empty for a plain
4889    /// image. See [`MediaSource`]; a theme- or codec-aware frontend picks one and
4890    /// otherwise loads [`destination`](MediaInfo::destination).
4891    pub sources: Vec<MediaSource>,
4892    /// The media's alt text, flattened from its inline children (empty when it
4893    /// has none).
4894    pub alt: String,
4895    /// A `<video poster="…">`'s still frame, or empty when there is none — an
4896    /// image destination, resolved exactly as [`destination`] is.
4897    ///
4898    /// [`destination`]: MediaInfo::destination
4899    pub poster: String,
4900}
4901
4902/// One leaf directive (`::name{…}`) as a frontend sees it: which rows its
4903/// placeholder occupies, its type, and its attributes. A plain surface paints
4904/// the `⧉ name` placeholder glyphs as-is; a frontend that knows the host app's
4905/// vocabulary **skips the rows in `rows_span`** and paints the real thing there,
4906/// exactly as an image-capable one does with [`MediaInfo`]. Derived from
4907/// [`VRow::leaf_directive`] by [`directive_spans`].
4908///
4909/// Core resolves nothing here — it has no idea what an `embed` or a `toc` is,
4910/// and deliberately so: the directive vocabulary belongs to the app on top.
4911#[derive(Clone, Debug, PartialEq, Eq)]
4912pub struct DirectiveInfo {
4913    /// The [`VisualMap::rows`] rows this directive's placeholder occupies — the
4914    /// label row plus any blank fillers under it.
4915    pub rows_span: Range<usize>,
4916    /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
4917    pub name: String,
4918    /// Its `{…}` attributes in source order; a bare one has a `None` value.
4919    pub attrs: Vec<(String, Option<String>)>,
4920    /// Its `[label]` text, flattened from its inline children (empty when it has
4921    /// none) — what the placeholder row shows.
4922    pub label: String,
4923}
4924
4925impl DirectiveInfo {
4926    /// The value of attribute `key`, if it has one with a value. The convenience
4927    /// a frontend reaches for first (`info.attr("src")`), since almost every
4928    /// directive that draws as something real is pointed at by one attribute.
4929    pub fn attr(&self, key: &str) -> Option<&str> {
4930        self.attrs
4931            .iter()
4932            .find(|(k, _)| k == key)
4933            .and_then(|(_, v)| v.as_deref())
4934    }
4935}
4936
4937impl MediaInfo {
4938    /// The image URL to load under `scheme`: the first [`sources`] `<source>`
4939    /// whose media query matches, else the [`destination`] `<img>` fallback. The
4940    /// pick is a `<source>`'s first `srcset` URL or the destination — a frontend
4941    /// resolves whichever it gets against the document directory exactly as it
4942    /// resolves `destination`, and reserves/keys the picture under `destination`
4943    /// regardless, so a theme switch just re-picks without disturbing the layout.
4944    ///
4945    /// Only `prefers-color-scheme` is understood (that's what a light/dark banner
4946    /// uses); a `<source>` with any other media query is skipped, and one with no
4947    /// media at all always matches (an unconditional override). With no matching
4948    /// source — including every frontend that can't/doesn't theme and passes
4949    /// [`ColorScheme::Light`] to a dark-only picture — it's the plain `<img>`.
4950    ///
4951    /// [`sources`]: MediaInfo::sources
4952    /// [`destination`]: MediaInfo::destination
4953    pub fn resolve(&self, scheme: ColorScheme) -> &str {
4954        if let Some(url) = self
4955            .sources
4956            .iter()
4957            .find(|s| media_matches(&s.media, scheme))
4958            .and_then(|s| first_srcset_url(&s.srcset))
4959        {
4960            return url;
4961        }
4962        // A `<video>`/`<audio>` may carry no `src` of its own, naming its
4963        // candidates only in child `<source>`s — none of which matched above,
4964        // because a codec-typed `<source>` has no media query and core judges no
4965        // MIME types. Falling through to an empty destination would hand the
4966        // frontend nothing to load, so take the first candidate URL instead and
4967        // let the frontend reject it if it can't decode it. An `<img>` never
4968        // reaches this: its `src` is the picture.
4969        if self.destination.is_empty()
4970            && let Some(url) = self
4971                .sources
4972                .iter()
4973                .find_map(|s| first_srcset_url(&s.srcset))
4974        {
4975            return url;
4976        }
4977        &self.destination
4978    }
4979
4980    /// The **still picture** that stands for this media under `scheme`, for a
4981    /// frontend that can rasterize an image but not play a movie — a terminal, or
4982    /// a GUI still growing its player. `None` when there is no picture to draw,
4983    /// which is the honest answer for audio and for a poster-less video: the
4984    /// caller leaves core's labelled placeholder row, which already reads as
4985    /// *a thing that isn't text*.
4986    ///
4987    /// This exists so those frontends never hand a `.mp4` to an image decoder.
4988    /// That fails harmlessly today (a failed decode falls back to the same
4989    /// placeholder), but it spends a file read and a decode attempt per frame to
4990    /// arrive where this gets in one match.
4991    pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
4992        match self.kind {
4993            MediaKind::Image => Some(self.resolve(scheme)),
4994            // A `poster` is an image destination, so it resolves the same way —
4995            // but it is named directly and has no `<source>` alternatives of its
4996            // own, so it needs no theme matching.
4997            MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
4998            MediaKind::Video | MediaKind::Audio => None,
4999        }
5000    }
5001}
5002
5003/// A frontend's active color scheme — what a `<picture>`'s `prefers-color-scheme`
5004/// `<source>`s are matched against by [`MediaInfo::resolve`]. A frontend with no
5005/// notion of theme passes [`Light`](ColorScheme::Light), the web's own default.
5006#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5007pub enum ColorScheme {
5008    Light,
5009    Dark,
5010}
5011
5012/// Whether a `<source media="…">` query applies under `scheme`. Empty media is
5013/// an unconditional `<source>` (always matches); otherwise only a
5014/// `prefers-color-scheme: dark|light` feature is understood — anything else
5015/// (a width query, `print`, …) doesn't match, so resolution falls through to the
5016/// next source or the `<img>`. Deliberately lax about the surrounding syntax
5017/// (`(prefers-color-scheme: dark)`, `screen and (prefers-color-scheme:dark)`):
5018/// it keys off the feature and its value, which is all the theme case needs.
5019fn media_matches(media: &str, scheme: ColorScheme) -> bool {
5020    let media = media.trim();
5021    if media.is_empty() {
5022        return true;
5023    }
5024    let lower = media.to_ascii_lowercase();
5025    let Some(after) = lower
5026        .split_once("prefers-color-scheme")
5027        .map(|(_, rest)| rest)
5028    else {
5029        return false;
5030    };
5031    // Skip the `:` and any spaces to reach the value word.
5032    let value = after.trim_start_matches([':', ' ', '\t']);
5033    let wanted = match scheme {
5034        ColorScheme::Light => "light",
5035        ColorScheme::Dark => "dark",
5036    };
5037    value.starts_with(wanted)
5038}
5039
5040/// The first URL in a `srcset`: its first comma-separated candidate, before any
5041/// `1x`/`2x`/width descriptor. The theme case only ever puts one URL per
5042/// `<source>`, so the first candidate is the picture.
5043fn first_srcset_url(srcset: &str) -> Option<&str> {
5044    let first = srcset.split(',').next()?.trim();
5045    first.split_whitespace().next().filter(|u| !u.is_empty())
5046}
5047
5048/// The narrowest a column may be squeezed. Below a few characters a column
5049/// stops carrying text and just shreds it one letter per line, which is worse
5050/// than letting the grid run wide.
5051const MIN_COL_WIDTH: usize = 3;
5052
5053/// Shrink `widths` until the grid fits `avail` screen columns, taking from the
5054/// widest column each time so the loss is shared out rather than falling on
5055/// whichever column happens to be last. No column goes below
5056/// [`MIN_COL_WIDTH`]; a table with more columns than the surface has room for
5057/// still overflows, which is the honest outcome — there's nothing left to give.
5058fn fit_widths(widths: &mut [usize], avail: usize) {
5059    // Chrome: each column is its content plus a gutter either side, and every
5060    // column is closed by a `│` — with one more opening the row.
5061    let budget = avail.saturating_sub(3 * widths.len() + 1);
5062    while widths.iter().sum::<usize>() > budget {
5063        let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
5064            return;
5065        };
5066        *w -= 1;
5067    }
5068}
5069
5070/// Word-wrap `glyphs` into lines of at most `width` columns, hard-breaking any
5071/// single word too long to fit.
5072///
5073/// Unlike a paragraph — where an overlong word just trails off the end of the
5074/// line — a table column is a hard boundary: a glyph past it lands on top of
5075/// the border, or on the next cell. So the width here is a promise, and a word
5076/// that won't keep it is broken.
5077///
5078/// The space at a break is dropped rather than hung past the edge. Its offset
5079/// isn't lost: the caller gives every line an end stop just past its last
5080/// glyph, which is exactly where that space was.
5081///
5082/// `width` is in display columns, and a break only ever falls between grapheme
5083/// clusters. Both matter to more than the picture: the caller anchors each
5084/// line's end stop just past its last glyph, so a line cut mid-cluster would
5085/// put a caret stop inside a character — reachable by Down or a click, and the
5086/// next Backspace would take the cluster apart from the middle.
5087///
5088/// An explicit in-cell break (a `\n` glyph, from a `<br>`) is a hard boundary:
5089/// each run between the breaks wraps on its own and the results stack. The break
5090/// glyphs are dropped — the caller's per-line end stop already sits exactly where
5091/// each break was, so no offset is lost.
5092fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
5093    if glyphs.iter().any(|g| g.ch == '\n') {
5094        return glyphs
5095            .split(|g| g.ch == '\n')
5096            .flat_map(|seg| wrap_segment(seg, width))
5097            .collect();
5098    }
5099    wrap_segment(glyphs, width)
5100}
5101
5102/// [`wrap_glyphs`] for a run with no explicit breaks — the word-wrap proper.
5103fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
5104    let width = width.max(1);
5105    // Words are maximal non-space runs, each carrying the space that followed it
5106    // — which survives only if the next word joins it on this line.
5107    let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
5108    let mut word: Vec<Glyph> = Vec::new();
5109    for g in glyphs {
5110        if g.ch == ' ' {
5111            words.push((std::mem::take(&mut word), Some(g.clone())));
5112        } else {
5113            word.push(g.clone());
5114        }
5115    }
5116    if !word.is_empty() {
5117        words.push((word, None));
5118    }
5119
5120    let mut lines: Vec<Vec<Glyph>> = Vec::new();
5121    let mut line: Vec<Glyph> = Vec::new();
5122    let mut used = 0usize;
5123    let mut gap: Option<Glyph> = None;
5124    for (word, space) in words {
5125        for chunk in hard_break(&word, width) {
5126            let sep = gap.is_some() as usize;
5127            let cells = glyphs_width(chunk);
5128            if !line.is_empty() && used + sep + cells > width {
5129                lines.push(std::mem::take(&mut line));
5130                used = 0;
5131                gap = None; // the break swallows the space
5132            }
5133            if let Some(sp) = gap.take() {
5134                line.push(sp);
5135                used += 1;
5136            }
5137            line.extend_from_slice(chunk);
5138            used += cells;
5139        }
5140        gap = space;
5141    }
5142    // An empty cell is still one (empty) line — it has an end the caret can
5143    // sit at, which is how you type into it.
5144    if !line.is_empty() || lines.is_empty() {
5145        lines.push(line);
5146    }
5147    lines
5148}
5149
5150/// Break a single word into pieces of at most `width` columns, cutting only
5151/// between grapheme clusters — the replacement for slicing it into fixed runs
5152/// of glyphs, which measures a wide character as one column and can cut an
5153/// emoji in half.
5154///
5155/// A cluster wider than the whole column still gets a piece to itself: there is
5156/// nowhere legal to cut it, and overflowing by a cell is better than splitting a
5157/// character. An empty word yields no pieces at all, which is what keeps a
5158/// double space from opening a line of its own.
5159fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
5160    let mut out = Vec::new();
5161    if word.is_empty() {
5162        return out;
5163    }
5164    let (mut start, mut used) = (0usize, 0usize);
5165    for c in clusters(word) {
5166        if used > 0 && used + c.cells > width {
5167            out.push(&word[start..c.glyph]);
5168            start = c.glyph;
5169            used = 0;
5170        }
5171        used += c.cells;
5172    }
5173    out.push(&word[start..]);
5174    out
5175}
5176
5177/// A table rule spanning `widths`, e.g. `┌──────┬─────┐`. Each column is its
5178/// content width plus the one-space gutter on either side.
5179fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
5180    let mut s = String::new();
5181    s.push(left);
5182    for (i, w) in widths.iter().enumerate() {
5183        if i > 0 {
5184            s.push(mid);
5185        }
5186        for _ in 0..w + 2 {
5187            s.push('─');
5188        }
5189    }
5190    s.push(right);
5191    s
5192}
5193
5194/// Push real document text: each glyph maps to its own source byte, and the one
5195/// that opens a grapheme cluster is the caret stop for the whole cluster.
5196///
5197/// Per cluster rather than per codepoint because a cluster is the character the
5198/// user sees, and it's the unit backspace and delete already step by. A stop
5199/// inside 👨‍👩‍👧 — five codepoints strung together with joiners — is a caret
5200/// parked in the middle of a character: one press of Right lands there, and the
5201/// next Backspace severs a joiner from what it joined, leaving a dangling ZWJ in
5202/// the source. The rest of the cluster still gets its glyph (it has to be
5203/// drawn); it just isn't somewhere to stand.
5204fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
5205    for (gi, cluster) in text.grapheme_indices(true) {
5206        for (ci, ch) in cluster.char_indices() {
5207            out.push(Glyph {
5208                ch,
5209                style,
5210                src: base_src + gi + ci,
5211                stop: ci == 0,
5212            });
5213        }
5214    }
5215}
5216
5217/// [`push_text`] for one line of a highlighted code block: the same glyphs at
5218/// the same offsets, each additionally carrying the [`Token`] of the span it
5219/// falls in — `spans` being the line's entry from [`code_tokens`], ascending
5220/// byte ranges *into `text`*. A byte between spans keeps `style` as it is.
5221///
5222/// Offsets are what matters here: a token changes how a glyph is painted and
5223/// nothing about where it is or which source byte it stands on, so a caret
5224/// walks a highlighted block exactly as it walks an unhighlighted one.
5225fn push_code_text(
5226    out: &mut Vec<Glyph>,
5227    text: &str,
5228    base_src: usize,
5229    style: Style,
5230    spans: &[(Range<usize>, Token)],
5231) {
5232    let mut spans = spans.iter().peekable();
5233    for (gi, cluster) in text.grapheme_indices(true) {
5234        // Spans are ascending, so the one covering this cluster's first byte
5235        // is at or after the one that covered the last; step past those ended.
5236        while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
5237            spans.next();
5238        }
5239        let token = spans
5240            .peek()
5241            .filter(|(r, _)| r.contains(&gi))
5242            .map(|(_, t)| *t);
5243        // A cluster is classed whole, by its first byte: a grammar that split
5244        // an emoji's scalars between two tokens would otherwise split the
5245        // glyph, and no grammar means to.
5246        let style = style.token(token);
5247        for (ci, ch) in cluster.char_indices() {
5248            out.push(Glyph {
5249                ch,
5250                style,
5251                src: base_src + gi + ci,
5252                stop: ci == 0,
5253            });
5254        }
5255    }
5256}
5257
5258/// One line's highlighting — `crate::syntax::LineTokens`, spelled here so the
5259/// shape exists whether or not the feature that fills it does.
5260type LineTokens = Vec<(Range<usize>, Token)>;
5261
5262/// The syntax highlighting for a code block's lines, or `None` when the fence's
5263/// language is not one the grammars know. Without the `syntax` feature nothing
5264/// is known, and every code glyph draws in the plain code colour.
5265#[cfg(feature = "syntax")]
5266fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
5267    crate::syntax::highlight(lang, lines)
5268}
5269
5270#[cfg(not(feature = "syntax"))]
5271fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
5272    None
5273}
5274
5275/// Emit an inline `str`/`smart_punctuation` run, mapping every visible char back
5276/// to its *true* source byte even when the source carries backslash escapes the
5277/// parsed `text` dropped (`\*` → `*`). The naive `span.start + text_offset`
5278/// mapping [`push_text`] uses drifts by one byte after each escape, so a caret or
5279/// click past an escaped `*` would land on the wrong character; walking the text
5280/// against its source keeps them aligned, and the hidden escape backslash gets no
5281/// glyph of its own (it is a spelling artefact, not something the caret lands on).
5282fn push_escaped_text(
5283    out: &mut Vec<Glyph>,
5284    text: &str,
5285    span: Range<usize>,
5286    source: &str,
5287    style: Style,
5288) {
5289    let end = span.end.min(source.len());
5290    let src = source.get(span.start..end).unwrap_or("");
5291    // Fast path — no dropped bytes, so text and source align 1:1 (the common
5292    // case: prose with no escapes). Byte lengths equal ⇒ no backslash was eaten.
5293    if src.len() == text.len() {
5294        push_text(out, text, span.start, style);
5295        return;
5296    }
5297    // Slow path: some `\` was consumed. Walk char-by-char, skipping a backslash
5298    // in the source exactly when it escapes the next visible char (a real escape),
5299    // never when it is a literal backslash the parse kept (that case has equal
5300    // lengths and takes the fast path above).
5301    let sb = src.as_bytes();
5302    let mut si = 0usize;
5303    'text: for (_, cluster) in text.grapheme_indices(true) {
5304        for (ci, ch) in cluster.char_indices() {
5305            // The text outlasted the source it is being mapped onto. In a
5306            // consistent document that cannot happen on this path: the slow path
5307            // is only entered when the two lengths differ, and everything that
5308            // makes them differ makes the *source* the longer one — an escape
5309            // backslash the parse ate, or source folded into a neighbouring node.
5310            // A `smart_punctuation` node reports its canonical ASCII spelling
5311            // (`--`, `...`, `"`), which is never longer than what was written.
5312            //
5313            // So reaching here means `span` was measured against a document that
5314            // `source` is no longer, and there is no honest offset left to give
5315            // the remaining characters. Stop: the row comes out short, which is
5316            // a wrong picture of a document that is already inconsistent. The
5317            // alternative was `si` stepping past the end and the slice below
5318            // panicking — which is what it did, in a paint loop.
5319            if si >= sb.len() {
5320                break 'text;
5321            }
5322            // Advance to the source character this one came from, stepping over
5323            // whatever the parse dropped on the way. An escape backslash is the
5324            // common case, but not the only one: a span can cover source that
5325            // was folded into a neighbouring node (smart punctuation next to a
5326            // bracket gives `text: "]"` over a source span of `"…]"`). Advancing
5327            // by the *text* character's length assumed escapes were the only
5328            // divergence, so one dropped multi-byte character desynchronized
5329            // every glyph after it — placing `]` inside the `…` before it.
5330            while si < sb.len() && !src[si..].starts_with(ch) {
5331                si += src[si..].chars().next().map_or(1, char::len_utf8);
5332            }
5333            out.push(Glyph {
5334                ch,
5335                style,
5336                src: span.start + si.min(src.len()),
5337                stop: ci == 0,
5338            });
5339            si += src[si..]
5340                .chars()
5341                .next()
5342                .map_or(ch.len_utf8(), char::len_utf8);
5343        }
5344    }
5345}
5346
5347/// Build synthetic decoration glyphs (a bullet, a gutter) all pointing at `src`,
5348/// each carrying `role` so the frontend can style it (`Role::Body` for plain
5349/// padding). Synthetic glyphs are never caret stops — they share one offset, so
5350/// the caret steps over them (a click still lands at `src`).
5351fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
5352    let style = Style::default().role(role);
5353    text.chars()
5354        .map(|ch| Glyph {
5355            ch,
5356            style,
5357            src,
5358            stop: false,
5359        })
5360        .collect()
5361}
5362
5363fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
5364    let mut v = a.to_vec();
5365    v.extend_from_slice(b);
5366    v
5367}
5368
5369/// The columns a row's prefix (a bullet, a quote gutter, an indent) takes up
5370/// before the text it introduces — what the wrap budget has left to spend.
5371fn prefix_width(prefix: &[Glyph]) -> usize {
5372    glyphs_width(prefix)
5373}
5374
5375/// The label shown for an image with no alt text: the final path segment of its
5376/// destination (`img/cat.png` → `cat.png`), the whole destination when it has no
5377/// separator, and `"image"` when it's empty. A `data:` URI (which has no useful
5378/// tail) shows its scheme so the placeholder isn't a wall of base64.
5379fn media_label(dest: &str) -> String {
5380    if dest.is_empty() {
5381        return "image".to_string();
5382    }
5383    if dest.starts_with("data:") {
5384        return "data:…".to_string();
5385    }
5386    // Trim a query/fragment so a URL's `?v=2#frag` doesn't ride along.
5387    let clean = dest.split(['?', '#']).next().unwrap_or(dest);
5388    let tail = clean
5389        .trim_end_matches('/')
5390        .rsplit(['/', '\\'])
5391        .next()
5392        .unwrap_or(clean);
5393    if tail.is_empty() {
5394        dest.to_string()
5395    } else {
5396        tail.to_string()
5397    }
5398}
5399
5400/// A directive's attributes read as a human label — what a frontend puts on a
5401/// container's tinted panel, and what an attribute-bearing inline directive
5402/// shows in its chip.
5403///
5404/// Reads BOTH conventions diaryx content actually uses: twig's own dot-prefixed
5405/// classes (`{.public .family}`, arriving as one combined `class` attr) and bare
5406/// pandoc-style words with no leading dot (`{public family}` — what
5407/// `diaryx_core::visibility`'s publish-time filter and apps/web's directive
5408/// serializer both write, and which twig parses as one valueless attribute
5409/// each). Reading only `.class` would leave every *existing* diaryx `:::vis{…}`
5410/// block unlabeled. A `key=value` attr is configuration rather than a name, so
5411/// it contributes nothing. `None` when nothing readable is left.
5412fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
5413    let mut parts: Vec<String> = Vec::new();
5414    for (k, v) in attrs {
5415        if k == "class" {
5416            if let Some(v) = v
5417                && !v.is_empty()
5418            {
5419                parts.push(v.clone());
5420            }
5421        } else if v.as_deref().unwrap_or("").is_empty() {
5422            parts.push(k.clone());
5423        }
5424    }
5425    (!parts.is_empty()).then(|| parts.join(" "))
5426}
5427
5428fn heading_style(level: u32) -> Style {
5429    // Just the role — a frontend decides how a heading of this level *looks*
5430    // (the terminal cycles a color and bolds it, the GUI scales the font). The
5431    // author wrote no emphasis here, so core records none. `level as u8` is safe:
5432    // Markdown/Djot cap headings at 6.
5433    Style::default().role(Role::Heading(level.min(255) as u8))
5434}
5435
5436/// Is this `container` node a *directive* (`:::note{…}`, `::embed{…}`,
5437/// `:vis[…]`) rather than an HTML element (`<video>`, `<picture>`, `<div>`)?
5438///
5439/// twig 2.8 folded `div`/`span`/`directive`/`element` into one `container` kind,
5440/// and left nothing that separated them: `kind`, `name` and `directive_form` all
5441/// agree, field for field, on an HTML `<div>` and a Markdown `:::div`. Leaf
5442/// answered it by sniffing the span for whichever of `:` or `<` came first.
5443/// twig 3.0 records the answer at parse time as [`ContainerOrigin`], so this is
5444/// now the parser's own knowledge rather than a guess rebuilt from the bytes it
5445/// consumed.
5446pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
5447    node.origin == Some(ContainerOrigin::Directive)
5448}
5449
5450/// The tag a `container` node carries when it is an HTML element rather than a
5451/// directive — `Some("video")` for a promoted `<video>`, `None` for a `:::note`
5452/// or for any node that is not a container at all.
5453pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
5454    (node.origin == Some(ContainerOrigin::Element))
5455        .then_some(node.name.as_deref())
5456        .flatten()
5457}
5458
5459/// A leaf directive's name and whatever attributes are not part of spelling
5460/// it — the two things a [`DirectiveMark`] carries, which twig hands back
5461/// differently per format and which a frontend must not be able to tell apart.
5462///
5463/// Markdown's `::page-break` is a `Leaf`-form directive *named* `page-break`
5464/// with no attributes, and this returns it verbatim. Djot has no leaf form:
5465/// `insert_directive` writes the same document as an empty `::: page-break`
5466/// fence, whose container is anonymous (a djot div carries no name) and whose
5467/// name arrives as the fence's one class. So where the node has no name of its
5468/// own the first `class` token *is* the name, and whatever else the class said
5469/// — an author's `::: page-break {.wide}` — stays an attribute.
5470fn leaf_directive_identity(node: &FlatNode) -> (String, Vec<(String, Option<String>)>) {
5471    let named = node.name.clone().unwrap_or_default();
5472    if !named.is_empty() {
5473        return (named, node.attrs.clone());
5474    }
5475    let class = node
5476        .attrs
5477        .iter()
5478        .find(|(k, _)| k == "class")
5479        .and_then(|(_, v)| v.as_deref())
5480        .unwrap_or_default();
5481    let mut tokens = class.split_whitespace();
5482    let Some(name) = tokens.next().map(str::to_string) else {
5483        return (named, node.attrs.clone());
5484    };
5485    let rest = tokens.collect::<Vec<_>>().join(" ");
5486    let attrs = node
5487        .attrs
5488        .iter()
5489        .filter_map(|(k, v)| {
5490            if k != "class" {
5491                return Some((k.clone(), v.clone()));
5492            }
5493            (!rest.is_empty()).then(|| (k.clone(), Some(rest.clone())))
5494        })
5495        .collect();
5496    (name, attrs)
5497}
5498
5499/// Is this inline `container` an **attributed span** — the node leaf's run-level
5500/// vocabulary rides — rather than a named directive?
5501///
5502/// The four formats spell one span four ways and twig hands the name back for
5503/// two of them: HTML's and Markdown's `<span …>` arrive named `span` with
5504/// `Element` origin, while djot's `[text]{…}` and AsciiDoc's `[.a]#text#`
5505/// arrive anonymous (an empty name) with `Directive` origin. All four are the
5506/// same node to `wrap_range_attrs`, which is what writes them, so they are the
5507/// same node here.
5508///
5509/// A *named* directive is not one, whatever its name: a Markdown `:span[…]{…}`
5510/// is a directive the parser read as a directive, twig's own
5511/// `wrap_range_attrs` says so, and it keeps the handling it has.
5512///
5513/// **Anonymous is not enough**, and the form is what finishes the question:
5514/// a djot fenced div (`{.center}` / `:::` / … / `:::`) is anonymous too, with
5515/// the same `Directive` origin, and is a *block* — `Container` form against the
5516/// span's `Text`. Reading one as a span made every gesture and every query lie
5517/// about it: `set_text_color` over a word inside such a div copied the whole
5518/// div's attribute set — its `id` along with the rest — onto the new span, and
5519/// `alignment_at_caret` reported the div's `.center` as a *run's* answer while
5520/// the walker drew none. So the anonymous arm asks the form [`is_inline`] asks.
5521pub(crate) fn is_run_span(node: &FlatNode) -> bool {
5522    if node.kind != Kind::Container {
5523        return false;
5524    }
5525    match node.name.as_deref() {
5526        None | Some("") => node.directive_form == Some(DirectiveForm::Text),
5527        Some("span") => node.origin == Some(ContainerOrigin::Element),
5528        Some(_) => false,
5529    }
5530}
5531
5532/// `base` with an attributed span's three run-level keys written over it — the
5533/// nearest-wins fold [`is_run_span`] describes, for one span. `faces` is the
5534/// build's intern table, as it is for [`Presentation::under`].
5535fn run_style(node: &FlatNode, base: Style, faces: &RefCell<FaceTable>) -> Style {
5536    Style {
5537        size: FontSize::from_attrs(&node.attrs).or(base.size),
5538        font: faces
5539            .borrow_mut()
5540            .face_from_attrs(&node.attrs)
5541            .or(base.font),
5542        color: TextColor::from_attrs(&node.attrs).or(base.color),
5543        ..base
5544    }
5545}
5546
5547pub(crate) fn is_inline(node: &FlatNode) -> bool {
5548    // A directive is inline only in its `text` form (`:name[label]{…}`); the
5549    // `leaf` and `container` forms are blocks. All three report the same `kind`,
5550    // so the form is the only thing telling them apart — and getting it wrong
5551    // costs a whole paragraph: a text directive misread as a block makes its
5552    // paragraph fail the "all children inline" test in `block`, and the line is
5553    // then walked as a container of blocks, rendering as empty rows with no
5554    // caret home at all.
5555    //
5556    // An HTML element shares the `container` kind, and twig sets the same form
5557    // on the two tags the lightweight formats have a generic spelling for: a
5558    // `<span>` is `Text` and a `<div>` is `Container`, while a `<video>` or a
5559    // `<picture>` has no form at all. So the form answers for an element as it
5560    // answers for a directive, and the origin is not consulted — which is what
5561    // makes a `<span …>` inside a paragraph an inline node.
5562    //
5563    // It has to. `wrap_range_attrs` spells an attributed run as exactly that
5564    // span in Markdown and HTML, and a paragraph holding one whose kids were
5565    // not all inline failed the test below and was walked as a container of
5566    // blocks: the text either side of the span rendered as nothing at all.
5567    if node.kind == Kind::Container {
5568        return node.directive_form == Some(DirectiveForm::Text);
5569    }
5570    is_inline_kind(&node.kind)
5571}
5572
5573/// [`is_inline`] by kind alone — for the ancestor walks, whose `QueryMatch`es
5574/// carry no `directive_form`. It answers `false` for every directive, which its
5575/// callers must (and do) reconcile: they pair it with `is_block_container`,
5576/// which claims every directive, so the pair's verdict is the same one a form
5577/// would have given. Anything looking at a *directive itself* wants [`is_inline`]
5578/// and a real node.
5579pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
5580    matches!(
5581        kind,
5582        Kind::Str
5583            | Kind::SoftBreak
5584            | Kind::HardBreak
5585            | Kind::NonBreakingSpace
5586            | Kind::Emph
5587            | Kind::Strong
5588            | Kind::Mark
5589            | Kind::Insert
5590            | Kind::Delete
5591            | Kind::Verbatim
5592            | Kind::InlineMath
5593            | Kind::DisplayMath
5594            | Kind::Url
5595            | Kind::Email
5596            | Kind::Link
5597            | Kind::Image
5598            | Kind::SmartPunctuation
5599            | Kind::Superscript
5600            | Kind::Subscript
5601            | Kind::FootnoteReference
5602    )
5603}
5604
5605/// Assert two maps are identical down to every glyph, stop, and table span — the
5606/// contract `build_cached` and `build_spliced` must hold against `build`. Lives
5607/// at module scope (not in `mod tests`) so the Doc-driven differential test in
5608/// `doc.rs` can reach it and the private `stops` field it compares.
5609#[cfg(test)]
5610pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
5611    assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
5612    for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
5613        assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
5614        assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
5615        // The incremental walk labels a boundary from a query match's kind
5616        // string and the whole-arena walk from a `FlatNode`'s; this is what says
5617        // the two doors reach the same answer.
5618        assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
5619        assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
5620        assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
5621        assert_eq!(ra.align, rb.align, "row {i} align ({ctx})");
5622        assert_eq!(
5623            ra.line_height, rb.line_height,
5624            "row {i} line_height ({ctx})"
5625        );
5626        assert_eq!(
5627            ra.glyphs.len(),
5628            rb.glyphs.len(),
5629            "row {i} glyph count ({ctx})"
5630        );
5631        for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
5632            assert_eq!(
5633                (ga.ch, ga.src, ga.stop, ga.style),
5634                (gb.ch, gb.src, gb.stop, gb.style),
5635                "row {i} glyph {j} ({ctx})"
5636            );
5637        }
5638    }
5639    assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
5640    assert_eq!(a.stops, b.stops, "stops ({ctx})");
5641    assert_eq!(a.mark_ends, b.mark_ends, "mark_ends ({ctx})");
5642    assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
5643    for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
5644        assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
5645        assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
5646    }
5647    assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
5648    assert_eq!(a.media, b.media, "images ({ctx})");
5649}
5650
5651#[cfg(test)]
5652mod tests {
5653    use super::*;
5654    use crate::style::{FontFamily, LineSpacing, SizeStep};
5655    use twig::{Editor, Format, NodeId};
5656
5657    fn map(src: &str) -> VisualMap {
5658        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5659        build_t(&ed.nodes().unwrap(), src, Some(80))
5660    }
5661
5662    /// [`map`] over a Djot source. Djot is the format that spells superscript
5663    /// and subscript at all — Markdown has no syntax for either.
5664    fn map_djot(src: &str) -> VisualMap {
5665        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5666        build_t(&ed.nodes().unwrap(), src, Some(80))
5667    }
5668
5669    /// The baseline every glyph spelling `ch` was built with, in row order —
5670    /// how a test reads a raised or lowered run off the map without caring
5671    /// which row it landed on.
5672    fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
5673        m.rows
5674            .iter()
5675            .flat_map(|r| r.glyphs.iter())
5676            .filter(|g| g.ch == ch)
5677            .map(|g| g.style.baseline)
5678            .collect()
5679    }
5680
5681    /// [`map`] at a chosen wrap width.
5682    fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
5683        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5684        build_t(&ed.nodes().unwrap(), src, wrap)
5685    }
5686
5687    /// [`map`], but with twig's `directives` extension on (off by twig's own
5688    /// default) — the `:::name{.class}` fenced-div containers leaf-core's
5689    /// `"directive"` wysiwyg arm renders.
5690    fn map_directives(src: &str) -> VisualMap {
5691        let mut ed = Editor::new_ext(
5692            src.as_bytes(),
5693            Format::Markdown,
5694            twig::MarkdownExtensions {
5695                directives: true,
5696                ..Default::default()
5697            },
5698        )
5699        .unwrap();
5700        build_t(&ed.nodes().unwrap(), src, Some(80))
5701    }
5702
5703    /// [`map`] in `format`, parsed the way every leaf document is — the
5704    /// extensions [`crate::doc::parse_extensions`] turns on, which is what
5705    /// pairs a Markdown `<div …>` with its `</div>` into a container and makes
5706    /// `::page-break` a directive rather than a paragraph of colons.
5707    fn map_leaf(src: &str, format: Format) -> VisualMap {
5708        let mut ed =
5709            Editor::new_ext(src.as_bytes(), format, crate::doc::parse_extensions()).unwrap();
5710        build_t(&ed.nodes().unwrap(), src, Some(80))
5711    }
5712
5713    /// The alignment and line spacing of every row that draws text, in order —
5714    /// how a test reads a block property off the map.
5715    fn line_facts(m: &VisualMap) -> Vec<(Option<Align>, Option<LineHeight>)> {
5716        m.rows
5717            .iter()
5718            .filter(|r| r.glyphs.iter().any(|g| !g.ch.is_whitespace()))
5719            .map(|r| (r.align, r.line_height))
5720            .collect()
5721    }
5722
5723    /// The style of the glyph spelling `ch`, first occurrence — how a test reads
5724    /// a run property off the map.
5725    fn style_of(m: &VisualMap, ch: char) -> Style {
5726        m.rows
5727            .iter()
5728            .flat_map(|r| r.glyphs.iter())
5729            .find(|g| g.ch == ch)
5730            .unwrap_or_else(|| panic!("no glyph {ch:?} in the map"))
5731            .style
5732    }
5733
5734    /// [`map`] with soft breaks preserved (`LineFlow::Preserve`).
5735    fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
5736        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5737        build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
5738    }
5739
5740    /// The cache-free reference [`build`], with no per-image height overrides —
5741    /// every block image stays its default one-row placeholder. The tests that
5742    /// need a taller image drive it through [`crate::Doc::set_media_rows`] instead.
5743    fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
5744        build(nodes, src, wrap, false, &HashMap::new(), None)
5745    }
5746
5747    /// An arena and a string that disagree — spans reaching past the source they
5748    /// are built against.
5749    ///
5750    /// [`crate::Doc`] keeps the two in step, so this is a "cannot happen" that
5751    /// nonetheless *did*: `examples/bench` timed a loop of `edit_range` and then
5752    /// went on handing the grown editor's spans to a builder holding the string
5753    /// from before it, and every run ended in a slice panic rather than a
5754    /// number. `push_escaped_text` was already written to survive the mismatch —
5755    /// it clamps the span's end and falls back to an empty slice — and this is
5756    /// the half of that intent it did not carry through.
5757    ///
5758    /// Rendering the wrong thing is the acceptable answer here; panicking in a
5759    /// paint loop is not.
5760    #[test]
5761    fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
5762        // An escape puts the run on `push_escaped_text`'s slow path — the fast
5763        // path is a length comparison that a truncated source fails anyway.
5764        let src = "alpha \\*beta\\* gamma delta epsilon\n";
5765        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5766        let nodes = ed.nodes().unwrap();
5767
5768        // Every truncation of it, so the cut lands before, inside and after the
5769        // escaped run rather than only where one hand-picked index put it.
5770        for cut in 0..=src.len() {
5771            if !src.is_char_boundary(cut) {
5772                continue;
5773            }
5774            let map = build_t(&nodes, &src[..cut], Some(80));
5775            for row in &map.rows {
5776                for g in &row.glyphs {
5777                    assert!(
5778                        g.src <= src.len(),
5779                        "cut {cut}: glyph {:?} points past the source at {}",
5780                        g.ch,
5781                        g.src
5782                    );
5783                }
5784            }
5785        }
5786    }
5787
5788    fn rendered(m: &VisualMap) -> String {
5789        m.rows
5790            .iter()
5791            .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
5792            .collect::<Vec<_>>()
5793            .join("\n")
5794    }
5795
5796    /// Render a source both ways: `build` over the whole marshalled arena (the
5797    /// reference), and `build_cached` driven the way [`crate::Doc`] drives it —
5798    /// top-level blocks from `child_spans`, per-block subtrees on a miss.
5799    fn render_both(
5800        ed: &mut Editor,
5801        src: &str,
5802        wrap: Option<usize>,
5803        cache: &mut BlockCache,
5804    ) -> (VisualMap, VisualMap) {
5805        let all = ed.nodes().unwrap();
5806        let media_rows = HashMap::new();
5807        let plain = build(&all, src, wrap, false, &media_rows, None);
5808        let top = top_blocks(ed);
5809        let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
5810            ed.subtree(NodeId(id)).unwrap_or_default()
5811        });
5812        (plain, cached)
5813    }
5814
5815    /// The whole correctness claim of the block cache: `build_cached` produces a
5816    /// byte-identical map to `build`, on a fresh cache *and* — the case that
5817    /// actually exercises reuse-and-shift plus per-block subtree marshalling — on
5818    /// a warm cache after the source has been edited underneath it.
5819    /// **Every glyph must stand on the character it claims.** A row's source
5820    /// extent is computed from its last glyph's offset, so a glyph carrying an
5821    /// offset that is not its own character's start yields a row end inside a
5822    /// multi-byte character — and every later slice of the source panics on it.
5823    ///
5824    /// Reproduces a real crash from a journal entry: a bracketed elision inside
5825    /// a blockquote (`[…]`) gave the closing bracket a `text` of `"]"` over a
5826    /// source span covering `"…]"`, because the parse folded the ellipsis into a
5827    /// neighbouring node. `push_escaped_text` walked that span assuming a
5828    /// dropped backslash was the only way text and source could diverge, so the
5829    /// `]` landed on the `…`'s first byte:
5830    /// `byte index 1236 is not a char boundary; it is inside '…'`.
5831    #[test]
5832    fn a_glyph_never_lands_inside_the_character_before_it() {
5833        let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
5834        let vmap = map(src);
5835        for (r, row) in vmap.rows.iter().enumerate() {
5836            assert!(
5837                src.is_char_boundary(row.end_src.min(src.len())),
5838                "row {r} ends at {} — inside a character",
5839                row.end_src
5840            );
5841            for g in &row.glyphs {
5842                assert!(
5843                    src.is_char_boundary(g.src.min(src.len())),
5844                    "row {r} has {:?} at {}, which is inside a character",
5845                    g.ch,
5846                    g.src
5847                );
5848            }
5849        }
5850        // The elision survives, and its bracket sits on the real `]`.
5851        let text: String = vmap
5852            .rows
5853            .iter()
5854            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5855            .collect();
5856        assert!(text.contains("[…]"), "the elision should render: {text:?}");
5857        let close = vmap
5858            .rows
5859            .iter()
5860            .flat_map(|r| r.glyphs.iter())
5861            .find(|g| g.ch == ']')
5862            .expect("a closing bracket");
5863        assert_eq!(
5864            src[close.src..].chars().next(),
5865            Some(']'),
5866            "the bracket glyph should stand on the source's own `]`"
5867        );
5868    }
5869
5870    #[test]
5871    fn build_cached_matches_build() {
5872        let docs = [
5873            "# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
5874            "## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
5875            "para one\n\n```\ncode\nlines\n```\n\nafter code\n",
5876            "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
5877            "line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
5878            "> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
5879            "intro\n\n![a cat](img/cat.png)\n\nbetween\n\n![](https://x.dev/logo.svg)\n\nend\n",
5880            "- text item\n- ![alt](pic.png)\n- more text\n",
5881            // Footnotes: twig parses each definition as a root beside `doc`, so
5882            // these are the docs where the reference build and the incremental
5883            // one could disagree about what the top-level blocks even are.
5884            "A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
5885            "note[^a]\n\n[^a]: body **bold**\n    wrapped on\n    three lines\n\nafter\n",
5886            // No trailing newline. twig closes the document's last block on the
5887            // virtual newline it supplies at EOF, so that block's `span.end` is
5888            // `source.len() + 1` — a range that slices no bytes at all. Keying
5889            // the block cache off such a slice made every last block hash alike;
5890            // see [`block_bytes`].
5891            "# Title\n\nThe quick brown fox.\n\nA tail with no newline",
5892            "A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
5893            // Comments draw nothing. The per-block builder the cached path
5894            // renders one with starts at offset 0 and, drawing nothing, never
5895            // moved — so the walk went on from 0 and spelled every line of the
5896            // document as a blank row. One at the start, one between blocks,
5897            // one at the end, so each position is covered.
5898            "<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
5899            // A Markdown `<div>` ends with a hidden `</div>` line the walk
5900            // steps over — between blocks and closing the file, so both the
5901            // separator after it and the trailing count are covered.
5902            "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
5903            "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n",
5904            // Link reference definitions: roots beside `doc` like footnotes,
5905            // but drawing nothing. Alone between blocks, glued under a
5906            // paragraph, and closing the file under a comment — the README
5907            // shape.
5908            "see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
5909        ];
5910        for wrap in [None, Some(80usize), Some(20)] {
5911            for src in docs {
5912                let ctx = format!("wrap={wrap:?} src={src:?}");
5913                let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5914                let mut cache = BlockCache::default();
5915
5916                // 1) Fresh cache equals the cache-free build.
5917                let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
5918                assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
5919
5920                // 2) Type a char mid-document, reparse, rebuild with the now-warm
5921                //    cache: the edited block is re-marshalled and re-rendered,
5922                //    every block below it is reused shifted, and the result must
5923                //    still match a from-scratch build.
5924                let at = (src.len() / 2..=src.len())
5925                    .find(|&i| src.is_char_boundary(i))
5926                    .unwrap();
5927                ed.edit_range(at, at, "Z").unwrap();
5928                let src2 = ed.source_str().unwrap();
5929                let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
5930                assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
5931
5932                // 3) Delete it again: offsets shift back the other way, and the
5933                //    warm cache must not hand back stale shifted rows.
5934                ed.edit_range(at, at + 1, "").unwrap();
5935                let src3 = ed.source_str().unwrap();
5936                let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
5937                assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
5938            }
5939        }
5940    }
5941
5942    /// A document that does not end in a newline is the one place twig hands
5943    /// leaf a top-level span that addresses no source: the last block is closed
5944    /// on the virtual newline the parser supplies at EOF, so its `span.end` is
5945    /// `source.len() + 1`. The block cache keys on the bytes under that span, and
5946    /// reading the out-of-range slice as *no bytes* broke it two ways at once —
5947    /// [`block_bytes`] has the full account. Both ways are checked here, because
5948    /// they fail independently.
5949    #[test]
5950    fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
5951        // One: two overrunning blocks collide. A footnote definition is a root
5952        // beside `doc` that [`top_blocks`] merges into the top level, while the
5953        // `section` above it spans the definition's bytes too — so when the
5954        // definition ends the file, both blocks end past it. The second was
5955        // served the first's rows, and the definition rendered as a copy of the
5956        // heading.
5957        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.";
5958        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5959        let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
5960        assert_maps_eq(&plain, &cached, "a definition ending the file");
5961        let text = rendered(&cached);
5962        assert!(
5963            text.ends_with("[note] A note with a word for a label."),
5964            "the last definition should render itself: {text:?}"
5965        );
5966        assert_eq!(
5967            text.matches("A heading with a reference").count(),
5968            1,
5969            "the heading should render exactly once: {text:?}"
5970        );
5971
5972        // Two: one overrunning block goes stale. Its bytes are its cache key, so
5973        // a block that keeps hashing the same however it is edited is served the
5974        // rows built before the edit — the whole last line frozen as the user
5975        // types in it.
5976        let mut cache = BlockCache::default();
5977        let first = "first para\n\n# A heading\n\nlast para with no newline";
5978        let mut ed = Editor::new_str(first, Format::Djot).unwrap();
5979        let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
5980        assert!(rendered(&warm).ends_with("last para with no newline"));
5981
5982        let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
5983        let mut ed = Editor::new_str(second, Format::Djot).unwrap();
5984        let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
5985        assert_maps_eq(&plain, &cached, "edited last block, warm cache");
5986        let text = rendered(&cached);
5987        assert!(
5988            text.ends_with("DIFFERENT text without a newline"),
5989            "the warm cache served the pre-edit rows: {text:?}"
5990        );
5991    }
5992
5993    #[test]
5994    fn resolves_markup_to_plain_text() {
5995        let text = rendered(&map("# Title\n\na **bold** word\n"));
5996        assert!(!text.contains('#'), "heading marker shown: {text:?}");
5997        assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
5998        assert!(text.contains("Title") && text.contains("bold word"));
5999    }
6000
6001    #[test]
6002    fn every_glyph_points_at_its_source_byte() {
6003        let src = "a **bold** c\n";
6004        let m = map(src);
6005        for row in &m.rows {
6006            for g in &row.glyphs {
6007                // A real (non-synthetic) glyph's source byte is the glyph's char.
6008                if g.src < src.len()
6009                    && src.is_char_boundary(g.src)
6010                    && let Some(sc) = src[g.src..].chars().next()
6011                    && sc == g.ch
6012                {
6013                    continue;
6014                }
6015                // Synthetic prefixes (none here) would be the only exceptions.
6016                panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
6017            }
6018        }
6019    }
6020
6021    #[test]
6022    fn offset_and_position_round_trip_on_visible_text() {
6023        let m = map("hello world\n");
6024        let (r, c) = m.pos_of_offset(6); // the 'w'
6025        assert_eq!(m.offset_of_pos(r, c), 6);
6026    }
6027
6028    #[test]
6029    fn visible_utf16_indices_count_the_text_the_system_sees() {
6030        // Hidden delimiters, a two-unit emoji, and a block gap — every way the
6031        // visible text's UTF-16 length parts company with a source byte count.
6032        let src = "a **b\u{1F600}** c\n\nd\n";
6033        let m = map(src);
6034        let end = m.snap_to_stop(src.len());
6035        let text = m.visible_text(0, end);
6036        assert_eq!(text, "a b\u{1F600} c\nd");
6037
6038        // Forward: the index of each offset is where that character sits in
6039        // the visible string, in UTF-16 units.
6040        for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
6041            let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
6042            assert_eq!(
6043                m.visible_utf16_len(0, *src_off),
6044                expect,
6045                "utf16 index of source offset {src_off}"
6046            );
6047            // And back: the index resolves to the offset it came from.
6048            assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
6049        }
6050        // Inside the emoji's surrogate pair resolves to the emoji.
6051        let emoji_src = src.find('\u{1F600}').unwrap();
6052        let emoji_idx = m.visible_utf16_len(0, emoji_src);
6053        assert_eq!(
6054            m.offset_at_visible_utf16(end, emoji_idx + 1),
6055            Some(emoji_src)
6056        );
6057        // At or past the end is nobody's character.
6058        let total = m.visible_utf16_len(0, end);
6059        assert_eq!(total, text.encode_utf16().count());
6060        assert_eq!(m.offset_at_visible_utf16(end, total), None);
6061    }
6062
6063    #[test]
6064    fn visible_text_spends_exactly_one_character_on_every_stop() {
6065        // A list (whose items' ends no gap row follows), a table (whose cells'
6066        // ends draw a gutter space), and a code block (one row per line):
6067        // every place the text used to part company with the stop count, in
6068        // both directions. `UITextInput`'s tokenizer indexes this text by
6069        // that count, so the two must agree exactly between any two stops.
6070        let src = "- one\n- two\n\n| a | b |\n| - | - |\n| c | d |\n\n```\nx\ny\n```\n\nend\n";
6071        let m = map(src);
6072        let end = m.snap_to_stop(src.len());
6073        // The table's trailing stop draws no glyph, so it is spelled as a line
6074        // end too: to the system the table ends on a blank line, which is
6075        // where the caret past it stands.
6076        assert_eq!(m.visible_text(0, end), "one\ntwo\na\nb\nc\nd\n\nx\ny\nend");
6077        // Between any two stops, one character per hop.
6078        let first = m.snap_to_glyph_stop(0);
6079        let stops: Vec<usize> = std::iter::successors(Some(first), |&o| m.stop_after(o)).collect();
6080        for (i, &a) in stops.iter().enumerate() {
6081            for (j, &b) in stops.iter().enumerate().skip(i) {
6082                assert_eq!(
6083                    m.visible_text(a, b).chars().count(),
6084                    j - i,
6085                    "text between stops {a} and {b}"
6086                );
6087            }
6088        }
6089        // A cell's end is spelled as a line end, not the space it draws, so a
6090        // tap landing past `a`'s last letter has nothing to step over into `b`.
6091        let a_end = src.find("a |").unwrap() + 1;
6092        assert_eq!(m.visible_text(a_end, a_end + 1), "\n");
6093    }
6094
6095    #[test]
6096    fn unwrapped_mode_emits_one_row_per_paragraph() {
6097        // A long paragraph that would wrap under a column budget stays a single
6098        // row when wrap is None (the GUI wraps it at pixel width instead).
6099        let long = "one two three four five six seven eight nine ten eleven twelve\n";
6100        let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
6101        let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
6102        let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
6103        assert!(wrapped.num_rows() > 1, "narrow column should wrap");
6104        assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
6105        // Every glyph's source byte is preserved in the single row.
6106        let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
6107        assert_eq!(text.trim_end(), long.trim_end());
6108    }
6109
6110    fn line_texts(m: &VisualMap) -> Vec<String> {
6111        m.rows
6112            .iter()
6113            .map(|r| {
6114                // Trim the trailing whitespace a row may carry — the zero-width
6115                // '\n' that closes a preserved line, and any space glyph left at
6116                // a wrap boundary (both real caret stops, neither visible text).
6117                r.glyphs
6118                    .iter()
6119                    .map(|g| g.ch)
6120                    .collect::<String>()
6121                    .trim_end()
6122                    .to_string()
6123            })
6124            .collect()
6125    }
6126
6127    #[test]
6128    fn preserve_lays_each_soft_break_on_its_own_row() {
6129        // A soft break (a bare newline inside a paragraph) folds into a space by
6130        // default — the whole paragraph is one reflowed row...
6131        let src = "one two\nthree four\n";
6132        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6133        let folded = build_t(&ed.nodes().unwrap(), src, None);
6134        assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
6135        assert_eq!(
6136            line_texts(&folded),
6137            vec!["one two three four"],
6138            "break folded to a space"
6139        );
6140
6141        // ...and under Preserve it renders where it was written, a row per line.
6142        let kept = map_preserve(src, None);
6143        assert_eq!(
6144            line_texts(&kept),
6145            vec!["one two", "three four"],
6146            "preserve: a row per line"
6147        );
6148    }
6149
6150    #[test]
6151    fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
6152        // The break must leave a caret stop at the newline byte, or the caret
6153        // could not rest at the end of the first line. The '\n' glyph is dropped
6154        // from the row (so nothing stray renders); its offset (7 here) becomes the
6155        // row's end stop instead — the same offset the folded space would carry.
6156        let src = "one two\nthree four\n";
6157        let m = map_preserve(src, None);
6158        assert!(
6159            !m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
6160            "the break glyph is dropped"
6161        );
6162        assert_eq!(
6163            m.rows[0].end_src, 7,
6164            "the first row ends at the newline byte"
6165        );
6166        assert!(m.is_stop(7), "the newline offset is a caret stop");
6167        // Row end offsets stay strictly ascending — no two rows pin one offset.
6168        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
6169        assert!(
6170            offs.windows(2).all(|w| w[0] < w[1]),
6171            "offsets not unique: {offs:?}"
6172        );
6173    }
6174
6175    #[test]
6176    fn preserved_lines_wrap_independently() {
6177        // Each preserved line wraps to the column on its own; the break between
6178        // them is hard, so a word never crosses it — "gamma" and "delta" could
6179        // share a row on width alone but the soft break keeps them apart.
6180        let src = "alpha beta gamma\ndelta epsilon\n";
6181        let m = map_preserve(src, Some(12));
6182        assert_eq!(
6183            line_texts(&m),
6184            vec!["alpha beta", "gamma", "delta", "epsilon"],
6185            "each source line wraps on its own"
6186        );
6187    }
6188
6189    #[test]
6190    fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
6191        // "A", then two blank lines (an empty paragraph opened with Enter), then
6192        // "B": the empty paragraph must be navigable rows, not collapsed onto B.
6193        // Rows: "A", spacer, empty-paragraph, spacer, "B" — each blank row a
6194        // distinct source offset.
6195        let m = map("A\n\n\n\nB\n");
6196        let text: Vec<String> = m
6197            .rows
6198            .iter()
6199            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6200            .collect();
6201        assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
6202        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
6203        // Strictly ascending — no two rows share an offset (else the caret pins).
6204        assert!(
6205            offs.windows(2).all(|w| w[0] < w[1]),
6206            "offsets not unique: {offs:?}"
6207        );
6208    }
6209
6210    #[test]
6211    fn a_tight_block_boundary_still_gets_one_separator() {
6212        // A heading directly above text (no blank line between) keeps the single
6213        // conventional separator row, as before.
6214        let m = map("# H\ntext\n");
6215        let text: Vec<String> = m
6216            .rows
6217            .iter()
6218            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6219            .collect();
6220        assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
6221    }
6222
6223    #[test]
6224    fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
6225        // `a\*b` renders the three visible chars `a * b` — the escape backslash
6226        // is hidden — and every glyph points at its real source byte, so a caret
6227        // past the escape lands right (the `*` at source 2, `b` at source 3, not
6228        // the drifted 1/2 the naive text-offset mapping gave).
6229        let m = map("a\\*b\n");
6230        let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
6231        assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
6232    }
6233
6234    #[test]
6235    fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
6236        // `\# hi` is a paragraph beginning with a literal `#`, not a heading —
6237        // the backslash is hidden, the `#` shown at its true offset.
6238        let m = map("\\# hi\n");
6239        let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
6240        assert_eq!(text, "# hi");
6241        assert_eq!(
6242            m.rows[0].glyphs[0].src, 1,
6243            "the # is at source byte 1, past the \\"
6244        );
6245    }
6246
6247    #[test]
6248    fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
6249        // A list item's own text and the sub-list nested under it are written on
6250        // adjacent source lines, so the rich view butts them together — no
6251        // fabricated blank row. Regression: the synthetic "breathe" separator
6252        // used to open a gap between `• a` and its `  • b`.
6253        assert_eq!(rendered(&map("- a\n  - b\n")), "• a\n  • b");
6254    }
6255
6256    #[test]
6257    fn a_loose_nested_list_keeps_its_real_blank_line() {
6258        // A genuine blank source line (a loose list) still parts the item from
6259        // its sub-list — only the *fabricated* separator is suppressed, never a
6260        // real one the author typed. The gap row wears the item's continuation
6261        // prefix (the two-space indent), so it renders as "  ", not empty.
6262        assert_eq!(rendered(&map("- a\n\n  - b\n")), "• a\n  \n  • b");
6263    }
6264
6265    #[test]
6266    fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
6267        // Leading YAML frontmatter renders nothing — no phantom blank rows for
6268        // its lines, no leading gap — and `content_start` points at the first
6269        // real block so the caret floor can keep out of the hidden metadata.
6270        let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
6271        let src = format!("{fm}# leaf\n\nA line.\n");
6272        let m = map(&src);
6273        let text = rendered(&m);
6274        assert!(
6275            !text.contains("config"),
6276            "frontmatter body leaked: {text:?}"
6277        );
6278        assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
6279        assert_eq!(
6280            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6281            "leaf"
6282        );
6283        assert_eq!(
6284            m.content_start,
6285            fm.len(),
6286            "floor should be the first real block"
6287        );
6288    }
6289
6290    #[test]
6291    fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
6292        // Nothing to render, so the caret floor is the end of the hidden
6293        // frontmatter — not 0, which is *before* the opening `---` and made the
6294        // first keystroke in a fresh metadata-only note land ahead of it. And
6295        // the frontmatter's own newlines are not trailing blank lines: they used
6296        // to open phantom rows at offsets 1..4, inside the metadata.
6297        let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
6298        let m = map(src);
6299        assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
6300        assert!(
6301            m.rows.is_empty(),
6302            "frontmatter must render no rows: {:?}",
6303            rendered(&m)
6304        );
6305        assert!(
6306            m.stops.is_empty(),
6307            "no stop may sit inside the metadata: {:?}",
6308            m.stops
6309        );
6310    }
6311
6312    #[test]
6313    fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
6314        // Two blank lines after the frontmatter are the author's empty paragraph
6315        // and still render, counted from the metadata's end rather than from 0.
6316        let fm = "---\ntitle: n\n---\n";
6317        let m = map(&format!("{fm}\n\n"));
6318        assert_eq!(m.content_start, fm.len());
6319        assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
6320        assert!(
6321            m.rows.iter().all(|r| r.end_src > fm.len()),
6322            "rows must sit past the frontmatter"
6323        );
6324    }
6325
6326    #[test]
6327    fn a_document_without_frontmatter_has_a_zero_floor() {
6328        let m = map("# leaf\n\nbody\n");
6329        assert_eq!(m.content_start, 0);
6330    }
6331
6332    #[test]
6333    fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
6334        // Markdown/Djot drop the trailing space in `hello ` from the `str` node,
6335        // so without help the row would end at `hello` and the caret couldn't be
6336        // drawn past column 5 — typing a space at a line's end wouldn't move it
6337        // on screen until the next visible character reparsed the space into an
6338        // interior node. The builder recovers it from the block's span/content_span
6339        // gap and emits it as a real, caret-stoppable glyph.
6340        let m = map("hello \n");
6341        assert_eq!(
6342            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6343            "hello "
6344        );
6345        assert_eq!(
6346            m.rows[0].end_src, 6,
6347            "the row now ends past the trailing space"
6348        );
6349        // The caret can rest both on and past the space.
6350        assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
6351        assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
6352        // Two trailing spaces, both stops.
6353        let m = map("hello  \n");
6354        assert_eq!(
6355            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6356            "hello  "
6357        );
6358        assert_eq!(m.pos_of_offset(7), (0, 7));
6359    }
6360
6361    #[test]
6362    fn a_headings_trailing_space_is_a_caret_stop_too() {
6363        // The hidden `# ` marker means `# hi ` renders as `hi ` in three columns;
6364        // the caret past the trailing space lands on the third.
6365        let m = map("# hi \n");
6366        assert_eq!(
6367            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
6368            "hi "
6369        );
6370        assert_eq!(m.pos_of_offset(5), (0, 3));
6371    }
6372
6373    #[test]
6374    fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
6375        // A cell's own `span` is the whole row, so the trailing-whitespace
6376        // recovery must not run for cells or it would swallow the `│` delimiters
6377        // and neighbours between the cell text and the row's end. The grid stays
6378        // exactly as before.
6379        let text = rendered(&map(TABLE));
6380        assert!(
6381            text.contains("│ Pear │   3 │"),
6382            "cell padding disturbed:\n{text}"
6383        );
6384    }
6385
6386    #[test]
6387    fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
6388        // A drag into the empty space under a short document used to resolve to
6389        // offset 0 — the wrong direction, and not even a caret stop when the
6390        // document opens on hidden frontmatter (its `content_start` floor is not
6391        // a stop), which crashed the caret invariant. It now lands on the last
6392        // stop: the end of the document, where dragging downward should reach.
6393        let fm = "---\ntitle: n\n---\n";
6394        let m = map(&format!("{fm}# Hi\n\nbody\n"));
6395        let below = m.num_rows() + 5;
6396        let off = m.offset_of_pos(below, 0);
6397        assert!(
6398            m.is_stop(off),
6399            "offset {off} from a below-content click is not a stop"
6400        );
6401        assert_eq!(
6402            off,
6403            m.stops.last().copied().unwrap(),
6404            "should be the document's last stop"
6405        );
6406        assert!(
6407            off > fm.len(),
6408            "must not fall onto the hidden frontmatter floor"
6409        );
6410    }
6411
6412    #[test]
6413    fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
6414        // The invariant the caret motion asserts: whatever cell a click names,
6415        // the offset it resolves to is one the caret can actually rest at.
6416        for src in [
6417            "hello \n",
6418            "# A heading here \n\nbody text goes on \n",
6419            "---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
6420        ] {
6421            let m = map(src);
6422            for row in 0..m.num_rows() + 3 {
6423                for col in 0..30 {
6424                    let off = m.offset_of_pos(row, col);
6425                    assert!(
6426                        m.is_stop(off),
6427                        "row {row} col {col} → {off} is not a stop in {src:?}"
6428                    );
6429                }
6430            }
6431        }
6432    }
6433
6434    /// `| Name | Qty |` with Name left-aligned and Qty right-aligned.
6435    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
6436
6437    #[test]
6438    fn a_table_renders_as_an_aligned_grid() {
6439        let text = rendered(&map(TABLE));
6440        assert_eq!(
6441            text,
6442            "┌──────┬─────┐\n\
6443             │ Name │ Qty │\n\
6444             ├──────┼─────┤\n\
6445             │ Pear │   3 │\n\
6446             │ Fig  │  12 │\n\
6447             └──────┴─────┘",
6448            "got:\n{text}"
6449        );
6450    }
6451
6452    #[test]
6453    fn table_columns_honour_their_alignment() {
6454        // Centre and default(left) come straight from twig's cell.alignment —
6455        // the delimiter row it's spelled in is consumed and has no node.
6456        let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
6457        assert!(text.contains("│ x │  y  │"), "centred column: {text:?}");
6458    }
6459
6460    #[test]
6461    fn table_borders_are_decoration_the_caret_never_lands_on() {
6462        let m = map(TABLE);
6463        // The top and header rules are whole decoration rows.
6464        for r in [0, 2] {
6465            assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
6466            assert!(
6467                !m.rows[r].glyphs.iter().any(|g| g.stop),
6468                "row {r} has a stop"
6469            );
6470        }
6471        // The bottom border is the exception: no glyph of it is a stop, but
6472        // its end is the table's trailing caret home — the one place the caret
6473        // can stand past the last cell.
6474        let bottom = &m.rows[5];
6475        assert!(
6476            !bottom.decoration,
6477            "the bottom border holds the trailing stop"
6478        );
6479        assert!(
6480            !bottom.glyphs.iter().any(|g| g.stop),
6481            "the bottom border's glyphs are not stops"
6482        );
6483        assert!(m.is_stop(bottom.end_src), "the trailing stop is a stop");
6484        assert!(m.table_end_stop(bottom.end_src));
6485        assert_eq!(
6486            bottom.end_src,
6487            TABLE.trim_end_matches('\n').len(),
6488            "the trailing stop is the table's own end, before its newline"
6489        );
6490        assert!(
6491            !m.table_end_stop(TABLE.rfind("12").unwrap() + 2),
6492            "a cell's end is not the trailing stop"
6493        );
6494        // A content row's `│` and padding are decoration; only the cell text
6495        // and each cell's one end-stop are stops.
6496        let header = &m.rows[1];
6497        assert!(!header.decoration);
6498        for g in &header.glyphs {
6499            if g.ch == '│' {
6500                assert!(!g.stop, "a border is not a caret stop");
6501            }
6502        }
6503        let stops: String = header
6504            .glyphs
6505            .iter()
6506            .filter(|g| g.stop)
6507            .map(|g| g.ch)
6508            .collect();
6509        assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
6510    }
6511
6512    #[test]
6513    fn a_cell_maps_to_its_own_source_text() {
6514        let m = map(TABLE);
6515        // "Pear" starts at byte 32 in TABLE; the caret there draws on the 'P'.
6516        let pear = TABLE.find("Pear").unwrap();
6517        let (r, c) = m.pos_of_offset(pear);
6518        assert_eq!(m.rows[r].glyphs[c].ch, 'P');
6519        assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
6520    }
6521
6522    #[test]
6523    fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
6524        // Columns wider than the surface used to run off the right edge, where
6525        // nothing could reach them. They're cut to the budget instead, and the
6526        // text wraps down inside the column — the header rule stays put, and
6527        // an alignment holds on every line of a wrapped cell, not just the first.
6528        let src = "| Ingredient | Notes |\n|---|---:|\n\
6529                   | flour milled coarse | sift it twice |\n| salt | a pinch |\n";
6530        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6531        let m = build_t(&ed.nodes().unwrap(), src, Some(30));
6532        let text = rendered(&m);
6533        assert_eq!(
6534            text,
6535            "┌──────────────┬─────────────┐\n\
6536             │ Ingredient   │       Notes │\n\
6537             ├──────────────┼─────────────┤\n\
6538             │ flour milled │     sift it │\n\
6539             │ coarse       │       twice │\n\
6540             │ salt         │     a pinch │\n\
6541             └──────────────┴─────────────┘",
6542            "got:\n{text}"
6543        );
6544        for (r, row) in m.rows.iter().enumerate() {
6545            assert!(
6546                row.glyphs.len() <= 30,
6547                "row {r} overflows: {}",
6548                row.glyphs.len()
6549            );
6550        }
6551    }
6552
6553    #[test]
6554    fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
6555        // A paragraph lets an overlong word trail off the end of the line; a
6556        // table column can't — a glyph past the border lands on the border.
6557        let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
6558        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6559        let m = build_t(&ed.nodes().unwrap(), src, Some(20));
6560        for (r, row) in m.rows.iter().enumerate() {
6561            assert!(
6562                row.glyphs.len() <= 20,
6563                "row {r} overflows: {}",
6564                row.glyphs.len()
6565            );
6566        }
6567        // Broken across lines, but whole: every letter is still drawn, at its
6568        // own source byte, where the caret can reach it.
6569        let word = "antidisestablishmentarianism";
6570        let at = src.find(word).unwrap();
6571        for (i, ch) in word.char_indices() {
6572            assert!(
6573                m.rows
6574                    .iter()
6575                    .flat_map(|r| r.glyphs.iter())
6576                    .any(|g| g.stop && g.src == at + i && g.ch == ch),
6577                "{ch:?} at {} was lost to the break",
6578                at + i
6579            );
6580        }
6581    }
6582
6583    #[test]
6584    fn a_code_block_maps_each_line_to_its_own_source_text() {
6585        // Every glyph used to point at the block's start, which made the whole
6586        // block one offset — visible, but impossible to put a caret inside.
6587        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
6588        let m = map(src);
6589        for row in &m.rows {
6590            for g in row.glyphs.iter().filter(|g| g.stop) {
6591                assert_eq!(
6592                    src[g.src..].chars().next(),
6593                    Some(g.ch),
6594                    "glyph {:?} at {} isn't the source byte it claims",
6595                    g.ch,
6596                    g.src
6597                );
6598            }
6599        }
6600    }
6601
6602    #[test]
6603    fn an_indented_code_block_maps_past_its_stripped_indent() {
6604        // twig strips the four-space indent, so `text` isn't a source slice and
6605        // the lines have to be re-found. Offsets land on the code, not the indent.
6606        let src = "    indented\n    code\n";
6607        let m = map(src);
6608        let stops: Vec<(char, usize)> = m
6609            .rows
6610            .iter()
6611            .flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
6612            .collect();
6613        assert_eq!(
6614            stops[0],
6615            ('i', 4),
6616            "first line should start past the indent"
6617        );
6618        assert!(
6619            stops.contains(&('c', 17)),
6620            "second line misplaced: {stops:?}"
6621        );
6622    }
6623
6624    #[test]
6625    fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
6626        // The one case that defeats a forward search: the opening fence
6627        // ```` ```rust ```` ends with the same text as the code under it.
6628        let src = "```rust\nrust\n```\n";
6629        let m = map(src);
6630        let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
6631        assert_eq!(first.src, 8, "matched the info string, not the code");
6632    }
6633
6634    #[test]
6635    fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
6636        // The old `▏ ` gutter is gone: a code row is the block prefix (none, at
6637        // the top level) plus the code text, and the whole run is named in
6638        // `code_blocks` so a frontend can box it.
6639        let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
6640        let m = map(src);
6641        assert_eq!(m.code_blocks.len(), 1, "one code block");
6642        let span = m.code_blocks[0].rows_span.clone();
6643        let rows: Vec<String> = m.rows[span.clone()]
6644            .iter()
6645            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6646            .collect();
6647        assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
6648        assert!(!rendered(&m).contains('▏'), "gutter still drawn");
6649        assert!(
6650            m.rows[span].iter().all(|r| r.code),
6651            "every row in the span is flagged code"
6652        );
6653    }
6654
6655    #[test]
6656    fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
6657        // `trim_end_matches('\n')` cut the block's terminator *and* the newline
6658        // that spells a trailing empty line, so the row the Return had just made
6659        // never appeared and the caret on it fell through to the block below.
6660        // Every empty line is a row, wherever in the block it falls.
6661        let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
6662        let m = map(src);
6663        let span = m.code_blocks[0].rows_span.clone();
6664        let rows: Vec<String> = m.rows[span.clone()]
6665            .iter()
6666            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6667            .collect();
6668        assert_eq!(
6669            rows,
6670            vec!["alpha".to_string(), "beta".to_string(), String::new()],
6671            "the empty last line gets a row"
6672        );
6673        assert!(
6674            m.rows[span.clone()].iter().all(|r| r.code),
6675            "the empty row is flagged code like the rest of the block"
6676        );
6677        // And it is the *source's* empty line, not a coarse fallback to the
6678        // block start: the offset the caret resolves to is the one Return made.
6679        let empty = span.end - 1;
6680        assert_eq!(
6681            m.rows[empty].end_src,
6682            src.find("beta\n\n").unwrap() + "beta\n".len(),
6683            "the empty row maps to the line the Return opened"
6684        );
6685
6686        // Nothing is invented where there is no empty line, and a second one is
6687        // a second row.
6688        assert_eq!(
6689            map("```\nalpha\nbeta\n```\n").code_blocks[0]
6690                .rows_span
6691                .len(),
6692            2,
6693            "a block that ends at its last code line keeps two rows"
6694        );
6695        assert_eq!(
6696            map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
6697            3,
6698            "two trailing empty lines are two rows"
6699        );
6700    }
6701
6702    #[test]
6703    fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
6704        // diaryx's `:::vis{.public .family}` visibility block, and any other
6705        // `:::name{.class}` fenced div — core is agnostic of `name`.
6706        let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
6707        let m = map_directives(src);
6708
6709        let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
6710        assert!(!content_rows.is_empty(), "some row is flagged directive");
6711
6712        let after_rows: Vec<usize> = (0..m.rows.len())
6713            .filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
6714            .collect();
6715        assert!(
6716            after_rows.iter().all(|&i| !m.rows[i].directive),
6717            "content outside the fence isn't tinted"
6718        );
6719
6720        let labels: Vec<&str> = content_rows
6721            .iter()
6722            .filter_map(|&i| m.rows[i].directive_label.as_deref())
6723            .collect();
6724        assert_eq!(
6725            labels,
6726            vec!["public family"],
6727            "only the first row carries the label"
6728        );
6729
6730        assert_eq!(
6731            rendered(&m)
6732                .lines()
6733                .filter(|l| !l.is_empty())
6734                .collect::<Vec<_>>(),
6735            vec!["hello", "world", "after"],
6736            "fence markers don't leak into the rendered text"
6737        );
6738    }
6739
6740    #[test]
6741    fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
6742        // diaryx_core::visibility's own `:::vis{public family}` — no leading
6743        // dots — is what apps/web's directive serializer and the native
6744        // publish-time filter both actually write today, distinct from twig's
6745        // `.class` convention. Both must label the same way so every existing
6746        // diaryx `:::vis{...}` block reads, not just newly dot-authored ones.
6747        let src = ":::vis{public family}\nhello\n:::\n";
6748        let m = map_directives(src);
6749        let label = m.rows.iter().find_map(|r| r.directive_label.clone());
6750        assert_eq!(label.as_deref(), Some("public family"));
6751    }
6752
6753    #[test]
6754    fn a_text_directive_keeps_its_paragraph_visible() {
6755        // Regression: an inline `:name[label]{…}` used to make its paragraph
6756        // fail the "all children inline" test, so the whole line was walked as
6757        // a container of blocks and rendered as empty rows with NO caret stops —
6758        // the text vanished from the editor and the caret couldn't enter it.
6759        // diaryx's inline `:vis[…]` is exactly this shape.
6760        let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
6761        let m = map_directives(src);
6762        assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
6763        // Every character of the line is a caret home, markup excluded — the
6764        // label reads as ordinary text, the way a link's does.
6765        let stops: usize = m
6766            .rows
6767            .iter()
6768            .map(|r| r.glyphs.iter().filter(|g| g.stop).count())
6769            .sum();
6770        assert_eq!(stops, "Text with HTML inline.".chars().count());
6771        // It is inline, so it is not the container form's tinted panel.
6772        assert!(m.rows.iter().all(|r| !r.directive));
6773    }
6774
6775    #[test]
6776    fn a_text_directives_label_maps_to_its_true_source_bytes() {
6777        // Regression (needs twig-doc >= 2.5.0): twig parses a `[label]` as a
6778        // detached slice, and until it rebased the enclosing scan's segments
6779        // onto it every node inside the label reported a span of `(0,0)`. Read
6780        // by anything that trusts a span that means "byte 0", so the label's
6781        // glyphs mapped to the START OF THE DOCUMENT — a click on the label put
6782        // the caret at the top of the file, its stops collided with the real
6783        // first line's, and an edit there landed on the wrong bytes entirely.
6784        //
6785        // The sibling test `a_text_directive_keeps_its_paragraph_visible` only
6786        // counts stops, which is exactly why this went unnoticed: the right
6787        // NUMBER of stops at completely wrong offsets.
6788        let src = "x :abbr[HTML]{title=\"y\"} z\n";
6789        let m = map_directives(src);
6790        let stops: Vec<(char, usize)> = m
6791            .rows
6792            .iter()
6793            .flat_map(|r| &r.glyphs)
6794            .filter(|g| g.stop)
6795            .map(|g| (g.ch, g.src))
6796            .collect();
6797        // `HTML` sits at 8..12. The name, brackets and `{…}` are hidden markup
6798        // the caret steps over, so the line's stops run 0, 1, 8..12, then 24.
6799        assert_eq!(
6800            stops,
6801            [
6802                ('x', 0),
6803                (' ', 1),
6804                ('H', 8),
6805                ('T', 9),
6806                ('M', 10),
6807                ('L', 11),
6808                (' ', 24),
6809                ('z', 25)
6810            ]
6811        );
6812    }
6813
6814    #[test]
6815    fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
6816        // The `every_glyph_points_at_its_source_byte` invariant, extended over
6817        // directive labels now that their offsets are real. Nested markup is
6818        // included: its delimiters are hidden, so the visible glyphs must skip
6819        // them and still name their own bytes.
6820        let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
6821        let m = map_directives(src);
6822        for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
6823            let at = src[g.src..].chars().next();
6824            assert_eq!(
6825                at,
6826                Some(g.ch),
6827                "glyph {:?} claims byte {}, which is {at:?}",
6828                g.ch,
6829                g.src
6830            );
6831        }
6832        assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
6833    }
6834
6835    #[test]
6836    fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
6837        let src = "x :abbr[a *b* c] y\n";
6838        let m = map_directives(src);
6839        let b = m
6840            .rows
6841            .iter()
6842            .flat_map(|r| &r.glyphs)
6843            .find(|g| g.ch == 'b')
6844            .expect("the emphasised char");
6845        assert!(b.style.italic, "the label's *b* lost its emphasis");
6846        assert_eq!(b.src, 11, "the label's *b* lost its source byte");
6847    }
6848
6849    #[test]
6850    fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
6851        // Regression: twig matches a colon followed by any letter-led word, so
6852        // ordinary prose is full of "text directives" nobody meant to write.
6853        // With no `[label]` there are no children, and the arm recursed into
6854        // them — rendering *nothing*. The word vanished from the document with
6855        // no caret stop left behind, so it could not even be deleted.
6856        for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
6857            let m = map_directives(src);
6858            assert_eq!(
6859                rendered(&m).trim_end(),
6860                src.trim_end(),
6861                "prose was eaten: {src:?}"
6862            );
6863        }
6864    }
6865
6866    #[test]
6867    fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
6868        let src = "a :word b\n";
6869        let m = map_directives(src);
6870        // Nothing here is markup, so nothing is hidden: each byte maps to
6871        // itself and can be stood on, which is what makes the colon deletable.
6872        let stops: Vec<(char, usize)> = m
6873            .rows
6874            .iter()
6875            .flat_map(|r| &r.glyphs)
6876            .filter(|g| g.stop)
6877            .map(|g| (g.ch, g.src))
6878            .collect();
6879        assert_eq!(
6880            stops,
6881            "a :word b"
6882                .chars()
6883                .enumerate()
6884                .map(|(i, c)| (c, i))
6885                .collect::<Vec<_>>()
6886        );
6887    }
6888
6889    #[test]
6890    fn an_attribute_bearing_text_directive_draws_a_chip() {
6891        // `{…}` is deliberate in a way a bare colon is not — diaryx writes
6892        // `:vis{.family}` inline — so this one reads as an embed, on the same
6893        // `⧉ label` recipe the leaf form's placeholder row uses.
6894        // Both attribute conventions label it: twig's dot-prefixed classes and
6895        // the bare pandoc-style words diaryx also writes.
6896        for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
6897            let m = map_directives(src);
6898            assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
6899        }
6900        // A `key=value` attr is configuration, not a name, so it adds nothing.
6901        let m = map_directives("a :foo{title=\"x\"} b\n");
6902        assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
6903    }
6904
6905    #[test]
6906    fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
6907        let src = "a :vis{.family} b\n";
6908        let m = map_directives(src);
6909        let stops: Vec<usize> = m
6910            .rows
6911            .iter()
6912            .flat_map(|r| &r.glyphs)
6913            .filter(|g| g.stop)
6914            .map(|g| g.src)
6915            .collect();
6916        // The chip contributes exactly one stop, at the directive's start (2),
6917        // so the caret steps over it whole instead of walking hidden markup a
6918        // byte at a time. `{.family}`'s bytes (3..15) are never stood on.
6919        assert_eq!(stops, [0, 1, 2, 15, 16]);
6920    }
6921
6922    #[test]
6923    fn a_paragraph_holding_only_a_chip_is_still_navigable() {
6924        // With no stop of its own the row would be unreachable — the caret
6925        // could never be put on the line to edit or delete the directive.
6926        let m = map_directives(":vis{.family}\n");
6927        assert!(
6928            m.row_is_navigable(0),
6929            "a chip-only paragraph has no caret home"
6930        );
6931        assert_eq!(
6932            m.offset_of_pos(0, 0),
6933            0,
6934            "its caret home isn't the directive's start"
6935        );
6936    }
6937
6938    #[test]
6939    fn a_ratio_or_a_clock_time_is_never_a_directive() {
6940        // twig requires a letter after the colon, so these stay prose — the
6941        // verbatim arm must not be reached for them at all.
6942        let src = "ratio 3:4 and 10:30\n";
6943        assert_eq!(
6944            rendered(&map_directives(src)).trim_end(),
6945            "ratio 3:4 and 10:30"
6946        );
6947    }
6948
6949    #[test]
6950    fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
6951        // `::name{…}` is a standalone block with no body — an embed, a table of
6952        // contents. It used to emit no rows at all: invisible, no caret home,
6953        // vertical motion crossing a void. Now it draws the image recipe's
6954        // placeholder and publishes what the host app needs to paint the real
6955        // thing.
6956        let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
6957        let m = map_directives(src);
6958
6959        let row = m
6960            .rows
6961            .iter()
6962            .position(|r| r.leaf_directive.is_some())
6963            .expect("a placeholder row");
6964        assert_eq!(
6965            m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
6966            "⧉ embed"
6967        );
6968        assert!(
6969            m.rows[row].glyphs.iter().any(|g| g.stop),
6970            "the caret can land on it"
6971        );
6972        assert!(
6973            m.rows[row].directive,
6974            "a frontend frames it like the container form"
6975        );
6976
6977        assert_eq!(m.directives.len(), 1);
6978        let info = &m.directives[0];
6979        assert_eq!(info.name, "embed");
6980        assert_eq!(info.rows_span, row..row + 1);
6981        assert_eq!(info.attr("src"), Some("demo.html"));
6982        assert_eq!(info.attr("height"), Some("400"));
6983        assert_eq!(info.attr("nope"), None);
6984        // The prose around it is untouched.
6985        assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
6986    }
6987
6988    #[test]
6989    fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
6990        // A `[label]` names the placeholder (the way an image's alt does), and a
6991        // quoted directive keeps the quote's gutter — it is a block like any
6992        // other, not a special case that escapes its container.
6993        let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
6994        assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
6995        assert_eq!(m.directives[0].label, "Audience demo");
6996
6997        let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
6998        assert_eq!(rendered(&quoted).trim_end(), "│ ⧉ embed");
6999        assert_eq!(quoted.directives[0].name, "embed");
7000    }
7001
7002    #[test]
7003    fn a_container_directive_is_still_a_panel_not_a_placeholder() {
7004        // The three forms must not bleed into each other: only the leaf form is
7005        // a placeholder, and only the container form tints the blocks it wraps.
7006        let m = map_directives(":::note{.warning}\nBody\n:::\n");
7007        assert!(
7008            m.directives.is_empty(),
7009            "a container publishes no placeholder"
7010        );
7011        assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
7012        assert_eq!(rendered(&m).trim_end(), "Body");
7013        assert!(
7014            m.rows
7015                .iter()
7016                .any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
7017        );
7018    }
7019
7020    /// A production-path build with both extensions on — the only way to put a
7021    /// promoted HTML element and a directive in one document, which is what the
7022    /// `container` kind made necessary to tell apart. Returns the whole `Doc`
7023    /// because [`VisualMap`] is not `Clone`; read `doc.vmap`.
7024    fn doc_built(src: &str) -> crate::Doc {
7025        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7026        doc.build_visual(80);
7027        doc
7028    }
7029
7030    /// Every `container` node in `src`, parsed the way production does (both
7031    /// extensions on), paired with what [`container_is_directive`] makes of it.
7032    fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
7033        let mut ed = Editor::new_ext(
7034            src.as_bytes(),
7035            Format::Markdown,
7036            twig::MarkdownExtensions {
7037                directives: true,
7038                html_elements: true,
7039                ..Default::default()
7040            },
7041        )
7042        .unwrap();
7043        ed.nodes()
7044            .unwrap()
7045            .iter()
7046            .filter(|n| n.kind == Kind::Container)
7047            .map(|n| {
7048                (
7049                    n.name.clone().unwrap_or_default(),
7050                    container_is_directive(n),
7051                    n.directive_form,
7052                )
7053            })
7054            .collect()
7055    }
7056
7057    #[test]
7058    fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
7059        // twig 2.8 folded `div`/`span`/`directive`/`element` into one `container`
7060        // kind. `directive_form` reads as though it separates them and does not:
7061        // a block-level `<div>` reports `Some(DirectiveForm::Container)` exactly
7062        // as a `:::note` does. Trusting it would draw directive chrome — a tinted
7063        // panel, a `.class` audience label — on every pasted Slack/Docs div.
7064        for (src, name, want) in [
7065            (":::note{.a}\nbody\n:::\n", "note", true),
7066            ("::embed{src=x}\n", "embed", true),
7067            ("a :vis[hi]{.b} b\n", "vis", true),
7068            ("<div class=\"x\">\nhi\n</div>\n", "div", false),
7069            ("<video src=\"v.mp4\" controls></video>\n", "video", false),
7070            ("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
7071            ("<figure>\n\nhi\n\n</figure>\n", "figure", false),
7072            // The `:` in an attribute must not read as a directive opener: the
7073            // `<` of the tag comes first, and first one wins.
7074            (
7075                "<video src=\"http://x.test/v.mp4\" controls></video>\n",
7076                "video",
7077                false,
7078            ),
7079            (
7080                "<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
7081                "source",
7082                false,
7083            ),
7084        ] {
7085            let found = containers(src);
7086            let hit = found.iter().find(|(n, ..)| n == name);
7087            let Some((_, is_directive, form)) = hit else {
7088                panic!("no `{name}` container in {src:?} — found {found:?}");
7089            };
7090            assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
7091        }
7092
7093        // And the reason this can't just read the field: for the one collision
7094        // that matters, the field says the same thing for both.
7095        let div = containers("<div class=\"x\">\nhi\n</div>\n");
7096        let note = containers(":::note{.a}\nbody\n:::\n");
7097        assert_eq!(
7098            div[0].2, note[0].2,
7099            "if these ever differ, `directive_form` became usable and this rule can go"
7100        );
7101    }
7102
7103    #[test]
7104    fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
7105        // A container's span opens with its *block prefix*, not its own markup —
7106        // `> ::embed{…}` starts at the `>`. Reading only the first byte to tell a
7107        // directive from an element (both `container` since 2.8) therefore misses
7108        // every nested one, and the placeholder silently renders as nothing.
7109        for (src, ctx) in [
7110            ("> ::embed{src=\"x\"}\n", "quoted"),
7111            ("- ::embed{src=\"x\"}\n", "listed"),
7112            (">> ::embed{src=\"x\"}\n", "twice quoted"),
7113        ] {
7114            let m = map_directives(src);
7115            assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
7116            assert_eq!(m.directives[0].name, "embed", "{ctx}");
7117        }
7118    }
7119
7120    #[test]
7121    fn a_video_is_still_media_and_not_a_directive() {
7122        // The other side of the same coin: `<video>` is a `container` too, and
7123        // must reach `block_media` rather than the directive arms.
7124        let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
7125        assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
7126        assert!(
7127            doc.vmap.rows.iter().all(|r| !r.directive),
7128            "the video drew directive chrome"
7129        );
7130    }
7131
7132    #[test]
7133    fn a_directive_needs_the_extension_flag() {
7134        // `map` (twig's default extensions) leaves `directives` off — the fence
7135        // renders as literal paragraph text, same as any other unrecognized
7136        // punctuation, never corrupting or panicking.
7137        let src = ":::vis{.public}\nhello\n:::\n";
7138        let m = map(src);
7139        assert!(m.rows.iter().all(|r| !r.directive));
7140        assert!(rendered(&m).contains(":::vis{.public}"));
7141    }
7142
7143    #[test]
7144    fn a_footnote_reference_keeps_its_paragraph_visible() {
7145        // Regression: `footnote_reference` was in neither `is_inline_kind` nor
7146        // the inline walker, so a paragraph carrying one failed the "all children
7147        // inline" test, was walked as a container of blocks, and rendered as
7148        // empty rows with no caret stop anywhere — the whole line vanished.
7149        let src = "A claim[^1] and more.\n";
7150        let m = map(src);
7151        assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
7152        // The `^` is spelling, not text: hidden the way a link's `](dest)` is.
7153        assert!(!rendered(&m).contains('^'));
7154    }
7155
7156    #[test]
7157    fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
7158        // What makes `[1]` read as a reference rather than as bracketed text.
7159        // The brackets ride with the label: the chip is one raised mark.
7160        let m = map("A claim[^1] and more.\n");
7161        assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
7162        assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
7163        assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
7164        assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
7165    }
7166
7167    #[test]
7168    fn a_footnote_reference_keeps_the_link_role_it_had() {
7169        // The raised baseline is added to the role, not swapped for it: every
7170        // frontend already paints `Role::Link`, and a reference is one.
7171        let m = map("A claim[^1].\n");
7172        let label = m
7173            .rows
7174            .iter()
7175            .flat_map(|r| &r.glyphs)
7176            .find(|g| g.ch == '1')
7177            .unwrap();
7178        assert_eq!(label.style.role, Role::Link);
7179        assert_eq!(label.style.baseline, Baseline::Super);
7180    }
7181
7182    /// The [`Role`] of the first glyph spelling `ch` — how a test reads one
7183    /// run's styling off a map without caring which row it landed on.
7184    fn role_of(m: &VisualMap, ch: char) -> Role {
7185        m.rows
7186            .iter()
7187            .flat_map(|r| r.glyphs.iter())
7188            .find(|g| g.ch == ch)
7189            .unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
7190            .style
7191            .role
7192    }
7193
7194    #[test]
7195    fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
7196        // twig 3.3's `highlight`/`highlight_colors`, which `parse_extensions`
7197        // turns on for every leaf document: `==text==` is a `mark` in Markdown
7198        // and not the literal `==` it used to be, and `==🔴 text==` is one
7199        // carrying a colour.
7200        //
7201        // `doc_built` rather than `map`, deliberately — the extensions are
7202        // leaf's choice, not twig's default, so a test that parsed bare
7203        // Markdown here would be testing a document leaf never builds.
7204        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
7205        assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
7206        assert_eq!(
7207            role_of(&doc.vmap, 'r'),
7208            Role::Mark(Some(MarkColor::Red)),
7209            "the `data-color` twig stripped the emoji into"
7210        );
7211        assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
7212    }
7213
7214    #[test]
7215    fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
7216        // The colour is *spelling*: twig strips the emoji out of the mark's
7217        // content, so the reader sees the words and the wash, never the circle.
7218        // Drawing it would put a character in the rendered text that the author
7219        // wrote as syntax — the same mistake as drawing an emphasis's `*`.
7220        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
7221        let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
7222        assert_eq!(drawn, "Plain yes and red ok");
7223    }
7224
7225    #[test]
7226    fn a_superscript_and_a_subscript_sit_off_the_baseline() {
7227        // Regression: both rendered flat, so the toolbar's superscript button
7228        // produced markup that looked exactly like the text around it.
7229        let m = map_djot("H~2~O and x^2^\n");
7230        assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
7231        assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
7232        assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
7233    }
7234
7235    #[test]
7236    fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
7237        // Why this is a `Baseline` and not a `Role`: raising a glyph says where
7238        // it sits, and must not cost it what it already was.
7239        let m = map_djot("# Heading x^2^\n");
7240        let two = m
7241            .rows
7242            .iter()
7243            .flat_map(|r| &r.glyphs)
7244            .find(|g| g.ch == '2')
7245            .unwrap();
7246        assert_eq!(two.style.baseline, Baseline::Super);
7247        assert_eq!(two.style.role, Role::Heading(1), "still heading text");
7248    }
7249
7250    #[test]
7251    fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
7252        let src = "see[^note] here\n";
7253        let m = map(src);
7254        // `[^note]` spans 3..10, its label `note` 5..9. The caret walks the
7255        // label; the brackets are drawn but never stood on, as a table's are,
7256        // and the `[^`/`]` bytes are stepped over like any hidden delimiter.
7257        let stops: Vec<usize> = m
7258            .rows
7259            .iter()
7260            .flat_map(|r| &r.glyphs)
7261            .filter(|g| g.stop)
7262            .map(|g| g.src)
7263            .collect();
7264        for off in 5..9 {
7265            assert!(
7266                stops.contains(&off),
7267                "label byte {off} isn't a caret stop: {stops:?}"
7268            );
7269        }
7270        for off in [3usize, 4, 9] {
7271            assert!(
7272                !stops.contains(&off),
7273                "delimiter byte {off} is a caret stop: {stops:?}"
7274            );
7275        }
7276    }
7277
7278    #[test]
7279    fn a_task_item_draws_its_box_where_the_bullet_would_be() {
7280        // Regression: the `[ ] ` is markup twig consumes — the item's paragraph
7281        // content starts past it — so a task item used to render as `• todo`,
7282        // identical to a plain bullet and with no way to see it was ticked.
7283        let m = map("- [ ] todo\n- [x] done\n- plain\n");
7284        assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
7285
7286        // The tick rides the item's first row, for a GUI that paints its own box.
7287        let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
7288        assert_eq!(ticks, [Some(false), Some(true), None]);
7289    }
7290
7291    #[test]
7292    fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
7293        let m = map_at(
7294            "- [x] a much longer task that has to wrap somewhere\n",
7295            Some(20),
7296        );
7297        assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
7298        assert_eq!(m.rows[0].task, Some(true));
7299        assert!(
7300            m.rows[1..].iter().all(|r| r.task.is_none()),
7301            "only the first row"
7302        );
7303        // The continuation lines hang under the box, not under column zero.
7304        assert!(
7305            rendered(&m)
7306                .lines()
7307                .nth(1)
7308                .is_some_and(|l| l.starts_with("  "))
7309        );
7310    }
7311
7312    #[test]
7313    fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
7314        // `task_checked` finds the box past the list marker; a plain item whose
7315        // text merely contains a bracket has none, and must keep its bullet.
7316        let m = map("- see [1] below\n");
7317        assert_eq!(rendered(&m), "• see [1] below");
7318        assert_eq!(m.rows[0].task, None);
7319    }
7320
7321    #[test]
7322    fn a_footnote_definition_renders_where_it_was_written() {
7323        // Regression: twig parses `[^1]: …` as a root *beside* `doc` — not a
7324        // child of it — so the walk from `doc` never reached one and every byte
7325        // of the note's body rendered as nothing at all.
7326        let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
7327        let m = map(src);
7328        let text = rendered(&m);
7329        assert!(
7330            text.contains("The note body."),
7331            "the note body is invisible: {text:?}"
7332        );
7333        // In source order — between the paragraph that cites it and the one
7334        // after — not hoisted to the end, and marked to match its reference.
7335        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
7336        assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
7337    }
7338
7339    #[test]
7340    fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
7341        let src = "x[^a].\n\n[^a]: body\n";
7342        let m = map(src);
7343        // `body` sits at 14..18. Its glyphs must map there — a marker that ate
7344        // the offsets would put the caret in the wrong place on every click.
7345        let body: Vec<(char, usize)> = m
7346            .rows
7347            .iter()
7348            .flat_map(|r| &r.glyphs)
7349            .filter(|g| g.stop && g.src >= 14)
7350            .map(|g| (g.ch, g.src))
7351            .collect();
7352        assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
7353    }
7354
7355    #[test]
7356    fn an_empty_footnote_definition_still_shows_its_marker() {
7357        // The instant `[^1]: ` has been typed and nothing after it. `blocks`
7358        // renders no child, so without the explicit marker row the definition
7359        // wouldn't appear at all until something was typed into it.
7360        let src = "x[^1]\n\n[^1]:\n";
7361        let m = map(src);
7362        assert!(
7363            rendered(&m).contains("[1] "),
7364            "no marker row: {:?}",
7365            rendered(&m)
7366        );
7367    }
7368
7369    #[test]
7370    fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
7371        let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
7372        let m = map_at(src, Some(24));
7373        let text = rendered(&m);
7374        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
7375        // Continuation lines hang under the marker, as a list item's do — the
7376        // indent is the marker's own width, not a fixed one.
7377        assert_eq!(lines[1].trim_end(), "[src] one two three four");
7378        assert!(
7379            lines[2].starts_with("      "),
7380            "body doesn't hang: {:?}",
7381            lines[2]
7382        );
7383        assert_eq!(lines[2].trim(), "five six seven");
7384    }
7385
7386    #[test]
7387    fn a_code_block_leaves_exactly_one_blank_row_below_it() {
7388        // The closing fence line used to be miscounted as a blank separator,
7389        // opening a phantom second gap under the block. One block boundary is
7390        // one blank row, code block or not.
7391        let src = "para\n\n```\ncode\n```\n\nafter\n";
7392        let m = map(src);
7393        let code_end = m.code_blocks[0].rows_span.end;
7394        let after = m
7395            .rows
7396            .iter()
7397            .position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
7398            .unwrap();
7399        assert_eq!(
7400            after - code_end,
7401            1,
7402            "exactly one row between code and 'after'"
7403        );
7404    }
7405
7406    #[test]
7407    fn a_fenced_block_publishes_its_language_on_its_code_block() {
7408        // The info string becomes the block's label; a bare fence and an indented
7409        // block carry none.
7410        assert_eq!(
7411            map("```rust\nlet x = 1;\n```\n").code_blocks[0]
7412                .lang
7413                .as_deref(),
7414            Some("rust")
7415        );
7416        assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
7417        assert_eq!(map("    indented\n").code_blocks[0].lang, None);
7418    }
7419
7420    /// The token every glyph spelling `ch` carries, in row order — how a test
7421    /// reads a block's highlighting off the map.
7422    fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
7423        m.rows
7424            .iter()
7425            .flat_map(|r| r.glyphs.iter())
7426            .filter(|g| g.ch == ch)
7427            .map(|g| g.style.token)
7428            .collect()
7429    }
7430
7431    #[cfg(feature = "syntax")]
7432    #[test]
7433    fn a_fenced_block_in_a_known_language_carries_tokens() {
7434        // `let` is a keyword, the string literal a string, and the plain
7435        // identifier `x` nothing at all — it draws in the code colour. Every
7436        // glyph is still `Role::Code`: a token is beside the role, not instead.
7437        let m = map("```rust\nlet x = \"s\";\n```\n");
7438        assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
7439        assert_eq!(tokens_of(&m, 'x'), vec![None]);
7440        assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
7441        assert!(
7442            m.rows
7443                .iter()
7444                .filter(|r| r.code)
7445                .flat_map(|r| r.glyphs.iter())
7446                .all(|g| g.style.role == Role::Code),
7447            "a token replaced the code role"
7448        );
7449    }
7450
7451    #[cfg(feature = "syntax")]
7452    #[test]
7453    fn a_token_changes_nothing_about_where_a_glyph_is() {
7454        // The same block with and without a language it can be highlighted in
7455        // lays out identically: same rows, same offsets, same stops. Only the
7456        // token differs, so the caret walks a highlighted block as it walked an
7457        // unhighlighted one.
7458        let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
7459        let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
7460        assert_eq!(hl.rows.len(), plain.rows.len());
7461        for (a, b) in hl.rows.iter().zip(&plain.rows) {
7462            assert_eq!(a.end_src, b.end_src);
7463            assert_eq!(a.glyphs.len(), b.glyphs.len());
7464            for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
7465                assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
7466                assert_eq!(ga.style.token(None), gb.style);
7467            }
7468        }
7469        assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
7470        assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
7471    }
7472
7473    #[test]
7474    fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
7475        // A bare fence, an indented block, a fence in a language no grammar
7476        // covers, and inline code all draw as plain code — and so does a
7477        // `rust` fence when the `syntax` feature is off.
7478        for src in [
7479            "```\nlet x = 1;\n```\n",
7480            "    let x = 1;\n",
7481            "```no-such-language\nlet x = 1;\n```\n",
7482            "a `let x` b\n",
7483        ] {
7484            assert!(
7485                tokens_of(&map(src), 'l').iter().all(Option::is_none),
7486                "{src:?} was highlighted"
7487            );
7488        }
7489        #[cfg(not(feature = "syntax"))]
7490        assert!(
7491            tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
7492                .iter()
7493                .all(Option::is_none)
7494        );
7495    }
7496
7497    #[test]
7498    fn inline_code_is_not_a_code_block() {
7499        // A `code` span inside prose is styled by role, not boxed: it's part of a
7500        // normal paragraph row, so it names no `code_blocks` entry.
7501        let m = map("a `snippet` b\n");
7502        assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
7503        assert!(
7504            m.rows.iter().all(|r| !r.code),
7505            "inline code flagged a code row"
7506        );
7507    }
7508
7509    #[test]
7510    fn caret_steps_over_hidden_delimiters() {
7511        // "a **bold** c": bytes 8,9 are the closing ** — no glyph. Moving right
7512        // from 'd' (src 7) lands on the space before 'c' (src 10), not inside **.
7513        let m = map("a **bold** c\n");
7514        let (r, c) = m.pos_of_offset(7);
7515        assert_eq!(m.offset_of_pos(r, c + 1), 10);
7516    }
7517
7518    // ── the structural view of a table ───────────────────────────────────────
7519
7520    #[test]
7521    fn a_table_is_published_structurally_beside_its_picture() {
7522        let m = map(TABLE);
7523        let t = &m.tables[0];
7524        let cell = |r: usize, c: usize| -> String {
7525            t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
7526        };
7527        assert_eq!(t.grid.len(), 3, "head + two body rows");
7528        assert_eq!(
7529            (cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
7530            ("Name".into(), "Qty".into(), "Pear".into(), "12".into())
7531        );
7532        assert_eq!(
7533            t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
7534            [true, false, false]
7535        );
7536        // The alignment the delimiter row spelled, carried per cell — the only
7537        // place it survives, since the parser consumes that row.
7538        assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
7539        assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
7540    }
7541
7542    #[test]
7543    fn a_block_media_is_published_structurally_beside_its_placeholder() {
7544        let m = map("intro\n\n![a cat](img/cat.png)\n\nend\n");
7545        assert_eq!(m.media.len(), 1, "one block image");
7546        let img = &m.media[0];
7547        assert_eq!(img.destination, "img/cat.png");
7548        assert_eq!(img.alt, "a cat");
7549        // The placeholder row named by `rows_span` carries the label a plain
7550        // surface paints and a capable frontend replaces.
7551        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7552        assert_eq!(
7553            img.rows_span.end - img.rows_span.start,
7554            1,
7555            "one placeholder row"
7556        );
7557        assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
7558        // The row carries the mark `media_spans` derives the side-table from.
7559        assert!(m.rows[img.rows_span.start].media.is_some());
7560    }
7561
7562    #[test]
7563    fn an_image_without_alt_labels_itself_with_its_filename() {
7564        let m = map("![](photos/beach.jpg)\n");
7565        let row = &m.rows[m.media[0].rows_span.start];
7566        assert_eq!(
7567            row.glyphs.iter().map(|g| g.ch).collect::<String>(),
7568            "🖼 beach.jpg"
7569        );
7570        assert_eq!(m.media[0].alt, "");
7571    }
7572
7573    #[test]
7574    fn an_empty_cells_home_is_read_from_either_shape_of_span() {
7575        // A whole-row span: the cell's pipes are the `col`-th and next.
7576        let row = "|  |  |";
7577        assert_eq!(empty_cell_offset(row, 10, 0), 12);
7578        assert_eq!(empty_cell_offset(row, 10, 1), 15);
7579        // A cell's own span, opening pipe to closing pipe exclusive: the same
7580        // homes, each read from its own span.
7581        assert_eq!(empty_cell_offset("|  ", 10, 0), 12);
7582        assert_eq!(empty_cell_offset("|  ", 13, 1), 15);
7583        // Nothing to stand in: just inside the pipe, never past the span.
7584        assert_eq!(empty_cell_offset("|", 10, 0), 11);
7585        assert_eq!(empty_cell_offset("", 10, 1), 10);
7586    }
7587
7588    #[test]
7589    fn a_hidden_marks_content_end_is_a_caret_home_but_not_a_glyph_stop() {
7590        // `a **bold** b`: the `d` is at 7, the content ends at 8, the closing
7591        // `**` draws nothing, and the space after it is at 10. Two homes at one
7592        // spot on screen: 8 (inside the bold) and 10 (past it).
7593        let m = map("a **bold** b\n");
7594        assert!(
7595            !m.stops.contains(&8),
7596            "8 has no glyph, so it is no glyph stop"
7597        );
7598        assert_eq!(m.mark_ends, vec![8]);
7599        assert!(m.is_stop(8), "but the caret may rest there");
7600        assert_eq!(m.snap_to_stop(8), 8, "and is left there when placed there");
7601        // Left/Right take both homes; the character-pairing walk takes one.
7602        assert_eq!(m.caret_stop_after(7), Some(8));
7603        assert_eq!(m.caret_stop_after(8), Some(10));
7604        assert_eq!(m.caret_stop_before(10), Some(8));
7605        assert_eq!(m.caret_stop_before(8), Some(7));
7606        assert_eq!(m.stop_after(7), Some(10));
7607        assert_eq!(m.stop_before(10), Some(7));
7608        // Drawn where the next glyph is: after the `d`, not on it.
7609        assert_eq!(m.pos_of_offset(8), m.pos_of_offset(10));
7610    }
7611
7612    #[test]
7613    fn every_hidden_inline_mark_gives_its_content_end_a_home() {
7614        // One end per mark, whatever it is spelled with; nested marks closing
7615        // together share the outer's end and the inner's alike.
7616        assert_eq!(
7617            map("*em* `code` [link](u) ~~del~~\n").mark_ends,
7618            vec![3, 10, 17, 27]
7619        );
7620        assert_eq!(map("***both***\n").mark_ends, vec![7]);
7621        // A mark that closes at its row's end coincides with the row's own end
7622        // stop — one offset, in both tables.
7623        let m = map("**bold**\n");
7624        assert_eq!(m.mark_ends, vec![6]);
7625        assert!(m.stops.contains(&6));
7626        // Revealed, the delimiter is glyphs of its own and the end is an
7627        // ordinary glyph stop: nothing to add.
7628        let mut ed = Editor::new_str("a **bold** b\n", Format::Markdown).unwrap();
7629        let src = "a **bold** b\n";
7630        let revealed = build(
7631            &ed.nodes().unwrap(),
7632            src,
7633            Some(80),
7634            false,
7635            &HashMap::new(),
7636            Some(0..src.len()),
7637        );
7638        assert!(revealed.mark_ends.is_empty());
7639        assert!(revealed.stops.contains(&8));
7640    }
7641
7642    #[test]
7643    fn a_marks_content_end_is_a_home_inside_a_table_cell() {
7644        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
7645        let m = map(src);
7646        let end = src.find("bold").unwrap() + 4; // 32, before the closing `**`
7647        assert_eq!(m.mark_ends, vec![end]);
7648        assert_eq!(m.snap_to_stop(end), end);
7649        // Drawn after the `d`, in this cell — where the cell's own end stop is.
7650        assert_eq!(m.pos_of_offset(end), m.pos_of_offset(end + 2));
7651    }
7652
7653    #[test]
7654    fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
7655        // `![x](y)` on its own line: the caret can rest in front of the image
7656        // (its start) and just past it (the row end), and nowhere inside the
7657        // markup — the same coarse mapping a thematic break uses.
7658        let src = "![x](y.png)\n";
7659        let m = map(src);
7660        let img = &m.rows[m.media[0].rows_span.start];
7661        let start = 0; // the image opens the document
7662        let end = "![x](y.png)".len();
7663        // Every placeholder glyph maps to the image start and is a stop there.
7664        assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
7665        assert_eq!(img.end_src, end, "the row ends past the image");
7666        assert_eq!(m.stops.first(), Some(&start));
7667        assert!(m.stops.contains(&end), "a stop sits after the image");
7668        // Nothing inside the markup is a stop.
7669        assert!(!m.stops.iter().any(|&s| s > start && s < end));
7670    }
7671
7672    #[test]
7673    fn an_inline_image_amid_text_is_not_a_block_media() {
7674        // An image sharing its line with prose isn't block-level: it stays in the
7675        // inline path (rendered as its alt text), and publishes no MediaInfo.
7676        let m = map("see ![a cat](cat.png) here\n");
7677        assert!(m.media.is_empty(), "not a block image");
7678        assert!(
7679            rendered(&m).contains("a cat"),
7680            "alt text still renders inline"
7681        );
7682    }
7683
7684    /// The block images `Doc` publishes for `src`, driven through the real
7685    /// production build (`build_visual` → `build_cached`) with `html_elements`
7686    /// on — the path a `<picture>` actually travels. Not the raw `build` the
7687    /// other tests use: the editor's flat whole-arena snapshot tangles the links
7688    /// of inline-promoted HTML (phantom roots, dangling `parent`s), which only
7689    /// the per-block subtree walk `build_cached` does untangles.
7690    fn doc_media(src: &str) -> Vec<MediaInfo> {
7691        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7692        doc.build_visual(80);
7693        doc.vmap.media.clone()
7694    }
7695
7696    #[test]
7697    fn a_video_block_is_media_with_its_src_poster_and_kind() {
7698        // The load-bearing assumption of video support: twig has no `video` node
7699        // kind, so `html_elements` promotion must land a `<video>` as a generic
7700        // `element` whose tag name and attributes survive onto `FlatNode` — the
7701        // same treatment `<picture>` gets. If that ever stops holding, this is
7702        // the test that says so.
7703        let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
7704        assert_eq!(m.len(), 1, "the video is one block media");
7705        assert_eq!(m[0].kind, MediaKind::Video);
7706        assert_eq!(m[0].destination, "clip.mp4");
7707        assert_eq!(m[0].poster, "still.png");
7708    }
7709
7710    #[test]
7711    fn a_single_line_video_is_a_block_too() {
7712        // The spelling everyone actually writes. It used to parse as a paragraph
7713        // of raw inline HTML — CommonMark opens a block on a complete tag only
7714        // when the line ends there, and its fixed tag list predates `<video>` —
7715        // so the tags never reached core as an element at all. twig 2.5.1 widened
7716        // that list under `html_elements`; this is the test that would catch the
7717        // pin sliding back.
7718        let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
7719        assert_eq!(m.len(), 1, "single-line <video> is a block");
7720        assert_eq!(m[0].kind, MediaKind::Video);
7721        assert_eq!(m[0].destination, "clip.mp4");
7722    }
7723
7724    #[test]
7725    fn a_single_line_picture_is_a_block_with_its_alternatives() {
7726        // `<picture>` had the identical gap and it went unnoticed because the
7727        // conventional spelling breaks the lines. Same twig fix covers it.
7728        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
7729                   <img src=\"l.svg\" alt=\"banner\"></picture>\n";
7730        let m = doc_media(src);
7731        assert_eq!(m.len(), 1);
7732        assert_eq!(m[0].kind, MediaKind::Image);
7733        assert_eq!(m[0].destination, "l.svg");
7734        assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
7735    }
7736
7737    #[test]
7738    fn an_audio_block_is_media_with_no_poster() {
7739        let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
7740        assert_eq!(m.len(), 1);
7741        assert_eq!(m[0].kind, MediaKind::Audio);
7742        assert_eq!(m[0].destination, "take.mp3");
7743        assert!(m[0].poster.is_empty(), "audio has no poster frame");
7744    }
7745
7746    #[test]
7747    fn a_videos_source_children_are_its_candidates_typed_by_mime() {
7748        // A `<video>` with no `src` of its own — the common shape, since it's how
7749        // you offer more than one codec. The candidates come from `<source src>`
7750        // (not `srcset`, which is `<picture>`'s spelling) and carry their MIME.
7751        let src = "<video controls>\n\
7752                   <source src=\"a.webm\" type=\"video/webm\">\n\
7753                   <source src=\"a.mp4\" type=\"video/mp4\">\n\
7754                   fallback\n\
7755                   </video>\n";
7756        let m = doc_media(src);
7757        assert_eq!(m.len(), 1);
7758        assert!(
7759            m[0].destination.is_empty(),
7760            "no src attribute on the element"
7761        );
7762        assert_eq!(m[0].sources.len(), 2);
7763        assert_eq!(m[0].sources[0].srcset, "a.webm");
7764        assert_eq!(m[0].sources[0].mime, "video/webm");
7765        assert_eq!(m[0].sources[1].srcset, "a.mp4");
7766        // With an empty destination, `resolve` falls through to the first
7767        // candidate rather than handing the frontend nothing to load.
7768        assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
7769    }
7770
7771    #[test]
7772    fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
7773        // The placeholder contract images already hold, now for a video: the row
7774        // renders as a labelled stand-in a plain surface can paint as-is, and
7775        // carries the mark a capable frontend replaces it from.
7776        let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
7777        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7778        doc.build_visual(80);
7779        let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
7780        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7781        assert!(
7782            text.starts_with('🎬'),
7783            "video sigil, not the image one: {text:?}"
7784        );
7785        assert!(row.media.is_some(), "the mark rides the placeholder row");
7786    }
7787
7788    #[test]
7789    fn a_picture_block_carries_its_source_alternatives() {
7790        // A `<picture>` with a dark-mode `<source>`: one block image, whose
7791        // fallback destination is the `<img>` and whose `sources` carry the
7792        // `<source>`'s media + srcset for a theme-aware frontend to pick.
7793        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
7794        let images = doc_media(src);
7795        assert_eq!(images.len(), 1, "the picture is one block image");
7796        let img = &images[0];
7797        assert_eq!(img.destination, "light.svg", "fallback is the <img>");
7798        assert_eq!(img.alt, "banner");
7799        assert_eq!(
7800            img.sources,
7801            vec![MediaSource {
7802                media: "(prefers-color-scheme: dark)".into(),
7803                srcset: "dark.svg".into(),
7804                mime: String::new(),
7805            }],
7806        );
7807    }
7808
7809    #[test]
7810    fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
7811        // fig.md's shape: the banner is an `<h1>` wrapping the `<picture>`.
7812        let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
7813        let images = doc_media(src);
7814        assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
7815        assert_eq!(images[0].destination, "l.svg");
7816        assert_eq!(images[0].sources.len(), 1);
7817        assert_eq!(images[0].sources[0].srcset, "d.svg");
7818    }
7819
7820    #[test]
7821    fn a_plain_image_has_no_media_sources() {
7822        // A bare Markdown image carries an empty `sources` — nothing to pick from.
7823        let images = doc_media("![alt](p.png)\n");
7824        assert_eq!(images.len(), 1);
7825        assert!(
7826            images[0].sources.is_empty(),
7827            "no <picture>, no alternatives"
7828        );
7829    }
7830
7831    #[test]
7832    fn resolve_picks_the_source_matching_the_scheme() {
7833        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
7834        let images = doc_media(src);
7835        let img = &images[0];
7836        // Dark theme takes the dark source; light falls through to the <img>.
7837        assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
7838        assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
7839    }
7840
7841    #[test]
7842    fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
7843        // A plain image ignores the scheme.
7844        let plain = doc_media("![a](p.png)\n");
7845        assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
7846
7847        // A <source> with an unrecognized media query is skipped; a light source
7848        // is taken under a light theme.
7849        let m = doc_media(
7850            "<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
7851        );
7852        assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
7853        assert_eq!(
7854            m[0].resolve(ColorScheme::Dark),
7855            "f.svg",
7856            "no dark source → <img>"
7857        );
7858    }
7859
7860    #[test]
7861    fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
7862        // A comma/descriptor srcset resolves to its first URL.
7863        assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
7864        assert_eq!(first_srcset_url("  solo.svg  "), Some("solo.svg"));
7865        assert_eq!(first_srcset_url(""), None);
7866        // An empty (unconditional) media always matches.
7867        assert!(media_matches("", ColorScheme::Light));
7868        assert!(media_matches(
7869            "(prefers-color-scheme:dark)",
7870            ColorScheme::Dark
7871        ));
7872        assert!(!media_matches(
7873            "(prefers-color-scheme: dark)",
7874            ColorScheme::Light
7875        ));
7876    }
7877
7878    #[test]
7879    fn a_block_media_carries_its_list_prefix() {
7880        // An image that is a list item's body opens past the bullet, like every
7881        // other block does.
7882        let m = map("- ![alt](p.png)\n");
7883        let row = &m.rows[m.media[0].rows_span.start];
7884        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7885        assert!(
7886            text.starts_with("• "),
7887            "the list marker prefixes the image row: {text:?}"
7888        );
7889        assert!(text.contains("🖼 alt"));
7890    }
7891
7892    #[test]
7893    fn the_structural_table_spans_exactly_its_drawn_rows() {
7894        // A frontend drawing its own grid skips `rows_span` and renders from
7895        // `grid`. If the span were short the leftover border rows would be
7896        // painted as text under the real table; if long it would eat a
7897        // neighbouring paragraph. Both are silent, so pin it to the picture.
7898        let m = map(&format!("before\n\n{TABLE}\nafter\n"));
7899        let t = &m.tables[0];
7900        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7901        assert!(
7902            row_text(t.rows_span.start).starts_with('┌'),
7903            "opens on the top border"
7904        );
7905        assert!(
7906            row_text(t.rows_span.end - 1).starts_with('└'),
7907            "closes on the bottom border"
7908        );
7909        assert!(
7910            !row_text(t.rows_span.start - 1).contains('┌'),
7911            "the row before the span is not the table's"
7912        );
7913        assert_eq!(
7914            row_text(t.rows_span.end),
7915            "",
7916            "the span ends before the gap row"
7917        );
7918    }
7919
7920    #[test]
7921    fn a_nested_tables_structure_carries_the_block_prefix() {
7922        // The picture puts the quote's gutter on every row of the grid. A
7923        // frontend drawing its own table has to draw that too and start past it,
7924        // so the prefix has to travel with the structure — without it a quoted
7925        // table renders flush at the margin and leaves the quote it's in.
7926        let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
7927        let t = &m.tables[0];
7928        let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
7929        assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
7930        // And it matches what the picture actually drew.
7931        let drawn: String = m.rows[t.rows_span.start]
7932            .glyphs
7933            .iter()
7934            .map(|g| g.ch)
7935            .collect();
7936        assert!(
7937            drawn.starts_with(&prefix),
7938            "picture and structure disagree: {drawn:?}"
7939        );
7940    }
7941
7942    #[test]
7943    fn a_top_level_table_carries_no_prefix() {
7944        assert!(map(TABLE).tables[0].prefix.is_empty());
7945    }
7946
7947    #[test]
7948    fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
7949        // The picture wraps a cell to its column; a frontend laying the grid out
7950        // in pixels needs the text as the document spells it, before that
7951        // decision. Narrow enough that the drawn cell must break.
7952        let src = "| Name |\n|------|\n| alpha beta gamma |\n";
7953        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7954        let m = build_t(&ed.nodes().unwrap(), src, Some(12));
7955        let drawn = rendered(&m);
7956        let cell: String = m.tables[0].grid[1].cells[0]
7957            .glyphs
7958            .iter()
7959            .map(|g| g.ch)
7960            .collect();
7961        assert_eq!(
7962            cell, "alpha beta gamma",
7963            "structure must not carry the wrap"
7964        );
7965        assert!(
7966            drawn.lines().count() > 5,
7967            "the picture should have wrapped, else this proves nothing:\n{drawn}"
7968        );
7969    }
7970
7971    // ── display columns ──────────────────────────────────────────────────────
7972
7973    #[test]
7974    fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
7975        // A column sized by counting characters is drawn narrower than the text
7976        // it has to hold — `你好` is two characters in four cells — and the cell
7977        // spills over the border it is supposed to sit inside, taking the whole
7978        // grid out of square with it. Squareness is the property: every row of a
7979        // grid is drawn to the same column, whatever its cells are spelled with.
7980        for src in [
7981            "| A | B |\n|---|---|\n| 你好 | y |\n",
7982            "| A | B |\n|---|---|\n| a👨‍👩‍👧b | y |\n",
7983            "| A | 漢字 |\n|---|---|\n| x | y |\n",
7984        ] {
7985            let m = map(src);
7986            let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
7987            assert!(
7988                widths.windows(2).all(|w| w[0] == w[1]),
7989                "ragged grid {widths:?} for {src:?}:\n{}",
7990                rendered(&m)
7991            );
7992        }
7993    }
7994
7995    #[test]
7996    fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
7997        // A column too narrow for its cell hard-breaks the text, and every line
7998        // of it is given an end stop just past its last glyph. Broken into runs
7999        // of four glyphs, the first line of this cell ends between `👨‍👩` and the
8000        // joiner holding `👧` on — so its end stop lands inside a character,
8001        // where a click or Down can reach it and the next Backspace takes the
8002        // cluster apart from the middle.
8003        let src = "| A |\n|---|\n| 👨‍👩‍👧👨‍👩‍👧 |\n";
8004        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
8005        let m = build_t(&ed.nodes().unwrap(), src, Some(8));
8006        let boundaries: Vec<usize> = src
8007            .grapheme_indices(true)
8008            .map(|(i, _)| i)
8009            .chain(std::iter::once(src.len()))
8010            .collect();
8011        for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
8012            assert!(
8013                boundaries.contains(&off),
8014                "stop at {off} is inside a character:\n{}",
8015                rendered(&m)
8016            );
8017        }
8018    }
8019
8020    #[test]
8021    fn a_wrapped_cell_keeps_every_line_inside_its_column() {
8022        // The width is a promise in a table, where a glyph past the column lands
8023        // on the border or in the next cell — and it is a promise about cells,
8024        // which is not what a count of glyphs measures.
8025        let src = "| A |\n|---|\n| 你好世界漢字 |\n";
8026        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
8027        let m = build_t(&ed.nodes().unwrap(), src, Some(14));
8028        for r in &m.rows {
8029            assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
8030        }
8031    }
8032
8033    #[test]
8034    fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
8035        let glyphs = |s: &str| {
8036            let mut out = Vec::new();
8037            push_text(&mut out, s, 0, Style::default());
8038            out
8039        };
8040        let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
8041
8042        // Six cells of CJK broken at four: two characters, then one — never
8043        // between the two cells of `好`.
8044        let w = glyphs("你好世");
8045        let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
8046        assert_eq!(pieces, ["你好", "世"]);
8047
8048        // A character wider than the column has nowhere legal to break, so it
8049        // keeps its cells rather than being cut in half.
8050        let w = glyphs("你好");
8051        let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
8052        assert_eq!(pieces, ["你", "好"]);
8053
8054        // An empty word yields no pieces at all — a double space stays a space.
8055        assert!(hard_break(&[], 4).is_empty());
8056    }
8057
8058    #[test]
8059    fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
8060        // Pressing Enter at the end of a list item opens a new, empty item —
8061        // a childless `list_item`. Without a row of its own the new bullet
8062        // wouldn't appear until something was typed into it (the caret would be
8063        // stranded on an offset no row draws). It now renders as one prefixed
8064        // row whose end is a caret stop, so the bullet shows and the caret lands
8065        // just past the marker.
8066        let m = map("- item\n- \n");
8067        assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
8068        assert_eq!(
8069            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8070            "• ",
8071            "the empty item draws just its bullet",
8072        );
8073        // Its end is the caret home (past the `- ` marker), and it's a real stop.
8074        assert!(
8075            m.is_stop(m.rows[1].end_src),
8076            "the empty item's caret home is not a stop"
8077        );
8078        assert_eq!(
8079            m.pos_of_offset(m.rows[1].end_src),
8080            (1, 2),
8081            "caret sits after '• '"
8082        );
8083    }
8084
8085    #[test]
8086    fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
8087        // The peek bug: a note whose body ends in a link has its last byte
8088        // inside the hidden destination, so mapping `end - 1` through
8089        // `pos_of_offset` snapped *forward* — past its own row, past the drawn
8090        // gap, and onto the next note's row. The popover then drew both notes.
8091        let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
8092        let m = map(src);
8093        let body = src.find("[title]").unwrap();
8094        let end = src.find("\n\n[^3]").unwrap();
8095
8096        let (first, last) = m.row_range_for(body..end);
8097        assert_eq!(
8098            first, last,
8099            "a one-block note is one row, not a span onto the next"
8100        );
8101
8102        // The old arithmetic, kept here as the thing that must stay wrong: it
8103        // is what this method exists instead of.
8104        assert_ne!(
8105            m.pos_of_offset(end - 1).0,
8106            last,
8107            "the forward snap still leaves the note's row — that is the whole point",
8108        );
8109
8110        // A note ending in *visible* text was never broken, and still isn't:
8111        // both readings agree there, which is why the original test missed it.
8112        let plain = src.find("bare text").unwrap();
8113        let plain_end = src.find("\n\n[^2]").unwrap();
8114        let (pf, pl) = m.row_range_for(plain..plain_end);
8115        assert_eq!(pf, pl);
8116        assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
8117    }
8118
8119    #[test]
8120    fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
8121        // The range is a span, not a point: a quote of two paragraphs covers its
8122        // gap row and both of its text rows, so a peek draws the whole thing.
8123        let src = "> one\n>\n> two\n\nafter\n";
8124        let m = map(src);
8125        let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
8126        assert_eq!((first, last), (0, 2));
8127
8128        // And a range with no visible byte at all still covers the row it opened
8129        // on, rather than collapsing to nothing.
8130        let (f, l) = m.row_range_for(0..1);
8131        assert_eq!((f, l), (0, 0));
8132    }
8133
8134    #[test]
8135    fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
8136        // The peer of the empty list item, and the case that made an empty line
8137        // in a quote draw as plain body text: a childless `block_quote` — a bare
8138        // `> `, which is what the toolbar's Quote button leaves on a blank line —
8139        // has no inner block to carry the gutter, so the whole quote used to
8140        // render as *nothing*. It didn't merely lose its bar; the row went away
8141        // and the caret had no home on it.
8142        let m = map("a\n\n> \n\nb\n");
8143        assert_eq!(
8144            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
8145            "│ ",
8146            "the empty quote draws just its gutter",
8147        );
8148        assert!(
8149            m.rows[2]
8150                .glyphs
8151                .iter()
8152                .all(|g| g.style.role == Role::QuoteGutter)
8153        );
8154        assert!(
8155            !m.rows[2].decoration,
8156            "it is a line text can go on, not a drawn gap"
8157        );
8158        assert!(
8159            m.is_stop(m.rows[2].end_src),
8160            "the empty quote's caret home is not a stop"
8161        );
8162        assert_eq!(
8163            m.pos_of_offset(m.rows[2].end_src),
8164            (2, 2),
8165            "caret sits after '│ '"
8166        );
8167
8168        // And a document that is *only* an empty quote still renders a row — it
8169        // used to render none at all, leaving the caret nowhere to stand.
8170        let m = map("> \n");
8171        assert_eq!(m.num_rows(), 1);
8172        assert_eq!(
8173            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
8174            "│ "
8175        );
8176    }
8177
8178    #[test]
8179    fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
8180        // Enter at the end of `> a` writes `> a\n>\n> \n`. Those last two lines
8181        // hold no block — a quote's `content_span` stops at its last child — so
8182        // the children walk never reaches them, and they used to fall through to
8183        // the document-level trailing pass, which knows no prefix: the gutter
8184        // stopped and the writer's new line drew as plain prose. Fixable only
8185        // since twig 3.2.0, where the quote's *span* covers its own marker lines
8186        // (`0..3` before, `0..8` now) and there is finally a node saying they
8187        // are the quote's.
8188        let m = map("> a\n>\n> \n");
8189        assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
8190        for (i, row) in m.rows.iter().enumerate() {
8191            let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
8192            assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
8193            assert!(
8194                !row.decoration,
8195                "row {i} is a line to type on, not a drawn gap"
8196            );
8197            assert!(m.is_stop(row.end_src), "row {i} has no caret home");
8198        }
8199        assert_eq!(
8200            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
8201            "│ a"
8202        );
8203        // Distinct offsets, so ↑/↓ between them moves the caret rather than
8204        // landing twice on the same byte.
8205        assert!(m.rows[0].end_src < m.rows[1].end_src);
8206        assert!(m.rows[1].end_src < m.rows[2].end_src);
8207
8208        // A blank line *after* the quote is not the quote's: it is spelled with
8209        // no marker, so it stays an ordinary boundary and the gutter ends.
8210        let m = map("> a\n\nb\n");
8211        assert_eq!(m.num_rows(), 3);
8212        assert_eq!(
8213            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
8214            "b"
8215        );
8216        assert!(
8217            !m.rows[1]
8218                .glyphs
8219                .iter()
8220                .any(|g| g.style.role == Role::QuoteGutter)
8221        );
8222
8223        // Nesting is the case this could get wrong, and the depth has to come
8224        // from which quote's span the line falls in rather than from the row
8225        // above it. A trailing `>` under `> > a` matches only the OUTER quote,
8226        // so it wears one gutter; spell it `> >` and it wears two.
8227        let m = map("> > a\n>\n");
8228        assert_eq!(
8229            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
8230            "│ │ a"
8231        );
8232        assert_eq!(
8233            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8234            "│ "
8235        );
8236        let m = map("> > a\n> >\n");
8237        assert_eq!(
8238            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8239            "│ │ "
8240        );
8241
8242        // And a marker line BETWEEN two quoted paragraphs is untouched: that is
8243        // the boundary `emit_separators_before` spells, and it stays a drawn gap
8244        // rather than becoming a line to type on.
8245        let m = map("> a\n>\n> b\n");
8246        assert_eq!(m.num_rows(), 3);
8247        assert!(
8248            m.rows[1].decoration,
8249            "the gap between two quoted blocks is still a gap"
8250        );
8251    }
8252
8253    #[test]
8254    fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
8255        let m = map("1. item\n2. \n");
8256        assert_eq!(m.num_rows(), 2);
8257        assert_eq!(
8258            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
8259            "2. "
8260        );
8261        assert!(m.is_stop(m.rows[1].end_src));
8262        assert_eq!(
8263            m.pos_of_offset(m.rows[1].end_src),
8264            (1, 3),
8265            "caret sits after '2. '"
8266        );
8267    }
8268
8269    #[test]
8270    fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
8271        // The toolbar's H1 on a blank line writes `# ` and nothing else. The row
8272        // it renders is empty (the marker is hidden), so its end *is* its only
8273        // caret stop — and it has to be the offset past the `# `, where typing
8274        // continues the heading. Anchored at the block's start instead, the caret
8275        // drew in front of the hashes and the first character typed there landed
8276        // before them (`x# `), which isn't a heading at all.
8277        let m = map("# \n");
8278        assert_eq!(m.num_rows(), 1);
8279        assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
8280        assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
8281        assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
8282    }
8283
8284    #[test]
8285    fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
8286        // The row-level fact a proportional frontend sizes a whole line by. An
8287        // empty heading has no glyph to read a `Role::Heading` off, so a renderer
8288        // scanning glyphs drew `# ` (and its caret) at body height until the
8289        // first character landed.
8290        let m = map("# \n");
8291        assert_eq!(
8292            m.rows[0].heading,
8293            Some(1),
8294            "the empty heading knows its level"
8295        );
8296
8297        // Every row of one that wraps, not just the first — and nothing else.
8298        let m = map_at(
8299            "## a heading long enough to wrap over two rows\n\nbody\n",
8300            Some(20),
8301        );
8302        let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
8303        assert!(
8304            heads.iter().filter(|h| **h == Some(2)).count() >= 2,
8305            "got {heads:?}"
8306        );
8307        assert_eq!(
8308            m.rows.last().and_then(|r| r.heading),
8309            None,
8310            "the paragraph under it is not a heading",
8311        );
8312    }
8313
8314    #[test]
8315    fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
8316        // The row's end is also what the *next* row's separator is measured from,
8317        // so an empty heading that under-reported it shifted every offset below —
8318        // and the blank line under the heading then claimed the same offset as the
8319        // heading's own end. `pos_of_offset` resolves such a tie downstream (a
8320        // soft wrap belongs to the row below), so the caret at the end of the
8321        // heading was drawn two rows lower, on the blank line.
8322        // `text\n\n# \n\n`: the heading's content opens at 8, and the two rows
8323        // under it end at 9 and 10 — the blank line and the document's end.
8324        let m = map("text\n\n# \n\n");
8325        let end = m.rows.last().expect("a trailing blank row").end_src;
8326        assert_eq!(end, 10, "the trailing rows must end at their real offsets");
8327        // The heading's caret home is its own row's, not one shared with a row
8328        // below — the tie that drew the caret two rows down.
8329        assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
8330        assert!(
8331            m.rows[3..].iter().all(|r| r.end_src > 8),
8332            "rows below own later offsets"
8333        );
8334    }
8335
8336    // ── block boundaries ─────────────────────────────────────────────────────
8337
8338    /// Every drawn boundary in `src`, in order, as `(above, below)`.
8339    fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
8340        m.rows
8341            .iter()
8342            .filter_map(|r| r.boundary)
8343            .map(|b| (b.above, b.below))
8344            .collect()
8345    }
8346
8347    #[test]
8348    fn a_boundary_says_which_blocks_it_divides() {
8349        use BlockClass::*;
8350        let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
8351        assert_eq!(
8352            boundaries(&m),
8353            vec![
8354                (Paragraph, Paragraph),
8355                (Paragraph, Heading),
8356                (Heading, Paragraph),
8357                (Paragraph, Quote),
8358                (Quote, Code),
8359                // The blank the document trails off with is a boundary too — it
8360                // closes the last block above the empty paragraph the caret rests
8361                // on. See `emit_trailing_blank_lines`.
8362                (Code, Paragraph),
8363            ],
8364            "each gap names the pair it falls between, in document order"
8365        );
8366    }
8367
8368    // ── hidden blocks ────────────────────────────────────────────────────────
8369
8370    /// The row texts of `m`, one string per row.
8371    fn row_texts(m: &VisualMap) -> Vec<String> {
8372        m.rows
8373            .iter()
8374            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8375            .collect()
8376    }
8377
8378    #[test]
8379    fn a_div_s_closing_tag_is_not_a_blank_row() {
8380        // The `</div>` sits on a line of its own under the div's last child and
8381        // draws nothing. Counting the separator from the child's end read that
8382        // line as a blank line between the div and the block below — a
8383        // navigable empty row the author never opened — and at the end of the
8384        // file, as an empty trailing paragraph.
8385        let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n");
8386        assert_eq!(row_texts(&m), ["above", "", "hello", "", "below"]);
8387        assert!(!m.is_stop(36), "the `</div>` line is not a caret home");
8388        assert_eq!(
8389            m.stop_after(34),
8390            Some(44),
8391            "from `hello` the next stop is `below`"
8392        );
8393
8394        let m = map("above\n\n<div class=\"center\">\n\nhello\n\n</div>\n");
8395        assert_eq!(row_texts(&m), ["above", "", "hello"], "no trailing rows");
8396    }
8397
8398    #[test]
8399    fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
8400        // `<!-- exec -->` is a top-level block that draws no rows. The blocks
8401        // either side of it meet across the one boundary a paragraph and a code
8402        // block always meet across — not that boundary *plus* one blank row per
8403        // line of the comment, which is what counting the separator from the
8404        // paragraph's end used to spell.
8405        let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
8406        assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
8407        assert_eq!(
8408            boundaries(&m),
8409            vec![
8410                (BlockClass::Paragraph, BlockClass::Code),
8411                (BlockClass::Code, BlockClass::Paragraph),
8412            ],
8413            "the boundary names the drawn blocks either side, not the comment"
8414        );
8415        // The gap stands past the comment, so the caret's row lookup never
8416        // resolves inside it.
8417        assert_eq!(
8418            m.rows[1].end_src, 23,
8419            "the gap row ends at the comment's end"
8420        );
8421    }
8422
8423    #[test]
8424    fn a_comment_opening_the_document_draws_no_leading_gap() {
8425        let m = map("<!-- lead -->\n\npara\n");
8426        assert_eq!(row_texts(&m), ["para"]);
8427        assert_eq!(m.content_start, 0, "the comment is still the first block");
8428    }
8429
8430    #[test]
8431    fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
8432        // Its lines are not blank lines the author opened with Enter, so no
8433        // gap-plus-empty-paragraph is fabricated under the last drawn block.
8434        let m = map("para\n\n<!-- trail -->\n");
8435        assert_eq!(row_texts(&m), ["para"]);
8436        // Enter at the end of the document still opens the empty paragraph the
8437        // caret rests on: the newlines *after* the comment count as they would
8438        // after any block.
8439        let m = map("para\n\n<!-- trail -->\n\n");
8440        assert_eq!(row_texts(&m), ["para", "", ""]);
8441    }
8442
8443    #[test]
8444    fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
8445        // The first *drawn* child wears the item's marker; a hidden first child
8446        // would otherwise take it and leave the text without one.
8447        let m = map("- <!-- note -->\n\n  text\n- two\n");
8448        let texts = row_texts(&m);
8449        assert!(
8450            texts.iter().any(|t| t == "• text"),
8451            "the text wears the bullet: {texts:?}"
8452        );
8453        assert!(
8454            !texts.iter().any(|t| t == "• "),
8455            "no empty bullet row for the comment: {texts:?}"
8456        );
8457    }
8458
8459    #[test]
8460    fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
8461        // The bug as seen: a 200-line document with one comment in it rendered
8462        // ~200 blank rows after the comment, one per source line, because the
8463        // comment's per-block builder handed back a `last_off` of 0. Parity with
8464        // `build` alone would not catch a *shared* wrong answer, so the count is
8465        // pinned outright.
8466        let body = (0..200)
8467            .map(|i| format!("line {i}"))
8468            .collect::<Vec<_>>()
8469            .join("\n\n");
8470        let src = format!("intro\n\n<!-- exec -->\n{body}\n");
8471        let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
8472        let mut cache = BlockCache::default();
8473        let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
8474        assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
8475        // intro, then 200 × (gap, paragraph): 401 rows and not a row more.
8476        assert_eq!(cached.rows.len(), 401);
8477    }
8478
8479    #[test]
8480    fn a_link_reference_definition_is_stepped_over_like_a_comment() {
8481        // `[a]: /a` is a root beside `doc` with no rows of its own. Merged into
8482        // the walk it is a hidden block: the blocks either side meet across one
8483        // boundary, and its line is not a blank row.
8484        let m = map("see [a]\n\n[a]: /a\n\nafter\n");
8485        assert_eq!(row_texts(&m), ["see a", "", "after"]);
8486        assert_eq!(
8487            boundaries(&m),
8488            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
8489        );
8490    }
8491
8492    #[test]
8493    fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
8494        // The README shape: prose, then a `[links]` block nobody reads. Its
8495        // lines used to be counted as blank ones, an empty paragraph per
8496        // definition under the last real block.
8497        let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
8498        assert_eq!(row_texts(&m), ["see a and b"]);
8499    }
8500
8501    #[test]
8502    fn a_definition_glued_under_a_paragraph_stays_inside_it() {
8503        // `[a]: /a` at the front of a paragraph's lines is stripped from the
8504        // paragraph's text, but the paragraph's span still starts on its line.
8505        // Both blocks start at the same offset; the definition, sorted first,
8506        // is stepped over, and the paragraph draws as it always did — one gap
8507        // above it, none inside.
8508        let m = map("intro\n\n[a]: /a\ntext [a]\n");
8509        assert_eq!(row_texts(&m), ["intro", "", "text a"]);
8510    }
8511
8512    #[test]
8513    fn a_definition_with_no_span_is_left_out_of_the_walk() {
8514        // twig before 3.3.3 reported `0..0` for every link reference
8515        // definition. One of those has nowhere to be merged: sorted first by
8516        // its zero start it would open the document with a phantom block, and
8517        // the walk would step back to offset 0. It is simply not a block. A
8518        // footnote definition is always placed; it has a body to draw.
8519        assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
8520        assert!(is_placed_definition(&Kind::Reference, &(7..14)));
8521        assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
8522        assert!(!is_placed_definition(&Kind::Str, &(7..14)));
8523    }
8524
8525    #[test]
8526    fn the_trailing_gap_closes_the_last_block() {
8527        // Two Enters at the end of a document: a drawn gap, then the navigable
8528        // empty paragraph. Only the gap is labelled, so a frontend that shrinks
8529        // boundaries shrinks the spacer and leaves the row being typed on alone.
8530        let m = map("# Head\n\n\n");
8531        assert_eq!(
8532            boundaries(&m),
8533            vec![(BlockClass::Heading, BlockClass::Paragraph)]
8534        );
8535    }
8536
8537    #[test]
8538    fn only_the_drawn_gap_rows_carry_a_boundary() {
8539        let m = map("one\n\ntwo\n");
8540        for row in &m.rows {
8541            assert_eq!(
8542                row.boundary.is_some(),
8543                row.decoration,
8544                "a boundary is exactly a drawn gap row: {:?}",
8545                row.glyphs.iter().map(|g| g.ch).collect::<String>()
8546            );
8547        }
8548    }
8549
8550    #[test]
8551    fn preserve_flow_labels_no_boundary() {
8552        // Every blank line is a caret home there — somewhere text can go, not a
8553        // gap between blocks — so nothing is drawn-only and nothing is labelled.
8554        // A frontend keying its spacing off `boundary` can't shrink a row the
8555        // author is about to type on.
8556        let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
8557        assert!(boundaries(&m).is_empty());
8558    }
8559
8560    #[test]
8561    fn a_list_draws_no_boundary_between_its_items() {
8562        // Tight or loose, core puts no gap row between two items of one list —
8563        // so an item↔item boundary is a shape no frontend will ever be handed,
8564        // and spacing one is spacing something that isn't there.
8565        for src in ["- one\n- two\n", "- one\n\n- two\n"] {
8566            let m = map(src);
8567            assert!(
8568                boundaries(&m).is_empty(),
8569                "no gap row inside the list of {src:?}"
8570            );
8571        }
8572        // Leaving the list is an ordinary boundary, and the list is named as
8573        // what sits above it.
8574        let m = map("- one\n- two\n\npara\n");
8575        assert_eq!(
8576            boundaries(&m),
8577            vec![(BlockClass::List, BlockClass::Paragraph)]
8578        );
8579    }
8580
8581    #[test]
8582    fn a_nested_boundary_names_the_blocks_inside_the_container() {
8583        // Two paragraphs inside a blockquote are divided by a Paragraph↔Paragraph
8584        // boundary — the quote is the container they're both in, not what the gap
8585        // separates.
8586        let m = map("> one\n>\n> two\n");
8587        assert_eq!(
8588            boundaries(&m),
8589            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
8590        );
8591    }
8592
8593    #[test]
8594    fn a_directive_container_draws_one_boundary_like_every_other_block() {
8595        // A container's rows stop at its last *child*, so without anchoring
8596        // `last_off` past the closing `:::` the separator logic counted the fence
8597        // line as a blank row of its own and drew the gap twice — one authored
8598        // blank line, two boundaries, and a frontend spacing each of them put
8599        // double margin under every fenced div. The code-block arm anchors past
8600        // its ``` for exactly this reason; compare the two here.
8601        let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
8602        assert_eq!(
8603            boundaries(&fenced),
8604            vec![(BlockClass::Directive, BlockClass::Paragraph)],
8605            "one authored gap, one boundary row"
8606        );
8607        let code = map("```\nc\n```\n\ntwo\n");
8608        assert_eq!(
8609            boundaries(&code).len(),
8610            boundaries(&fenced).len(),
8611            "a fenced div spaces like a fenced code block"
8612        );
8613        // Nesting closes several fences at once; still one gap.
8614        let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
8615        assert_eq!(
8616            boundaries(&nested),
8617            vec![(BlockClass::Directive, BlockClass::Paragraph)]
8618        );
8619    }
8620
8621    #[test]
8622    fn a_block_media_names_itself_in_the_boundaries_either_side() {
8623        use BlockClass::*;
8624        // A block image is never a node of its own — `media_only` promotes the
8625        // *paragraph* wrapping it — so classifying the node the walk stands on
8626        // called the picture `Paragraph` and left `BlockClass::Media` unreachable:
8627        // a frontend could not give a photo more air than a line of prose.
8628        // `label_media_boundaries` reads it back off the finished rows instead.
8629        let m = map("one\n\n![alt](p.png)\n\ntwo\n");
8630        assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
8631        // At the edges of the document too: the leading gap has no boundary of
8632        // its own, and the trailing one is `emit_trailing_blank_lines`'.
8633        let edges = map("![a](p.png)\n\nmid\n\n![b](q.png)\n");
8634        assert_eq!(
8635            boundaries(&edges),
8636            vec![(Media, Paragraph), (Paragraph, Media)]
8637        );
8638        // One gap spelled with several rows — the row closing the block above and
8639        // the row opening the one below, with the author's spare blank line
8640        // navigable between them — carries the same pair on every drawn row.
8641        let roomy = map("one\n\n\n\n![alt](p.png)\n");
8642        assert_eq!(
8643            boundaries(&roomy),
8644            vec![(Paragraph, Media), (Paragraph, Media)]
8645        );
8646    }
8647
8648    #[test]
8649    fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
8650        // Worse than the image case before `label_media_boundaries`: a `<video>`
8651        // arrives as twig's generic `container`, which classifies `Directive` —
8652        // the one class a frontend reads as "draw a tinted panel here". A movie
8653        // got the chrome of a fenced div.
8654        let mut doc = crate::Doc::from_source(
8655            "one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
8656            Format::Markdown,
8657        )
8658        .unwrap();
8659        doc.build_visual(80);
8660        assert_eq!(
8661            boundaries(&doc.vmap),
8662            vec![
8663                (BlockClass::Paragraph, BlockClass::Media),
8664                (BlockClass::Media, BlockClass::Paragraph),
8665            ]
8666        );
8667    }
8668
8669    #[test]
8670    fn the_incremental_walk_labels_boundaries_like_the_full_one() {
8671        // `assert_maps_eq` compares boundaries too, so this pins the two doors
8672        // into `BlockClass::from_node_kind` — a `FlatNode`'s kind on the full
8673        // build, a query match's on the cached one — against a document with one
8674        // of every boundary in it.
8675        let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
8676        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
8677        let mut cache = BlockCache::default();
8678        let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
8679        assert_maps_eq(&full, &cached, "boundary labelling");
8680        assert!(
8681            !boundaries(&full).is_empty(),
8682            "the fixture has boundaries to compare"
8683        );
8684    }
8685
8686    #[test]
8687    fn every_caret_stop_opens_a_cluster_of_its_row() {
8688        // The two ways of finding a cluster have to agree. `push_text` marks the
8689        // stops by segmenting one run of text; the column mapping segments the
8690        // whole row, decoration and all. A stop that came out as the *middle* of
8691        // some row-level cluster would be a caret with no column of its own —
8692        // drawn at the column of whatever swallowed it.
8693        let src = "# 標題\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` 你好\n\n\
8694                   - 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
8695                   | A | 值 |\n|---|---|\n| 你好 | 👩‍🚀 |\n";
8696        let m = map(src);
8697        for (r, row) in m.rows.iter().enumerate() {
8698            let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
8699            for (i, g) in row.glyphs.iter().enumerate() {
8700                assert!(
8701                    !g.stop || openers.contains(&i),
8702                    "row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
8703                     so it is drawn at another glyph's column",
8704                    g.ch
8705                );
8706            }
8707        }
8708    }
8709
8710    // ── the presentation vocabulary ─────────────────────────────────────────
8711
8712    /// A block's own attributes, in the three formats that spell one on the
8713    /// block itself: HTML's tag, djot's `{…}` line, and — the odd one — a
8714    /// Markdown `<div>` around it, which is where twig has to put a Markdown
8715    /// block's attributes because the format has nowhere else.
8716    #[test]
8717    fn a_block_carries_its_alignment_on_every_row_it_draws() {
8718        // HTML, on the paragraph. `lead` is somebody else's class and is
8719        // neither read nor in the way.
8720        let html = map_leaf("<p class=\"lead center\">hi</p>\n", Format::Html);
8721        assert_eq!(line_facts(&html), vec![(Some(Align::Center), None)]);
8722
8723        // djot's attribute line, on the block.
8724        let dj = map_leaf("{.right}\nhi\n", Format::Djot);
8725        assert_eq!(line_facts(&dj), vec![(Some(Align::Right), None)]);
8726
8727        // A heading carries it too, and on every row a wrapped one draws.
8728        let h = map_leaf("{.center}\n# a heading\n", Format::Djot);
8729        assert_eq!(line_facts(&h), vec![(Some(Align::Center), None)]);
8730        assert_eq!(h.rows[0].heading, Some(1));
8731
8732        // Both keys at once, and the line spacing is read the same way.
8733        let both = map_leaf("{.justify data-line-height=\"1.5\"}\nhi\n", Format::Djot);
8734        assert_eq!(
8735            line_facts(&both),
8736            vec![(
8737                Some(Align::Justify),
8738                Some(LineHeight::Step(LineSpacing::OneHalf))
8739            )]
8740        );
8741
8742        // An unknown token is somebody else's and the block draws at the
8743        // theme's alignment; a ratio outside the menu's three is the author's
8744        // own and draws at exactly what they wrote.
8745        let other = map_leaf("{.lead data-line-height=\"1.3\"}\nhi\n", Format::Djot);
8746        assert_eq!(line_facts(&other), vec![(None, LineHeight::ratio(1.3))]);
8747
8748        // A value the grammar does not cover is neither: carried by the
8749        // document, drawn at the theme's spacing, and read as nothing at all.
8750        let em = map_leaf("{data-line-height=\"1.3em\"}\nhi\n", Format::Djot);
8751        assert_eq!(line_facts(&em), vec![(None, None)]);
8752    }
8753
8754    /// `<div class="center">` around three paragraphs centres all three, which
8755    /// is what the author of that HTML meant — and around one is the sole-child
8756    /// shape twig's `set_block_attrs` writes in Markdown.
8757    #[test]
8758    fn a_div_lends_its_alignment_to_every_block_inside_it() {
8759        let m = map_leaf(
8760            "<div class=\"center\" data-line-height=\"2\">\n\none\n\ntwo\n\n</div>\n",
8761            Format::Markdown,
8762        );
8763        assert_eq!(
8764            line_facts(&m),
8765            vec![
8766                (
8767                    Some(Align::Center),
8768                    Some(LineHeight::Step(LineSpacing::Double))
8769                ),
8770                (
8771                    Some(Align::Center),
8772                    Some(LineHeight::Step(LineSpacing::Double))
8773                ),
8774            ]
8775        );
8776
8777        // The nearer node wins, and the block after the div is untouched — the
8778        // context is restored, not left running.
8779        let nested = map_leaf(
8780            "<div class=\"center\">\n\n<div class=\"right\">\n\ninner\n\n</div>\n\nouter\n\n</div>\n\nafter\n",
8781            Format::Markdown,
8782        );
8783        assert_eq!(
8784            line_facts(&nested),
8785            vec![
8786                (Some(Align::Right), None),
8787                (Some(Align::Center), None),
8788                (None, None),
8789            ]
8790        );
8791    }
8792
8793    /// Size, face and colour are the run's, and the block's when the whole
8794    /// block is meant — read at both levels with the nearer winning.
8795    #[test]
8796    fn a_span_s_size_beats_its_block_s_and_its_face_falls_through() {
8797        // `<div data-font>` over `<p data-size>` over `<span data-size>`: the
8798        // span wins on size, the block is still what says the face.
8799        // The span is not first on its line: a `<span …>` opening one is an
8800        // HTML *block* to CommonMark, which is a fact about Markdown and not
8801        // about this.
8802        let m = map_leaf(
8803            "<div data-font=\"serif\">\n\nc <span data-size=\"small\">a</span> b\n\n</div>\n",
8804            Format::Markdown,
8805        );
8806        let a = style_of(&m, 'a');
8807        assert_eq!(a.size, Some(FontSize::Step(SizeStep::Small)));
8808        assert_eq!(a.font, Some(FaceRef::Generic(FontFamily::Serif)));
8809        // The text outside the span keeps the div's face and no size at all.
8810        let b = style_of(&m, 'b');
8811        assert_eq!(b.size, None);
8812        assert_eq!(b.font, Some(FaceRef::Generic(FontFamily::Serif)));
8813
8814        // djot spells the same span anonymously and it reads identically.
8815        let dj = map_leaf(
8816            "{data-size=\"large\"}\nx [y]{data-size=\"xx-large\" data-color=\"blue\"} z\n",
8817            Format::Djot,
8818        );
8819        assert_eq!(
8820            style_of(&dj, 'x').size,
8821            Some(FontSize::Step(SizeStep::Large))
8822        );
8823        assert_eq!(
8824            style_of(&dj, 'y').size,
8825            Some(FontSize::Step(SizeStep::XxLarge))
8826        );
8827        assert_eq!(
8828            style_of(&dj, 'y').color,
8829            Some(TextColor::Named(MarkColor::Blue))
8830        );
8831        // The block's size is still the block's outside the span.
8832        assert_eq!(
8833            style_of(&dj, 'z').size,
8834            Some(FontSize::Step(SizeStep::Large))
8835        );
8836        assert_eq!(style_of(&dj, 'z').color, None);
8837    }
8838
8839    /// The exact half of the vocabulary reaches a glyph and a row by the same
8840    /// doors the names do — the fold has one rule, not one per form. A named
8841    /// family is the one that cannot ride the glyph as itself: the walker
8842    /// interns it and the glyph carries the id.
8843    #[test]
8844    fn an_exact_size_face_and_colour_reach_the_glyph_and_the_row() {
8845        let m = map_leaf(
8846            "<div data-line-height=\"1.3\">\n\nc <span data-size=\"14pt\" \
8847             data-color=\"#c03030\" data-font=\"Garamond\">a</span> b\n\n</div>\n",
8848            Format::Markdown,
8849        );
8850        let a = style_of(&m, 'a');
8851        assert_eq!(a.size, FontSize::points(14.0));
8852        assert_eq!(
8853            a.color,
8854            Some(TextColor::Rgb {
8855                r: 0xc0,
8856                g: 0x30,
8857                b: 0x30
8858            })
8859        );
8860        assert_eq!(a.font, Some(FaceRef::Named(FaceId::of("Garamond"))));
8861        assert_eq!(m.face_name(FaceId::of("Garamond")), Some("Garamond"));
8862        // The div's ratio is the row's, on every row the block draws.
8863        assert_eq!(line_facts(&m), vec![(None, LineHeight::ratio(1.3))]);
8864        // And the text outside the span has none of the span's three.
8865        let b = style_of(&m, 'b');
8866        assert_eq!((b.size, b.font, b.color), (None, None, None));
8867
8868        // One name, one entry, however many spans wear it — the table is what
8869        // keeps a `Style` `Copy` and it should not grow per run.
8870        let twice = map_leaf(
8871            "x <span data-font=\"Garamond\">a</span> y <span data-font=\"Garamond\">b</span>\n",
8872            Format::Markdown,
8873        );
8874        assert_eq!(twice.faces().len(), 1);
8875        assert_eq!(
8876            style_of(&twice, 'a').font,
8877            style_of(&twice, 'b').font,
8878            "one family, one id"
8879        );
8880
8881        // A generic needs no entry at all: it names itself.
8882        let generic = map_leaf(
8883            "<div data-font=\"serif\">\n\nhi\n\n</div>\n",
8884            Format::Markdown,
8885        );
8886        assert_eq!(
8887            style_of(&generic, 'h').font,
8888            Some(FaceRef::Generic(FontFamily::Serif))
8889        );
8890        assert!(generic.faces().is_empty());
8891    }
8892
8893    /// The one key two nodes share. `data-color` on a `mark` is the highlight's
8894    /// *background* and reaches a glyph through [`Role::Mark`]; the same key on
8895    /// an attributed span is the text's foreground. Same vocabulary, same enum,
8896    /// no collision — and a mark inside a coloured span wears both.
8897    #[test]
8898    fn a_mark_keeps_its_highlight_colour_and_a_span_colours_the_text() {
8899        let m = map_leaf("a ==\u{1f534} red== b\n", Format::Markdown);
8900        let r = style_of(&m, 'r');
8901        assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)));
8902        assert_eq!(r.color, None, "a highlight is not a text colour");
8903
8904        let both = map_leaf(
8905            "<span data-color=\"blue\">a ==\u{1f534} red== b</span>\n",
8906            Format::Markdown,
8907        );
8908        let r = style_of(&both, 'r');
8909        assert_eq!(r.role, Role::Mark(Some(MarkColor::Red)), "the highlight");
8910        assert_eq!(
8911            r.color,
8912            Some(TextColor::Named(MarkColor::Blue)),
8913            "the letters"
8914        );
8915    }
8916
8917    /// A page break is the `::page-break` leaf directive, and djot spells the
8918    /// same document as an empty `::: page-break` fence whose name comes back
8919    /// as a class. Both draw the placeholder row every leaf directive gets and
8920    /// both carry the same [`DirectiveMark`], because a frontend that opens a
8921    /// page at one must not be able to tell which format the file is in.
8922    #[test]
8923    fn a_page_break_reads_the_same_in_markdown_and_in_djot() {
8924        for (fmt, src) in [
8925            (Format::Markdown, "a\n\n::page-break\n\nb\n"),
8926            (Format::Djot, "a\n\n::: page-break\n:::\n\nb\n"),
8927        ] {
8928            let m = map_leaf(src, fmt);
8929            let marks: Vec<&DirectiveMark> = m
8930                .rows
8931                .iter()
8932                .filter_map(|r| r.leaf_directive.as_ref())
8933                .collect();
8934            assert_eq!(marks.len(), 1, "{fmt:?} draws one placeholder");
8935            assert_eq!(marks[0].name, "page-break", "{fmt:?}");
8936            assert!(marks[0].attrs.is_empty(), "{fmt:?}: {:?}", marks[0].attrs);
8937            assert!(
8938                m.rows
8939                    .iter()
8940                    .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>()
8941                        == "\u{29c9} page-break"),
8942                "{fmt:?} draws the label, got {:?}",
8943                m.rows
8944                    .iter()
8945                    .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
8946                    .collect::<Vec<_>>()
8947            );
8948        }
8949
8950        // A Markdown `:::note` with nothing in it is *not* this: its name is
8951        // its own, and nothing about it says "a block with no body" the way
8952        // djot's spelling of a leaf directive does.
8953        let empty_fence = map_leaf("::: note\n:::\n", Format::Markdown);
8954        assert!(
8955            empty_fence.rows.iter().all(|r| r.leaf_directive.is_none()),
8956            "a named empty fence keeps the reading it has"
8957        );
8958    }
8959
8960    /// A djot fence carrying more than its name keeps the rest as an attribute
8961    /// rather than folding it into the name: the *first* class token is the
8962    /// name, because that is where `insert_directive` puts it.
8963    #[test]
8964    fn a_djot_fence_s_first_class_is_the_directive_s_name_and_the_rest_is_attributes() {
8965        let m = map_leaf("{.page-break .wide}\n:::\n:::\n", Format::Djot);
8966        let mark = m
8967            .rows
8968            .iter()
8969            .find_map(|r| r.leaf_directive.as_ref())
8970            .expect("a placeholder");
8971        assert_eq!(mark.name, "page-break");
8972        assert_eq!(
8973            mark.attrs,
8974            vec![("class".to_string(), Some("wide".to_string()))]
8975        );
8976    }
8977}