Skip to main content

leaf_core/
wysiwyg.rs

1//! The WYSIWYG view: render the document with its markup *resolved*, not shown —
2//! headings and code tagged with a typographic role (a frontend sizes or colours
3//! them; see [`crate::style`]), `**bold**` as real bold, `# ` / `**` / `` ` ``
4//! delimiters hidden — while keeping every visible glyph tied back to the source
5//! byte it came from.
6//!
7//! That back-reference (`Glyph::src`) is what lets a caret still work: the caret
8//! stays a source offset (shared with the source view), but the [`VisualMap`]
9//! converts between an offset and a screen `(row, col)`, so cursor drawing,
10//! mouse clicks, and vertical motion all operate in *visible* space.
11//!
12//! Left and Right instead walk the map's caret *stops* in document order. On
13//! ordinary prose that's the same journey — the stops are laid out left to right
14//! — and it steps over the hidden delimiters either way. They part company only
15//! in a table, where the text is arranged in two dimensions and a cell wrapped
16//! within its column continues *below* rather than to the right. Following the
17//! document is what a caret means there.
18//!
19//! Text is walked from the AST (`str` nodes carry exact spans, and their text is
20//! the verbatim source slice), so a Markdown and a Djot file that parse alike
21//! render — and map — identically.
22
23use std::cell::{Cell, RefCell};
24use std::collections::HashMap;
25use std::ops::Range;
26
27use twig::{Alignment, ContainerOrigin, DirectiveForm, Editor, FlatNode, Kind, QueryMatch};
28use unicode_segmentation::UnicodeSegmentation;
29use unicode_width::UnicodeWidthStr;
30
31use crate::style::{Baseline, MarkColor, Role, Style, Token};
32
33/// One rendered character plus the source byte offset it originates from.
34/// Synthetic glyphs (a list bullet, a quote gutter) point at their block's
35/// start, so clicking one lands the caret at the start of that block.
36#[derive(Clone)]
37pub struct Glyph {
38    pub ch: char,
39    pub style: Style,
40    pub src: usize,
41    /// Whether the caret may *rest* on this glyph. Decoration — a table border
42    /// or a cell's alignment padding — is visible but isn't text, so the caret
43    /// steps over it instead of into it. It also can't be a stop even in
44    /// principle: a run of decoration shares one `src`, and a caret can only
45    /// move by changing offset, so resting on it would pin horizontal motion.
46    /// A click still maps through `src`, which is why decoration points at the
47    /// text it decorates.
48    ///
49    /// Real text is a stop once per *grapheme cluster*, on the glyph that opens
50    /// it: the continuation glyphs of an emoji or an accented letter are drawn,
51    /// but standing between them is standing inside a character.
52    pub stop: bool,
53}
54
55/// One visual line. `end_src` is the source offset a caret sits at when placed
56/// at the line's end (past its last glyph) — the anchor for end-of-line and
57/// click-past-content.
58///
59/// `Clone` so a block's rows can be cached and re-emitted at a shifted offset
60/// across an edit — see [`BlockCache`].
61#[derive(Clone)]
62pub struct VRow {
63    pub glyphs: Vec<Glyph>,
64    pub end_src: usize,
65    /// A row that is drawn but holds no caret: a table's `├───┼───┤` rules, and
66    /// the blank gap a block boundary is spelled with. Vertical motion steps
67    /// over it, `pos_of_offset` never resolves onto it, and its stops (it has
68    /// none) and `end_src` stay out of the map's stop table.
69    ///
70    /// Emptiness isn't the test — an empty paragraph is a blank row too, and a
71    /// real caret stop. The test is whether the row is somewhere text can go.
72    pub decoration: bool,
73    /// This row is one line of a fenced or indented code block. Set on every row
74    /// the `"code_block"` arm emits — including its blank lines, which carry no
75    /// glyph to tell them apart otherwise. A frontend draws its own chrome (a
76    /// border and a tinted background) around each maximal run of these, and
77    /// scrolls them horizontally instead of wrapping; see
78    /// [`VisualMap::code_blocks`]. Survives the row shuffling of [`BlockCache`]
79    /// reuse and [`build_spliced`] because it rides on the row, not on a
80    /// row-index span the way a table's picture does.
81    pub code: bool,
82    /// A fenced code block's info string (its language), carried on the *first*
83    /// row of the block so it survives row reuse the way [`code`](Self::code)
84    /// does. `None` on every other row, and on an indented block (which has no
85    /// fence to label). A frontend paints it as a small label on the block's box
86    /// and edits it through a prompt — see [`CodeBlockInfo::lang`]. It's a plain
87    /// display string, not a source slice, so it needs no offset shifting; the
88    /// label re-derives from twig on the next build.
89    pub code_lang: Option<String>,
90    /// This row belongs to a `:::name{.class}` directive container — twig's
91    /// generic fenced-div block, whose meaning is entirely up to the host app
92    /// (diaryx's `:::vis{.audience}` visibility blocks, say). Set on every row
93    /// the `"directive"` arm emits, the same way [`code`](Self::code) marks a
94    /// code block's rows, so a frontend can draw a tinted panel around each
95    /// maximal run of these.
96    pub directive: bool,
97    /// A directive container's space-joined attrs — dot-prefixed classes
98    /// (`.public .family` → `"public family"`) unioned with bare pandoc-style
99    /// words (`public family`, no leading dot — diaryx's other `:::vis{...}`
100    /// convention), carried on the block's *first* row only — the
101    /// [`code_lang`](Self::code_lang) pattern. `None` on every other row, and
102    /// when the directive carries no such attrs. A frontend paints it as a
103    /// small label on the block's panel; it's a plain display string, not a
104    /// source slice, so it rides row reuse untouched.
105    pub directive_label: Option<String>,
106    /// Set on the single placeholder row a block-level image renders to, carrying
107    /// the image's destination and alt text; `None` on every other row. The row's
108    /// glyphs are the default `🖼 alt` label (which a plain surface paints as-is);
109    /// an image-capable frontend reads this to paint the real picture instead,
110    /// skipping the row named by [`MediaInfo::rows_span`]. Like
111    /// [`code_lang`](Self::code_lang) it's plain display strings, not source
112    /// slices, so it rides row reuse and needs no offset shifting; the map's
113    /// [`media`](VisualMap::media) side-table is derived from it once the rows
114    /// are final, the same way [`code_blocks`](VisualMap::code_blocks) is.
115    pub media: Option<MediaMark>,
116    /// Set on the **first** row of a task list item, carrying whether its box is
117    /// ticked; `None` on every other row, including a plain `list_item`'s. The
118    /// row's glyphs already draw the box as `☐ `/`☑ ` in the marker's place, so a
119    /// plain surface needs nothing further; a GUI reads this to paint a real
120    /// checkbox widget and to know which way it is facing.
121    ///
122    /// A `bool` rather than a source span, for the reason
123    /// [`code_lang`](Self::code_lang) is a plain string: it rides [`BlockCache`]
124    /// reuse and [`build_spliced`] untouched, needing no offset shifting. To
125    /// *toggle* the box, a frontend maps its click to a source offset the way it
126    /// maps any other — the marker's glyphs carry the item's own `src` — and
127    /// hands that to [`crate::Doc::toggle_task_at`].
128    pub task: Option<bool>,
129    /// Set on the single placeholder row a **leaf** directive (`::name{…}`)
130    /// renders to, carrying its name and attributes; `None` on every other row.
131    /// The container form isn't this — it wraps real blocks and marks each of
132    /// them [`directive`](Self::directive) instead. Like [`media`](Self::media)
133    /// it's plain display strings, so it rides row reuse untouched, and the map's
134    /// [`directives`](VisualMap::directives) side-table is derived from it once
135    /// the rows are final.
136    pub leaf_directive: Option<DirectiveMark>,
137    /// The heading level (1–6) of the block this row belongs to, on every row a
138    /// `heading` emits (a long one wraps to several) and `None` everywhere else.
139    ///
140    /// A frontend that sizes a whole line — a proportional renderer giving the
141    /// row a bigger line box — needs the level *per row*, and the glyphs can't
142    /// always supply it: an empty heading (`# ` with nothing typed after it,
143    /// which is what the toolbar's H1 leaves on a blank line) has no glyph to
144    /// carry a [`Role::Heading`] at all, so a glyph scan called it body text and
145    /// the line drew at body height until the first character landed. Riding the
146    /// row says it once, for the empty case and the wrapped case alike.
147    ///
148    /// Per-*glyph* styling still comes from [`Role::Heading`] on the glyphs; this
149    /// is the row-level fact, and the two agree wherever a heading has content —
150    /// same `u8` level, clamped the same way [`heading_style`] clamps it.
151    pub heading: Option<u8>,
152    /// What this row divides, on the blank rows a block boundary is *drawn* with
153    /// and `None` on every other row — including the navigable blank lines of
154    /// preserve-soft flow, which are somewhere text can go rather than a gap
155    /// between blocks. So `boundary.is_some()` is exactly "this row is a drawn
156    /// block boundary", the [`decoration`](Self::decoration) rows that come from
157    /// [`Builder::emit_separators_before`].
158    ///
159    /// It exists because a boundary's *height* is a frontend decision but its
160    /// *kind* is not. Typography spaces a boundary by what it separates — the
161    /// margin above a heading is wider than the one between two paragraphs, so
162    /// the heading groups with the text it introduces — and a frontend that has
163    /// only rows to look at has to re-derive the structure by sniffing glyph
164    /// roles. Three frontends sniffing separately is three chances to disagree
165    /// about the same document. Core already knows, having just walked the AST
166    /// to emit this row, so it says so once here and each frontend multiplies by
167    /// its own spacing.
168    pub boundary: Option<Boundary>,
169    /// The offsets on this row where an inline mark's *content* ends under a
170    /// hidden closing delimiter — the end of the `d` in `**bold**`, one byte
171    /// before the `**` that draws nothing. Each is a caret stop with no glyph
172    /// of its own: the caret standing there is drawn where the next glyph is,
173    /// but typing there extends the mark, where typing past the delimiter
174    /// leaves it. See [`VisualMap::mark_ends`] for the rule.
175    ///
176    /// Source offsets, so [`shift_row`] moves them with the glyphs; empty on
177    /// decoration rows and on every row no mark closes on.
178    pub mark_ends: Vec<usize>,
179}
180
181/// What a drawn block boundary separates: the kinds of the blocks it falls
182/// between — the pair a frontend spaces by.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub struct Boundary {
185    pub above: BlockClass,
186    pub below: BlockClass,
187}
188
189/// The block kinds core tells apart when it walks a document — the vocabulary
190/// [`Boundary`] is spelled in. A statement about *structure*, not about how any
191/// of it should look: what a frontend does with "this gap sits above a heading"
192/// is entirely the frontend's.
193///
194/// `Class` rather than `Kind` because [`twig::BlockKind`] already means
195/// something else in this crate's public surface — the *command* vocabulary
196/// (`Paragraph | Heading(n)`) a toolbar passes to [`Doc::set_block`](crate::Doc::set_block).
197/// This is the reverse direction: what a block already *is*, read back off a
198/// rendered row.
199///
200/// [`BlockClass::Other`] is the honest answer for a node kind core doesn't
201/// separate out, so adding one here is additive for every frontend: nothing has
202/// to change until it wants to space that kind differently.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub enum BlockClass {
205    Paragraph,
206    Heading,
207    /// A whole list. Its *items* are [`BlockClass::ListItem`]; note that core
208    /// draws no boundary row between two items of one list, tight or loose, so
209    /// an item↔item pair never reaches a frontend.
210    List,
211    ListItem,
212    Quote,
213    Code,
214    Table,
215    /// A block-level image, video, or audio.
216    ///
217    /// Never reached through [`from_node_kind`](BlockClass::from_node_kind): a
218    /// block picture is not a node of its own — [`Builder::media_only`] promotes
219    /// the paragraph (or `<picture>`/`<video>` container) wrapping it — so the
220    /// walk only ever sees the wrapper's kind. [`label_media_boundaries`] reads
221    /// it back off the finished rows instead, after the fact.
222    Media,
223    /// A `:::name{.class}` directive container.
224    Directive,
225    Rule,
226    Footnote,
227    Other,
228}
229
230impl BlockClass {
231    /// Classify a twig node kind — the same vocabulary [`Builder::block`]
232    /// matches on, so the two can't drift about what a block is. Both the
233    /// whole-arena walk (which has [`FlatNode`]s) and the incremental top-level
234    /// walk (which has only a query match's kind) reach it by this one door.
235    pub fn from_node_kind(kind: &Kind) -> BlockClass {
236        match kind {
237            Kind::Para => BlockClass::Paragraph,
238            Kind::Heading => BlockClass::Heading,
239            Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
240            Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
241            Kind::BlockQuote => BlockClass::Quote,
242            Kind::CodeBlock => BlockClass::Code,
243            Kind::Table => BlockClass::Table,
244            Kind::Image => BlockClass::Media,
245            // twig 2.8 folded `div`/`span`/`directive`/`element` into one
246            // `container` kind, so a `:::note` panel and a promoted `<video>`
247            // arrive here indistinguishable — telling them apart needs the
248            // node's `origin`, and the incremental walk has only this kind.
249            // `Directive` is the right answer for the case that motivates the
250            // class (nothing else draws a tinted panel) and a harmless one for
251            // the rest: `BlockClass` is descriptive and core never branches on
252            // it. The one case where it was actively wrong — a promoted
253            // `<video>`, which would have been handed to a frontend as something
254            // to draw a fenced-div panel around — is corrected by
255            // [`label_media_boundaries`] once the rows are final, along the same
256            // door as a block image. Anything else that must be exact reads
257            // [`container_is_directive`] off a real node.
258            Kind::Container => BlockClass::Directive,
259            Kind::ThematicBreak => BlockClass::Rule,
260            Kind::Footnote => BlockClass::Footnote,
261            _ => BlockClass::Other,
262        }
263    }
264}
265
266/// The name and attributes a leaf directive's placeholder row carries, so a
267/// frontend that knows the host app's vocabulary can paint the real thing —
268/// an embedded page for diaryx's `::embed{src=…}`, a generated table of
269/// contents for a `::toc`, and the plain `⧉ name` label for one it doesn't
270/// know. The peer of [`MediaMark`], and plain strings for the same reason: they
271/// survive the row shuffling of [`BlockCache`] reuse and [`build_spliced`].
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct DirectiveMark {
274    /// The directive's type — `embed`, `toc`, `vis` — with no leading colons.
275    /// Core is agnostic of what it means: the vocabulary is the host app's.
276    pub name: String,
277    /// Its `{…}` attributes as `(key, value)` pairs in source order. A bare
278    /// attribute (`{public}`) has a `None` value, the way twig reports it.
279    pub attrs: Vec<(String, Option<String>)>,
280    /// The directive's `[label]` text, flattened from its inline children, or
281    /// empty when it has none. Also what the placeholder label shows.
282    pub label: String,
283    /// How many visual rows this directive reserves — the label row plus blank
284    /// filler rows below it, so a frontend painting something real has the
285    /// vertical room. `1` is the bare placeholder, and the only value core
286    /// produces today: unlike an image (whose height a terminal frontend
287    /// measures and reports back), nothing has told core how tall an embed is.
288    /// A pixel-laid-out GUI sets its own height regardless.
289    pub rows: usize,
290}
291
292/// What a block-level media placeholder actually is, so a frontend knows which
293/// widget to build over the reserved rows: a raster, a movie player, or a
294/// transport with no picture at all. Core classifies and stops there — it opens
295/// nothing, so this is a statement about the *markup*, not about a file it has
296/// verified exists or can decode.
297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
298pub enum MediaKind {
299    /// A `![](…)` / `<img>` / `<picture>` — a still picture.
300    Image,
301    /// An HTML `<video>`. Markdown and Djot spell no video of their own, so this
302    /// only ever arrives through `html_elements` promotion (or a `::video{…}`
303    /// directive a host app maps itself, which core reports as a directive).
304    Video,
305    /// An HTML `<audio>` — a transport with no picture, so a frontend gives it a
306    /// fixed control height rather than measuring an aspect ratio.
307    Audio,
308}
309
310/// Which of the two caret homes a block media has — see
311/// [`VisualMap::block_media_stop`].
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub enum MediaStop {
314    /// The stop in front of the picture. What is typed here belongs above it.
315    Before,
316    /// The stop just past it. What is typed here belongs below it.
317    After,
318}
319
320impl MediaKind {
321    /// The emoji a plain surface prefixes the placeholder label with — the
322    /// `🖼`/`🎬`/`🔊` that makes the row read as *a thing* rather than as text.
323    fn sigil(self) -> char {
324        match self {
325            MediaKind::Image => '🖼',
326            MediaKind::Video => '🎬',
327            MediaKind::Audio => '🔊',
328        }
329    }
330}
331
332/// The destination and label a block-level media placeholder row carries, so a
333/// capable frontend can resolve and paint the real thing. Plain strings (no
334/// source offsets), so they survive the row shuffling of [`BlockCache`] reuse
335/// and [`build_spliced`] untouched — see [`VRow::media`].
336#[derive(Clone, Debug, PartialEq, Eq)]
337pub struct MediaMark {
338    /// Whether this is a picture, a movie, or a sound — which widget the
339    /// frontend builds over the reserved rows.
340    pub kind: MediaKind,
341    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
342    /// the AST. A frontend resolves a relative path against the document's
343    /// directory itself; core holds no I/O.
344    ///
345    /// Empty is possible and legal for a `<video>`/`<audio>`, which may carry no
346    /// `src` of its own and name its candidates in child `<source>`s instead —
347    /// unlike an `<img>`, whose `src` *is* the picture. A frontend with an empty
348    /// destination takes its URL from [`sources`](MediaMark::sources).
349    pub destination: String,
350    /// A `<picture>`'s theme/media alternatives, in document order, when this
351    /// block image came from one; empty for a plain `![](…)` / bare `<img>`. Each
352    /// is a `<source>`'s media query + candidate URL(s); a frontend that knows its
353    /// theme picks the first whose media matches and falls back to [`destination`]
354    /// (the `<img>`). Core keeps them verbatim and picks nothing — it has no theme.
355    ///
356    /// [`destination`]: MediaMark::destination
357    pub sources: Vec<MediaSource>,
358    /// The media's alt text (its rendered inline children, flattened), or empty
359    /// when it has none. Also what the placeholder label shows. For a `<video>`/
360    /// `<audio>` this is the element's own text content — the "your browser does
361    /// not support…" fallback, which doubles as its accessible name.
362    pub alt: String,
363    /// A `<video poster="…">`'s still frame, verbatim, or empty when there is
364    /// none (and always empty for an image or audio). It is an *image*
365    /// destination, so a frontend already able to draw a picture can show it
366    /// before the movie loads — or in place of one it can't play at all.
367    pub poster: String,
368    /// How many visual rows this media reserves — the placeholder label row plus
369    /// the blank filler rows below it, so a frontend that paints a real raster has
370    /// the vertical room to draw it. `1` is the bare placeholder (a frontend that
371    /// can't draw pictures, or an image it couldn't resolve). A terminal frontend
372    /// asks for as many rows as the fitted picture is tall; the pixel-laid-out GUI
373    /// ignores this and sets its own row height, so it always leaves it `1`. The
374    /// count comes from the frontend (via [`crate::Doc::set_media_rows`]) because
375    /// core does no I/O and can't measure the image itself. See [`VRow::media`].
376    pub rows: usize,
377}
378
379/// One `<source>` under a `<picture>`, `<video>`, or `<audio>`: a candidate URL
380/// plus whichever of the two things HTML lets a `<source>` be chosen by — a
381/// media query (`<picture>`) or a MIME type (`<video>`/`<audio>`). Verbatim from
382/// the AST: core carries the alternatives and resolves none of them, having
383/// neither a theme nor a codec list to judge them by.
384///
385/// The two spellings are normalised onto one field. `<picture>` writes
386/// `srcset`, `<video>`/`<audio>` write `src`; both land in
387/// [`srcset`](MediaSource::srcset), since a frontend wants the URL either way
388/// and only `<picture>` ever uses the descriptor syntax.
389#[derive(Clone, Debug, PartialEq, Eq)]
390pub struct MediaSource {
391    /// The `<source media="…">` query, verbatim (`"(prefers-color-scheme: dark)"`),
392    /// or empty for a `<source>` with no `media` (an unconditional override, and
393    /// the norm for `<video>`/`<audio>`, which pick by codec rather than theme).
394    pub media: String,
395    /// The candidate URL(s): a `<picture>`'s `srcset` verbatim — one URL, or a
396    /// comma-separated candidate list with `1x`/`2x`/width descriptors — or a
397    /// `<video>`/`<audio>` `<source>`'s plain `src`. A frontend takes the first
398    /// URL token; the theme and codec cases both only ever need that.
399    pub srcset: String,
400    /// The `<source type="…">` MIME type (`"video/webm"`), verbatim, or empty
401    /// when the `<source>` declares none. How a `<video>`/`<audio>` frontend
402    /// picks a candidate it can actually decode; a `<picture>`'s sources
403    /// normally leave it empty and are chosen by [`media`](MediaSource::media).
404    pub mime: String,
405}
406
407/// The rendered document plus the offset⇄position mapping the caret rides on.
408#[derive(Clone, Default)]
409pub struct VisualMap {
410    /// The document's **default monospace rendering** — one [`VRow`] of glyphs
411    /// per visual line, tables spelled with box-drawing borders (`│ ─ ┌┬┐…`) and
412    /// cells padded to whole character-cell columns. Any monospace surface can
413    /// draw these verbatim, so a consumer gets a working view for free: the TUI
414    /// paints them as-is, and a five-line plain-text dump would too.
415    ///
416    /// It's a *default*, not the only truth. A frontend with its own geometry —
417    /// a proportional GUI — lays text out in its own units, and for a table
418    /// skips the box-drawn rows named by [`TableInfo::rows_span`] and draws from
419    /// the structural [`TableInfo`] instead. The box glyphs live here rather than
420    /// in a frontend precisely because they *are* a renderable default: unlike a
421    /// colour (a role each surface must map to its own palette — see
422    /// [`crate::style`]), `┌─┐` is finished text that needs no interpretation.
423    pub rows: Vec<VRow>,
424    /// The first source offset that is actually rendered — the caret floor for
425    /// the WYSIWYG view. Non-zero when a leading `metadata` block (YAML/TOML
426    /// frontmatter) is skipped: the frontmatter is preserved in the source and
427    /// editable in the source view, but hidden and unreachable here, so the
428    /// caret and selection can't wander into it (and copy won't grab it).
429    pub content_start: usize,
430    /// Every offset the caret may rest at, ascending and deduplicated: each
431    /// row's stop glyphs plus the row's own end (the "after the last character"
432    /// spot every line needs). Decoration contributes nothing.
433    ///
434    /// Left/Right read this instead of walking the grid, because the grid isn't
435    /// laid out in offset order: a table with wrapped cells puts column 1's
436    /// second line *below* column 2's first, so "the next stop rightward" and
437    /// "the next stop in the document" part ways. Following the document is what
438    /// a caret means — and on every row that *is* in order the two agree anyway,
439    /// so nothing else has to change.
440    stops: Vec<usize>,
441    /// The caret's second home at the end of every hidden inline mark: the
442    /// offset where the mark's content ends, one byte before its closing
443    /// delimiter — ascending and deduplicated, from every row's
444    /// [`VRow::mark_ends`].
445    ///
446    /// With delimiters hidden, `**bold** tail` draws one spot after the `d`
447    /// and the source has two offsets for it: the content end (inside the
448    /// mark, where typing extends the bold) and the byte past the `**` (where
449    /// typing leaves it). Only the second is a glyph's offset, so only it was
450    /// a stop, and a caret asked to rest at the first was snapped a whole
451    /// character back onto the `d` — a drag over `bold` came back one letter
452    /// short. The delete and backspace paths already settle the caret on the
453    /// content end as its natural home there
454    /// ([`crate::Doc::settle_inside_close_delims`]); this makes it one the
455    /// caret can be placed at and step onto too.
456    ///
457    /// Kept apart from [`stops`](Self::stops) rather than merged in, because
458    /// the two lists answer different questions. A stop with no glyph is
459    /// invisible to a walk that pairs stops with characters — a system text
460    /// input counting `position(from:offset:)` steps against the text it was
461    /// shown would drift a character at every mark — and to word motion, which
462    /// classifies a stop by the source byte under it (a `*`). So
463    /// [`stop_after`](Self::stop_after) and its kin walk the glyph stops alone,
464    /// and only the places a caret *rests* — snapping, resting checks, and
465    /// Left/Right — read both.
466    mark_ends: Vec<usize>,
467    /// Every table in the document, in order, described structurally rather than
468    /// drawn — see [`TableInfo`] for why both exist.
469    pub tables: Vec<TableInfo>,
470    /// Every fenced/indented code block, in order, as the range of [`rows`] it
471    /// occupies — a frontend draws one bordered, tinted box around each and
472    /// scrolls it horizontally rather than wrapping. Derived from the per-row
473    /// [`VRow::code`] flag once the rows are final (so it survives incremental
474    /// row reuse), the same way [`collect_stops`] derives the stop table.
475    ///
476    /// [`rows`]: VisualMap::rows
477    pub code_blocks: Vec<CodeBlockInfo>,
478    /// Every block-level image in the document, in order — one per placeholder
479    /// row a frontend replaces with a real picture. Derived from the per-row
480    /// [`VRow::media`] mark once the rows are final (so it survives incremental
481    /// row reuse), the same way [`code_blocks`](VisualMap::code_blocks) is
482    /// derived from [`VRow::code`].
483    pub media: Vec<MediaInfo>,
484    /// Every **leaf** directive in the document, in order — one per placeholder
485    /// row a frontend may replace with whatever the host app's vocabulary makes
486    /// of it. Derived from the per-row [`VRow::leaf_directive`] mark once the
487    /// rows are final, exactly as [`media`](VisualMap::media) is.
488    pub directives: Vec<DirectiveInfo>,
489}
490
491impl VisualMap {
492    pub fn num_rows(&self) -> usize {
493        self.rows.len()
494    }
495
496    /// The width of `row` in display columns — the rightmost column its caret
497    /// can occupy, and so what a goal column is clamped to on the way in.
498    pub fn row_width(&self, row: usize) -> usize {
499        self.rows.get(row).map_or(0, |r| r.width())
500    }
501
502    /// The screen `(row, col)` for a source offset — where to draw the caret:
503    /// the *nearest* stop at or past `off`. Snaps a hidden offset (inside a
504    /// delimiter) to the next visible glyph, and never resolves onto decoration
505    /// (a table border, a cell's padding), which is drawn but holds no caret.
506    ///
507    /// "Nearest" rather than "the first one found" because a table's wrapped
508    /// cells put rows slightly out of offset order: scanning top to bottom, the
509    /// second line of column 1 comes *after* the first line of column 2 but
510    /// holds smaller offsets. Where rows are in order the two rules agree.
511    ///
512    /// A soft wrap is the one place two rows want the same offset: the row above
513    /// ends where the row below opens, the space the wrap ate being drawn on the
514    /// row above and the offset past it being the row below's first character.
515    /// It resolves *downstream*, to the row that character is on — the row
516    /// above's last column is a phantom, a place the caret can be drawn but
517    /// never sent, and resolving upstream into it is what pinned Down at the
518    /// first wrap of a paragraph: it aimed at the row below's column 0, landed
519    /// on the offset it already had, and read that back as the row above's end.
520    pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
521        let mut best: Option<(usize, usize, usize)> = None; // (src, row, col)
522        for (r, row) in self.rows.iter().enumerate() {
523            if row.decoration {
524                continue;
525            }
526            // Offsets ascend *within* a row, so its first stop at or past `off`
527            // is the best this row has to offer.
528            let cand = row
529                .glyphs
530                .iter()
531                .enumerate()
532                .find(|(_, g)| g.stop && g.src >= off)
533                .map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
534                .or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
535            if let Some(c) = cand {
536                // `<=`, so a tie goes to the later row: the only offset two rows
537                // both hold is a wrap boundary, and it belongs to the row below.
538                if best.is_none_or(|b| c.0 <= b.0) {
539                    best = Some(c);
540                }
541            }
542            // A row's *first* stop never decreases from one row to the next —
543            // true even across a table's wrapped cells, since a cell's lines run
544            // downward. So once a row opens past the best found so far, no later
545            // row can beat it and the scan stays proportional to `off`.
546            if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
547                && first.src > b.0
548            {
549                break;
550            }
551        }
552        match best {
553            Some((_, r, c)) => (r, c),
554            None => {
555                let r = self.last_stop_row();
556                (r, self.row_width(r))
557            }
558        }
559    }
560
561    /// The rows a source range occupies, inclusive: `(first, last)`.
562    ///
563    /// A *different question* from [`pos_of_offset`](Self::pos_of_offset), which
564    /// is why it can't be spelled with two calls to it. That one answers "where
565    /// does the caret go", and for a caret its forward snap is right — an offset
566    /// inside a hidden delimiter has no column of its own, so the caret belongs
567    /// at the next visible glyph, wherever that turns out to be. This one asks
568    /// "which rows does this block cover", and there the snap is a trap: a
569    /// footnote whose body *ends* in a link (`[^2]: [title](url)`) has a last
570    /// byte inside the hidden destination, so `pos_of_offset(end - 1)` walked
571    /// clean off the note's row and landed on the next note's — and a peek
572    /// slicing `first..=last` out of the frame drew two notes where the reader
573    /// asked for one. Every block ending in a link, an image, or any trailing
574    /// hidden markup had the same fault; only a block ending in visible text
575    /// (which is what the tests happened to use) did not.
576    ///
577    /// `row.end_src` is no help either: it is where the *rendered* text of a row
578    /// ends, not how far into the source the block reaches, and redefining it
579    /// would move every end-of-line caret.
580    ///
581    /// So the last row is found by asking which rows *open* before the range
582    /// does, rather than by mapping its last byte: a row belongs to the range
583    /// when its first caret stop lies before `range.end`. Decoration is skipped
584    /// (a drawn gap between blocks is not part of either), and the answer is
585    /// never shorter than one row — a range whose every byte is hidden still
586    /// covers the row it started on.
587    pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
588        if self.rows.is_empty() {
589            return (0, 0);
590        }
591        let first = self.pos_of_offset(range.start).0;
592        let mut last = first;
593        for (r, row) in self.rows.iter().enumerate().skip(first) {
594            if row.decoration {
595                continue;
596            }
597            let open = row
598                .glyphs
599                .iter()
600                .find(|g| g.stop)
601                .map_or(row.end_src, |g| g.src);
602            if open >= range.end {
603                // A row's first stop never decreases from one row to the next —
604                // the invariant `pos_of_offset` breaks on, true even across a
605                // table's wrapped cells — so nothing below can be in range.
606                break;
607            }
608            last = r;
609        }
610        (first, last)
611    }
612
613    /// The source offset of the task checkbox drawn at `(row, col)`, or `None`
614    /// when that cell holds no box — the hit-test a frontend runs on a click
615    /// before treating it as a tick rather than a caret placement.
616    ///
617    /// Only the box's own cells answer. Clicking an item's *text* places the
618    /// caret like any other click, so the box is a target aimed at rather than
619    /// something tripped over while editing — which is also why this is a
620    /// separate question from [`offset_of_pos`](Self::offset_of_pos) instead of
621    /// a flag on the offset it returns.
622    pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
623        let r = self.rows.get(row)?;
624        self.task_box_at_glyph(row, r.glyph_at_col(col)?)
625    }
626
627    /// [`task_box_at`](Self::task_box_at) keyed by glyph index rather than
628    /// display column — for a frontend that shapes its own rows (the GUI) and so
629    /// resolves a click to a glyph before it ever has a column.
630    pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
631        let r = self.rows.get(row)?;
632        r.task?;
633        let g = r.glyphs.get(glyph)?;
634        (g.style.role == Role::ListMarker).then_some(g.src)
635    }
636
637    /// The source offset for a screen `(row, col)` — where a click or a
638    /// visual-space move lands the caret. Clicking decoration maps through its
639    /// `src`, which points at the text it decorates, so a click on a border or
640    /// on a cell's padding lands in that cell.
641    ///
642    /// The inverse of [`pos_of_offset`](Self::pos_of_offset), which it has to
643    /// agree with: `col` is a display column, and the one it names may be the
644    /// far cell of a wide glyph — [`VRow::glyph_at_col`] is where that lands.
645    pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
646        let Some(r) = self.rows.get(row) else {
647            // A click or drag below the last row — a short document with empty
648            // space under it, dragged into to extend a selection. Land on the
649            // document's last caret stop (its end), not offset 0: jumping the
650            // caret to the top is the wrong direction, and 0 isn't even a stop
651            // when the document opens on hidden frontmatter or a `# ` marker, so
652            // returning it would leave the caret where it draws in one place and
653            // types in another (`move_to` would then clamp it onto the unhomeable
654            // frontmatter floor). `None` only for a document with no stops at all
655            // (empty), where the caret has nowhere to be but 0.
656            return self.stops.last().copied().unwrap_or(0);
657        };
658        match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
659            // A glyph that holds no caret is clickable, but where it points
660            // isn't always somewhere the caret can be: the blank gap between two
661            // paragraphs stands at an offset that belongs to neither of them,
662            // and the tail of a grapheme cluster stands inside a character.
663            // Land on the nearest real stop instead of handing back an offset
664            // that looks like the gap but types into the paragraph above.
665            Some(g) if !g.stop => self.nearest_stop(g.src),
666            Some(g) => g.src,
667            // A row's end is a stop by construction — unless the row is
668            // decoration, which contributes none.
669            None if r.decoration => self.nearest_stop(r.end_src),
670            None => r.end_src,
671        }
672    }
673
674    /// Which of a block media's two caret homes `off` is, or `None` for every
675    /// other offset in the document.
676    ///
677    /// [`block_media`](Builder::block_media) gives a block-level image, video, or
678    /// audio exactly two stops — one in front of it and one just past it — and
679    /// nothing inside the markup. Both are ordinary offsets to everything else in
680    /// core, but they are the two places where inserting text would *dissolve the
681    /// picture*: `![](p.png)` with anything typed against it is no longer a block
682    /// image but a paragraph with an inline one, and the frontend that was
683    /// painting a photo there paints a text run instead. A caller that is about to
684    /// insert asks this so it can open a paragraph first — see
685    /// [`Doc::insert`](crate::Doc::insert).
686    ///
687    /// An *inline* image reports `None`: it has no placeholder row and no stops of
688    /// its own, and typing beside one is ordinary editing.
689    ///
690    /// Answers with the media's own source span as well, since a caller that has
691    /// to keep the picture whole usually has to address it — [`Doc::backspace`]
692    /// takes the picture out in one piece rather than nibbling a byte off its
693    /// markup, which is the same dissolution from the other side.
694    ///
695    /// [`Doc::backspace`]: crate::Doc::backspace
696    pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
697        for m in &self.media {
698            let Some(row) = self.rows.get(m.rows_span.start) else {
699                continue;
700            };
701            // Every glyph of the `🖼 alt` label maps to the media's start offset;
702            // the row's end is past its markup. Read the start off the label
703            // rather than the first glyph, which on a quoted or listed picture is
704            // the block prefix and points at the gutter.
705            let Some(start) = row
706                .glyphs
707                .iter()
708                .find(|g| g.style.role == Role::Image)
709                .map(|g| g.src)
710            else {
711                continue;
712            };
713            if off == start {
714                return Some((MediaStop::Before, start..row.end_src));
715            }
716            if off == row.end_src {
717                return Some((MediaStop::After, start..row.end_src));
718            }
719        }
720        None
721    }
722
723    /// Snap `off` to the nearest caret stop — the funnel a frontend that
724    /// hit-tests pixels straight to a source offset must run its result through.
725    /// A click or drag can land in the blank gap a paragraph break is drawn with,
726    /// or inside a hidden delimiter; both are offsets the caret can't rest at, so
727    /// resting there would draw the caret in one place and type in another. This
728    /// settles it on a real caret home instead. Idempotent on an offset that is
729    /// already a stop — the `(row, col)` click path already snaps this way inside
730    /// [`offset_of_pos`](Self::offset_of_pos), and this gives the pixel path the
731    /// same guarantee. Returns `off` unchanged only for an empty document (no
732    /// stops at all).
733    pub fn snap_to_stop(&self, off: usize) -> usize {
734        self.nearest_stop(off)
735    }
736
737    /// The caret stop nearest `off`, preferring the one before it when `off`
738    /// falls exactly between two. Returns `off` unchanged if there are no stops
739    /// at all (an empty document). A mark's content end counts: it is a place
740    /// the caret rests, and the one a drag ending on a marked word means.
741    fn nearest_stop(&self, off: usize) -> usize {
742        let before = Self::last_at_or_before(&self.stops, off)
743            .max(Self::last_at_or_before(&self.mark_ends, off));
744        let after = match (
745            Self::first_at_or_after(&self.stops, off),
746            Self::first_at_or_after(&self.mark_ends, off),
747        ) {
748            (Some(a), Some(b)) => Some(a.min(b)),
749            (a, b) => a.or(b),
750        };
751        match (before, after) {
752            (Some(b), Some(a)) if off - b <= a - off => b,
753            (_, Some(a)) => a,
754            (Some(b), None) => b,
755            (None, None) => off,
756        }
757    }
758
759    /// The glyph stop nearest `off` — [`nearest_stop`](Self::nearest_stop)
760    /// for a walk that pairs stops with characters, which a mark's content
761    /// end has none of. A caret resting on one resolves to the glyph stop
762    /// drawn at the same spot, the one just past the hidden delimiter, so the
763    /// text a system input is shown from there and the steps it counts agree.
764    pub fn snap_to_glyph_stop(&self, off: usize) -> usize {
765        if self.mark_ends.binary_search(&off).is_ok()
766            && let Some(next) = Self::first_at_or_after(&self.stops, off)
767        {
768            return next;
769        }
770        let before = Self::last_at_or_before(&self.stops, off);
771        let after = Self::first_at_or_after(&self.stops, off);
772        match (before, after) {
773            (Some(b), Some(a)) if off - b <= a - off => b,
774            (_, Some(a)) => a,
775            (Some(b), None) => b,
776            (None, None) => off,
777        }
778    }
779
780    /// The last of `sorted` at or before `off`, if any.
781    fn last_at_or_before(sorted: &[usize], off: usize) -> Option<usize> {
782        let i = sorted.partition_point(|&s| s <= off);
783        i.checked_sub(1).map(|i| sorted[i])
784    }
785
786    /// The first of `sorted` at or after `off`, if any.
787    fn first_at_or_after(sorted: &[usize], off: usize) -> Option<usize> {
788        let i = sorted.partition_point(|&s| s < off);
789        sorted.get(i).copied()
790    }
791
792    /// The next place the caret rests past `off` — the next glyph stop or the
793    /// next mark's content end, whichever comes first. What Right walks:
794    /// leaving `**bold**` from the `d` is two presses, one onto the end of the
795    /// bold (still bold, the toolbar lit) and one past its delimiter, at the
796    /// same spot on screen. [`stop_after`](Self::stop_after) is the walk that
797    /// skips the first, for every caller that pairs stops with characters.
798    pub fn caret_stop_after(&self, off: usize) -> Option<usize> {
799        match (
800            self.stop_after(off),
801            Self::first_at_or_after(&self.mark_ends, off + 1),
802        ) {
803            (Some(a), Some(b)) => Some(a.min(b)),
804            (a, b) => a.or(b),
805        }
806    }
807
808    /// The previous place the caret rests before `off` — the mirror of
809    /// [`caret_stop_after`](Self::caret_stop_after), what Left walks.
810    pub fn caret_stop_before(&self, off: usize) -> Option<usize> {
811        self.stop_before(off).max(
812            off.checked_sub(1)
813                .and_then(|o| Self::last_at_or_before(&self.mark_ends, o)),
814        )
815    }
816
817    /// Whether the caret can occupy `row` at all: decoration rows (a table's
818    /// border rules) are stepped over by vertical motion.
819    pub fn row_is_navigable(&self, row: usize) -> bool {
820        self.rows.get(row).is_some_and(|r| !r.decoration)
821    }
822
823    /// The first offset the caret can rest at on `row` — its first stop, or the
824    /// row's own end when it holds no text (an empty paragraph). `None` for a
825    /// decoration row, which holds no caret at all.
826    ///
827    /// Not `offset_of_pos(row, 0)`: column 0 of a quoted or listed row is the
828    /// gutter, and a gutter's `src` points at the *block* it opens, so the stop
829    /// nearest it is the one on the block's first row rather than on this one.
830    /// Which is right for a click — the gutter decorates the whole block — and
831    /// wrong for Home, whose whole question is where *this* row starts.
832    pub fn row_start(&self, row: usize) -> Option<usize> {
833        let r = self.rows.get(row).filter(|r| !r.decoration)?;
834        Some(
835            r.glyphs
836                .iter()
837                .find(|g| g.stop)
838                .map_or(r.end_src, |g| g.src),
839        )
840    }
841
842    /// The last row the caret can rest on — the fallback when an offset is past
843    /// everything rendered (a table's bottom border must not swallow the caret).
844    fn last_stop_row(&self) -> usize {
845        (0..self.rows.len())
846            .rev()
847            .find(|&r| self.row_is_navigable(r))
848            .unwrap_or(0)
849    }
850
851    /// The nearest row above `row` the caret can occupy, skipping decoration.
852    pub fn navigable_above(&self, row: usize) -> Option<usize> {
853        (0..row.min(self.rows.len()))
854            .rev()
855            .find(|&r| self.row_is_navigable(r))
856    }
857
858    /// The nearest row below `row` the caret can occupy, skipping decoration.
859    pub fn navigable_below(&self, row: usize) -> Option<usize> {
860        ((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
861    }
862
863    /// The caret stop just before `off` — one press of Left. `None` at the
864    /// first stop in the document.
865    ///
866    /// Runs of decoration (a table border, a cell's alignment padding) are
867    /// stepped over in a single press: they hold no stop, so they aren't in the
868    /// table to land on.
869    pub fn stop_before(&self, off: usize) -> Option<usize> {
870        let i = self.stops.partition_point(|&s| s < off);
871        i.checked_sub(1).map(|i| self.stops[i])
872    }
873
874    /// The caret stop just after `off` — one press of Right. `None` at the last
875    /// stop in the document.
876    pub fn stop_after(&self, off: usize) -> Option<usize> {
877        let i = self.stops.partition_point(|&s| s <= off);
878        self.stops.get(i).copied()
879    }
880
881    /// The first caret stop at or past `off` — where the caret at a hidden
882    /// offset is *drawn*, and so where a rightward walk over the rendered text
883    /// starts from.
884    pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
885        let i = self.stops.partition_point(|&s| s < off);
886        self.stops.get(i).copied()
887    }
888
889    /// The last caret stop at or before `off` — where a leftward walk starts
890    /// from. Snapping the way the walk is headed, rather than always forward,
891    /// is what keeps a leftward motion from ever moving the caret right.
892    pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
893        let i = self.stops.partition_point(|&s| s <= off);
894        i.checked_sub(1).map(|i| self.stops[i])
895    }
896
897    /// Whether the caret may rest at `off` — the invariant every motion in this
898    /// view has to leave standing. A glyph stop, a row's end, or a hidden
899    /// mark's content end ([`mark_ends`](Self::mark_ends)).
900    pub fn is_stop(&self, off: usize) -> bool {
901        self.stops.binary_search(&off).is_ok() || self.mark_ends.binary_search(&off).is_ok()
902    }
903
904    /// The visible text a caret crosses walking rightward from `from` up to
905    /// (but not including) `to` — `UITextInput.text(in:)`'s `[from, to)` in
906    /// *this* view. A hidden inline-mark delimiter (`**`, `` ` ``, `_`, an
907    /// escape backslash) never got a glyph in the first place — see
908    /// [`push_text`]/[`synth`] — so it contributes nothing; what's left is
909    /// exactly what's drawn on screen for that span.
910    ///
911    /// Built from the same stop glyphs [`stop_after`](Self::stop_after) steps
912    /// across (every glyph with [`Glyph::stop`] set, i.e. one per grapheme
913    /// cluster, decoration excluded) — **plus one inserted `'\n'` for every
914    /// genuine block boundary strictly inside `[from, to)`**: a run of whole
915    /// [`decoration`] rows sitting between two content rows — a paragraph
916    /// gap, a table rule, an image's reserved filler rows — never an ordinary
917    /// soft wrap, which puts no decoration *row* between the two halves of
918    /// its one paragraph (only inline decoration glyphs, e.g. a table's `│`,
919    /// live inside a single content row, and never split one).
920    ///
921    /// [`decoration`]: VRow::decoration
922    ///
923    /// Without that inserted break, two blocks abutting in this string were
924    /// indistinguishable from one run of text: [`collect_stops`] gives a
925    /// block boundary *zero* stops of its own (crossing one is a single,
926    /// free hop — see `the_caret_skips_the_gap_between_two_paragraphs` in
927    /// `doc.rs`'s tests, which pins that as intentional caret behaviour, a
928    /// paragraph gap costing no extra Right presses, not a bug to fix here).
929    /// So the last word of one paragraph and the first word of the next used
930    /// to land directly adjacent with *nothing* between them in this string
931    /// (`"...edb\n\nhello\n"` read back as `"edbhello"`), and `UITextInput`'s
932    /// default word tokenizer then saw one unbroken run of letters and
933    /// selected across the boundary — reported as double-tapping the last
934    /// word on a line expanding the selection into the following
935    /// paragraph(s).
936    ///
937    /// This means the once-strict equality with `distance_offset`/
938    /// `step_offset` (`leaf-ffi`) no longer always holds: those intentionally
939    /// keep costing a block boundary *zero* stops, while this text now
940    /// spends one *character* on it that is never itself a stop. So the
941    /// relationship is `visible_text(a, b).chars().count() >=
942    /// distance_offset(a, b)`, equality holding whenever `(a, b)` spans no
943    /// block boundary (the common case, and the only case the previous
944    /// equality was ever tested against). It can only ever be *greater*,
945    /// never less: every character this function omits relative to a plain
946    /// stop count is a stop with no glyph of its own (a hidden delimiter, or
947    /// a block's own trailing "end of row" stop), and every such omission at
948    /// a block's end is exactly paired with the one inserted separator that
949    /// follows it, so nothing this function returns is ever short of what a
950    /// consumer walking stops one at a time would need. That inequality is
951    /// still exactly what `UITextInput`'s tokenizer needs: it only ever reads
952    /// this string to find a boundary and converts the character index it
953    /// finds back to a position with `position(from:offset:)`, which walks
954    /// stops — an inserted separator is never handed back as one, it only
955    /// keeps two paragraphs' words apart for the tokenizer's letter-run scan.
956    ///
957    /// `from` is snapped to its nearest stop first, exactly as a caret asked
958    /// to stand at a hidden offset is drawn at the next stop instead; `to` is
959    /// left as given, so a stop landing exactly on it is still the walk's
960    /// last step — the same asymmetry `distance_offset`'s own loop has.
961    pub fn visible_text(&self, from: usize, to: usize) -> String {
962        self.visible_items(from, to)
963            .into_iter()
964            .map(|(_, ch)| ch.unwrap_or('\n'))
965            .collect()
966    }
967
968    /// The UTF-16 length of `visible_text(from, to)` — what an `NSRange`
969    /// location into that text is, without building the string.
970    ///
971    /// AppKit's `NSTextInputClient` and `NSAccessibility` speak in UTF-16
972    /// units of *the text as the system sees it*, which for leaf is the visible
973    /// text — delimiters hidden. A frontend reporting its selection to the
974    /// system converts each end with this and gets back an index into the
975    /// string `visible_text(0, end)` returns, which is exactly what the system
976    /// will index into.
977    pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
978        self.visible_items(from, to)
979            .into_iter()
980            .map(|(_, ch)| ch.map_or(1, char::len_utf16))
981            .sum()
982    }
983
984    /// The inverse of `visible_utf16_len(0, ·)`: the source offset of the
985    /// visible character a UTF-16 index into `visible_text(0, to)` lands on.
986    ///
987    /// An index inside a surrogate pair resolves to the character that owns
988    /// it; one at or past the end of the text returns `None`, so a caller can
989    /// substitute the document's end stop. A synthetic block separator (the
990    /// `\n` the text spells a boundary with) resolves to the gap offset, which
991    /// is not a stop, so a caller placing a caret there gets it snapped like
992    /// any other hidden offset.
993    pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
994        let mut seen = 0usize;
995        for (src, ch) in self.visible_items(0, to) {
996            let len = ch.map_or(1, char::len_utf16);
997            if index < seen + len {
998                return Some(src);
999            }
1000            seen += len;
1001        }
1002        None
1003    }
1004
1005    /// The items `visible_text` spells, in order: every stop glyph in range
1006    /// keyed by its own source offset (`Some(ch)`), and every block boundary
1007    /// in range keyed by its gap offset (`None`, drawn as `\n`).
1008    fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
1009        let from = self.snap_to_glyph_stop(from);
1010
1011        // Real content: every stop glyph in range, keyed by its own source
1012        // offset (`None` tags it as a genuine character, versus the
1013        // synthetic separators below).
1014        let mut items: Vec<(usize, Option<char>)> = self
1015            .rows
1016            .iter()
1017            .filter(|r| !r.decoration)
1018            .flat_map(|r| r.glyphs.iter())
1019            .filter(|g| g.stop && g.src >= from && g.src < to)
1020            .map(|g| (g.src, Some(g.ch)))
1021            .collect();
1022
1023        // Every structural boundary row contributes a separator; its `end_src`
1024        // is the gap offset itself (never a stop — see
1025        // `place_caret_snaps_out_of_the_blank_gap_between_paragraphs` in
1026        // `doc.rs`) — a source offset like any glyph's, so it merges into the
1027        // same ordering. `None` marks it a synthetic separator rather than a
1028        // real character, tagged distinctly so a query landing exactly on the
1029        // gap offset still opens with its break even with no glyph on either
1030        // side to anchor it to (a range spanning nothing but a bare gap).
1031        let mut boundaries: Vec<usize> = self
1032            .rows
1033            .iter()
1034            // A table rule is also a decoration row, but it is chrome *inside*
1035            // one block. Treating it as a block boundary inserts newlines into
1036            // table cells (for example "Feature" became "F\neature").
1037            .filter(|r| r.boundary.is_some())
1038            .map(|r| r.end_src)
1039            .filter(|&src| src >= from && src < to)
1040            .collect();
1041        boundaries.sort_unstable();
1042        boundaries.dedup();
1043        items.extend(boundaries.into_iter().map(|src| (src, None)));
1044
1045        // Row order matches source order except across a table's wrapped
1046        // cells (see `pos_of_offset`), so sort rather than trust it here too.
1047        // A boundary can't share an offset with a glyph (it's the undrawn gap
1048        // between two blocks' real content), so tie-breaking never arises.
1049        items.sort_by_key(|&(src, _)| src);
1050        items
1051    }
1052}
1053
1054/// Collect the caret stops of a laid-out grid: every stop glyph's offset plus
1055/// every row's end, ascending and deduplicated. Duplicates are the norm rather
1056/// than the exception — a wrapped line's end is the same offset as the next
1057/// line's first glyph — and collapsing them is what makes one press of Left or
1058/// Right cross exactly one stop.
1059fn collect_stops(rows: &[VRow]) -> Vec<usize> {
1060    let mut stops: Vec<usize> = rows
1061        .iter()
1062        .filter(|r| !r.decoration)
1063        .flat_map(|r| {
1064            r.glyphs
1065                .iter()
1066                .filter(|g| g.stop)
1067                .map(|g| g.src)
1068                .chain(std::iter::once(r.end_src))
1069        })
1070        .collect();
1071    stops.sort_unstable();
1072    stops.dedup();
1073    stops
1074}
1075
1076/// Collect every row's [`VRow::mark_ends`] into one ascending, deduplicated
1077/// table — the peer of [`collect_stops`] for the caret's second home at the
1078/// end of a hidden mark. A mark that closes at a row's end coincides with the
1079/// row's own end stop; that offset is in both tables, and harmlessly so.
1080fn collect_mark_ends(rows: &[VRow]) -> Vec<usize> {
1081    let mut ends: Vec<usize> = rows
1082        .iter()
1083        .filter(|r| !r.decoration)
1084        .flat_map(|r| r.mark_ends.iter().copied())
1085        .collect();
1086    ends.sort_unstable();
1087    ends.dedup();
1088    ends
1089}
1090
1091/// Group the rows tagged [`VRow::code`] into one [`CodeBlockInfo`] per maximal
1092/// run — the block-level view a frontend needs to box and scroll each code
1093/// block. Two code blocks are always parted by the blank separator row a block
1094/// boundary is spelled with (never itself a code row), so a contiguous run is
1095/// exactly one block. Derived from the final rows rather than tracked through
1096/// the builder so it comes out right no matter how [`build_cached`] and
1097/// [`build_spliced`] shuffle rows around.
1098fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
1099    let mut blocks = Vec::new();
1100    let mut start: Option<usize> = None;
1101    for (i, row) in rows.iter().enumerate() {
1102        match (row.code, start) {
1103            (true, None) => start = Some(i),
1104            (false, Some(s)) => {
1105                blocks.push(CodeBlockInfo {
1106                    rows_span: s..i,
1107                    lang: rows[s].code_lang.clone(),
1108                });
1109                start = None;
1110            }
1111            _ => {}
1112        }
1113    }
1114    if let Some(s) = start {
1115        blocks.push(CodeBlockInfo {
1116            rows_span: s..rows.len(),
1117            lang: rows[s].code_lang.clone(),
1118        });
1119    }
1120    blocks
1121}
1122
1123/// The value of `node`'s `key` attribute, if it carries one *with* a value. A
1124/// bare attribute (`controls`, `muted`) has a `None` value and so reads as
1125/// absent here — a caller wanting presence-not-value tests the list directly.
1126/// Shared by the media element and `<source>` readers.
1127fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
1128    node.attrs
1129        .iter()
1130        .find(|(k, _)| k == key)
1131        .and_then(|(_, v)| v.clone())
1132}
1133
1134/// Collect one [`MediaInfo`] per row carrying a [`VRow::media`] mark — the
1135/// block-level view a frontend needs to replace each placeholder row with a real
1136/// picture. The mark rides the block's *first* row and names how many rows the
1137/// media reserves ([`MediaMark::rows`]); the rows below it are blank
1138/// [`decoration`](VRow::decoration) fillers that hold the vertical space and no
1139/// caret. So the span runs from the marked row across those fillers. Derived from
1140/// the final rows rather than tracked through the builder so it survives however
1141/// [`build_cached`] and [`build_spliced`] shuffle rows around.
1142fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
1143    rows.iter()
1144        .enumerate()
1145        .filter_map(|(i, row)| {
1146            row.media.as_ref().map(|m| MediaInfo {
1147                rows_span: i..i + m.rows.max(1),
1148                kind: m.kind,
1149                destination: m.destination.clone(),
1150                sources: m.sources.clone(),
1151                alt: m.alt.clone(),
1152                poster: m.poster.clone(),
1153            })
1154        })
1155        .collect()
1156}
1157
1158/// Re-label the drawn block boundaries either side of a block-level media
1159/// placeholder, so the pair a frontend spaces by names the picture.
1160///
1161/// [`BlockClass::from_node_kind`] classifies the node the walk is standing on,
1162/// and a block image is never a node of its own: [`Builder::media_only`] promotes
1163/// its *wrapper* — a `paragraph`, or the `container` a `<video>`/`<audio>`
1164/// arrives as — so the gap above a picture reported [`BlockClass::Paragraph`] and
1165/// the gap above a movie reported [`BlockClass::Directive`], the class a frontend
1166/// paints a tinted panel for. [`BlockClass::Media`] was unreachable in
1167/// consequence: the vocabulary named a kind no frontend could ever be told about.
1168///
1169/// Done as a pass over the finished rows rather than inside the walk because
1170/// only the rows know. The incremental top-level walk carries no node arena at
1171/// all (`nodes: &[]`) and can classify by kind alone, so teaching the wrapper
1172/// promotion to the whole-arena walk would label the full and incremental builds
1173/// differently — the exact drift that walk's own comment forbids. Both builds
1174/// emit the same [`VRow::media`] marks, so both reach the same answer here. The
1175/// [`media_spans`] / [`code_block_spans`] pattern.
1176///
1177/// One gap can be spelled with several rows — [`Builder::emit_separators_before`]
1178/// draws the row that closes the block above and the row that opens the block
1179/// below, with any extra blank source lines navigable between them — and gives
1180/// every one of them the same [`Boundary`]. So the walk crosses those navigable
1181/// blanks and relabels the whole run, stopping at the first row that is neither.
1182fn label_media_boundaries(rows: &mut [VRow]) {
1183    let spans: Vec<Range<usize>> = rows
1184        .iter()
1185        .enumerate()
1186        .filter_map(|(i, row)| row.media.as_ref().map(|m| i..i + m.rows.max(1)))
1187        .collect();
1188    // A row inside one gap: a drawn boundary to relabel, or one of the navigable
1189    // blank lines sitting between two drawn ones. Anything else ends the run.
1190    fn in_gap(row: &VRow) -> bool {
1191        row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
1192    }
1193    for span in spans {
1194        for i in (0..span.start).rev() {
1195            if !in_gap(&rows[i]) {
1196                break;
1197            }
1198            if let Some(b) = rows[i].boundary.as_mut() {
1199                b.below = BlockClass::Media;
1200            }
1201        }
1202        for row in rows.iter_mut().skip(span.end) {
1203            if !in_gap(row) {
1204                break;
1205            }
1206            if let Some(b) = row.boundary.as_mut() {
1207                b.above = BlockClass::Media;
1208            }
1209        }
1210    }
1211}
1212
1213/// Collect one [`DirectiveInfo`] per row carrying a [`VRow::leaf_directive`]
1214/// mark — the block-level view a frontend needs to replace each placeholder row
1215/// with whatever the directive means to it. The peer of [`media_spans`], derived
1216/// from the final rows for the same reason: it survives however [`build_cached`]
1217/// and [`build_spliced`] shuffle rows around.
1218fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
1219    rows.iter()
1220        .enumerate()
1221        .filter_map(|(i, row)| {
1222            row.leaf_directive.as_ref().map(|m| DirectiveInfo {
1223                rows_span: i..i + m.rows.max(1),
1224                name: m.name.clone(),
1225                attrs: m.attrs.clone(),
1226                label: m.label.clone(),
1227            })
1228        })
1229        .collect()
1230}
1231
1232/// The source range of a fenced code block's info string — everything on the
1233/// opening line past the fence (`` ```rust `` → the `rust`). `block_start` is the
1234/// code block node's `span.start`. `None` for an indented code block, which
1235/// opens with no fence to carry one. The range is empty for a fence written
1236/// bare (`` ``` `` alone), which is exactly where a language would be inserted.
1237///
1238/// Shared by the WYSIWYG builder (to label the box) and [`crate::Doc`] (to edit
1239/// the label through a prompt), so the two agree on where the language lives.
1240pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
1241    let rest = source.get(block_start..)?;
1242    let line_len = rest.find('\n').unwrap_or(rest.len());
1243    let line = &rest[..line_len];
1244    // A fence may be indented up to three spaces; past that it opens with a run
1245    // of the same fence character.
1246    let indent = line.len() - line.trim_start().len();
1247    if indent > 3 {
1248        return None;
1249    }
1250    let fence = line[indent..].chars().next()?;
1251    if fence != '`' && fence != '~' {
1252        return None; // an indented block, not a fenced one
1253    }
1254    let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
1255    let info_start = block_start + indent + fence_len;
1256    Some(info_start..block_start + line_len)
1257}
1258
1259/// A fenced code block's language for display: its info string, trimmed, or
1260/// `None` when there's no fence or the fence carries no language. The trimmed
1261/// text is what a frontend labels the box with; [`code_info_span`] is what an
1262/// edit replaces.
1263pub fn code_language(source: &str, block_start: usize) -> Option<String> {
1264    let span = code_info_span(source, block_start)?;
1265    let text = source.get(span)?.trim();
1266    (!text.is_empty()).then(|| text.to_string())
1267}
1268
1269/// A horizontal rule's dash count when the map isn't wrapping to a column grid
1270/// (the GUI, which wraps at pixel width): a fixed, sane width the frontend can
1271/// paint or re-wrap, instead of a runaway count from an unbounded wrap width.
1272const UNWRAPPED_RULE_WIDTH: usize = 40;
1273
1274/// Render the document to a [`VisualMap`]. `wrap` is the column budget for
1275/// word-wrapping (`Some` for the monospace TUI), or `None` to emit one row per
1276/// block — the GUI does its own proportional pixel wrapping over these rows.
1277/// Text and offsets come from the AST (`str` nodes carry the verbatim source
1278/// slice and an exact span), so the original source string isn't needed here.
1279pub fn build(
1280    nodes: &[FlatNode],
1281    source: &str,
1282    wrap: Option<usize>,
1283    preserve_soft: bool,
1284    media_rows: &HashMap<String, usize>,
1285    reveal: Option<Range<usize>>,
1286) -> VisualMap {
1287    let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
1288        return VisualMap::default();
1289    };
1290    let top = top_level(nodes, doc);
1291    let mut b = Builder {
1292        nodes,
1293        source,
1294        wrap: wrap.map(|w| w.max(8)),
1295        rows: Vec::new(),
1296        tables: Vec::new(),
1297        last_off: 0,
1298        stepped_over: 0,
1299        media_rows,
1300        break_glyph: Cell::new(' '),
1301        preserve_soft,
1302        reveal: reveal.clone(),
1303        pending_mark_ends: RefCell::new(Vec::new()),
1304    };
1305    let last_drawn = b.top_blocks(&top);
1306    // The hidden frontmatter's end is the baseline for both the trailing blank
1307    // rows and the caret floor — see [`hidden_prefix_end`]. `top_level` has
1308    // already dropped every `metadata` child, so read it off the arena.
1309    let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
1310    b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
1311    let content_start = top.first().map_or(hidden_end, |&i| nodes[i].span.start);
1312    let stops = collect_stops(&b.rows);
1313    let mark_ends = collect_mark_ends(&b.rows);
1314    label_media_boundaries(&mut b.rows);
1315    let code_blocks = code_block_spans(&b.rows);
1316    let media = media_spans(&b.rows);
1317    let directives = directive_spans(&b.rows);
1318    VisualMap {
1319        rows: b.rows,
1320        content_start,
1321        stops,
1322        mark_ends,
1323        tables: b.tables,
1324        code_blocks,
1325        media,
1326        directives,
1327    }
1328}
1329
1330/// Like [`build`], but reuses a persistent [`BlockCache`] so an edit re-renders
1331/// only the top-level blocks whose source bytes changed *and* marshals only
1332/// those blocks from twig instead of the whole arena.
1333///
1334/// `top` is the document's top-level blocks — twig's `child_spans` of the doc
1335/// root: `(node_id, kind, span)` for each, in order. `fetch_subtree(node_id)`
1336/// marshals one block's subtree (local-indexed, root at 0) and is called *only*
1337/// for a block that missed the cache, i.e. one that actually changed. So a
1338/// keystroke marshals one small subtree, not ~20k nodes. The result is
1339/// byte-for-byte identical to [`build`] on the same document (the
1340/// `build_cached_matches_build` test pins this); [`build`] stays the cache-free,
1341/// whole-arena reference. This is the entry point [`crate::Doc`] uses.
1342// One builder, and every one of these is a distinct input to the same layout
1343// pass — a struct of them would be built at the one call site and unpacked
1344// here, which is the same arguments with an extra name in the way.
1345#[allow(clippy::too_many_arguments)]
1346pub fn build_cached(
1347    top: &[QueryMatch],
1348    source: &str,
1349    wrap: Option<usize>,
1350    preserve_soft: bool,
1351    media_rows: &HashMap<String, usize>,
1352    reveal: Option<Range<usize>>,
1353    cache: &mut BlockCache,
1354    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1355) -> VisualMap {
1356    let wrap = wrap.map(|w| w.max(8));
1357
1358    // Wrapping is a function of the width, so a width change makes every cached
1359    // row's wrap wrong: start the cache over.
1360    if cache.wrap != Some(wrap) {
1361        cache.entries.clear();
1362        cache.wrap = Some(wrap);
1363    }
1364    cache.generation = cache.generation.wrapping_add(1);
1365
1366    // Frontmatter (a leading `metadata` block) is document metadata, not prose:
1367    // hidden in the rich view exactly as [`Builder::blocks`] skips it.
1368    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1369
1370    // The outer builder only accumulates rows/tables and spells block boundaries
1371    // — both a function of the source and `last_off`, never of a node array — so
1372    // it carries an empty `nodes`. Each changed block is rendered by a *fresh*
1373    // builder over that block's subtree.
1374    let mut b = Builder {
1375        nodes: &[],
1376        source,
1377        wrap,
1378        rows: Vec::new(),
1379        tables: Vec::new(),
1380        last_off: 0,
1381        stepped_over: 0,
1382        media_rows,
1383        break_glyph: Cell::new(' '),
1384        preserve_soft,
1385        reveal: reveal.clone(),
1386        pending_mark_ends: RefCell::new(Vec::new()),
1387    };
1388
1389    // Record the per-block row decomposition as we go, so a later
1390    // [`build_spliced`] can patch one block without rebuilding the map.
1391    let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
1392    let mut all_shift_safe = true;
1393    // The class of the last block that drew anything: what the next separator
1394    // closes, and what the trailing blank lines close at the end. A hidden block
1395    // (a comment) never becomes it — see [`Builder::block_or_hidden`], whose
1396    // step-over this loop repeats for the incremental walk.
1397    let mut above: Option<BlockClass> = None;
1398    for block in &blocks {
1399        let start = block.span.start;
1400        let before_sep = b.rows.len();
1401        if let Some(above) = above {
1402            // This walker has no node arena at all (see the `nodes: &[]` above),
1403            // but a top-level query match carries its kind — the same string
1404            // `BlockClass::from_node_kind` classifies for the whole-arena walk, so
1405            // the incremental and full builds label a boundary identically.
1406            b.emit_separators_before(
1407                start,
1408                &[],
1409                true,
1410                Boundary {
1411                    above,
1412                    below: BlockClass::from_node_kind(&block.kind),
1413                },
1414            );
1415        }
1416        let after_sep = b.rows.len();
1417        let bytes = block_bytes(source, &block.span);
1418        let hash = block_hash(bytes);
1419        // How this block meets the reveal line, if at all — part of its cache
1420        // key, since the same bytes render differently on the caret's line.
1421        let rkey = reveal_key(&reveal, &block.span);
1422
1423        // Hit: clone the block's rows shifted to its current offset and restore
1424        // the (shifted) `last_off` so the next separator lands right — no marshal.
1425        // Only shift-safe blocks are ever cached, so a hit is safe by construction.
1426        if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
1427            let delta = start as isize - hit.built_start as isize;
1428            for row in &hit.rows {
1429                b.rows.push(shift_row(row, delta));
1430            }
1431            b.last_off = (hit.last_off as isize + delta) as usize;
1432        } else {
1433            // Miss: marshal just this block's subtree and render it. A subtree is
1434            // self-contained with local ids (root at 0) and absolute spans, so a
1435            // fresh builder over it produces the same rows the whole-arena path
1436            // would. An empty subtree (twig couldn't hand it back) renders nothing.
1437            let subtree = fetch_subtree(block.node_id);
1438            if !subtree.is_empty() {
1439                let mut sub = Builder {
1440                    nodes: &subtree,
1441                    source,
1442                    wrap,
1443                    rows: Vec::new(),
1444                    tables: Vec::new(),
1445                    last_off: 0,
1446                    stepped_over: 0,
1447                    media_rows,
1448                    break_glyph: Cell::new(' '),
1449                    preserve_soft,
1450                    reveal: reveal.clone(),
1451                    pending_mark_ends: RefCell::new(Vec::new()),
1452                };
1453                sub.block(0, &[], &[]);
1454                // A block that drew nothing is stepped over, not stood on: its
1455                // `last_off` is its own end, so the separator after it counts
1456                // from there. The sub-builder started at 0 and never moved, and
1457                // 0 is where the next separator would otherwise count from —
1458                // every line of the document, as a blank row each.
1459                let last_off = if sub.rows.is_empty() {
1460                    block.span.end
1461                } else {
1462                    sub.last_off
1463                };
1464                // Cache only a block that is table-free AND renders inside its own
1465                // span: those two are the conditions for reuse-by-shift to be
1466                // correct. A block failing either is re-rendered every build (a
1467                // fresh render always matches a fresh whole-document build).
1468                if sub.tables.is_empty() {
1469                    if rows_within(&sub.rows, &block.span) {
1470                        cache.store(hash, bytes, start, sub.rows.clone(), last_off, rkey);
1471                    }
1472                    b.rows.extend(sub.rows);
1473                } else {
1474                    // A table block is never cached; rebase its row-index
1475                    // bookkeeping onto the combined row vector and append.
1476                    let base = b.rows.len();
1477                    for t in &mut sub.tables {
1478                        t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
1479                    }
1480                    b.rows.extend(sub.rows);
1481                    b.tables.extend(sub.tables);
1482                }
1483                b.last_off = last_off;
1484            }
1485        }
1486        let content_rows = b.rows.len() - after_sep;
1487        let sep_rows = if content_rows == 0 {
1488            // Hidden: take back the separator drawn for it, so what stands
1489            // either side meets across one boundary. Its layout entry stays, at
1490            // no rows, so the splice arithmetic still counts one entry per block.
1491            b.rows.truncate(before_sep);
1492            // A cache hit restored the stored `last_off` above; an empty subtree
1493            // (twig couldn't hand it back) restored nothing. Either way the walk
1494            // stands past the block.
1495            b.last_off = b.last_off.max(block.span.end);
1496            b.stepped_over = b.stepped_over.max(block.span.end);
1497            0
1498        } else {
1499            above = Some(BlockClass::from_node_kind(&block.kind));
1500            all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
1501            after_sep - before_sep
1502        };
1503        layout_blocks.push(BlockLayout {
1504            span: block.span.clone(),
1505            kind: block.kind.clone(),
1506            sep_rows,
1507            content_rows,
1508        });
1509    }
1510
1511    let before_trailing = b.rows.len();
1512    let hidden_end = hidden_prefix_end(
1513        source,
1514        top.iter()
1515            .filter(|m| m.kind == Kind::Metadata)
1516            .map(|m| m.span.end)
1517            .next_back(),
1518    );
1519    b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
1520    let trailing_rows = b.rows.len() - before_trailing;
1521
1522    // Evict every entry no block reused this build, so the cache tracks the
1523    // current document instead of growing without bound over a session.
1524    let g = cache.generation;
1525    cache.entries.retain(|_, bucket| {
1526        bucket.retain(|e| e.generation == g);
1527        !bucket.is_empty()
1528    });
1529
1530    cache.layout = Layout {
1531        blocks: layout_blocks,
1532        trailing_rows,
1533        built_len: source.len(),
1534        has_tables: !b.tables.is_empty(),
1535        all_shift_safe,
1536        reveal: reveal.clone(),
1537    };
1538
1539    // The first rendered offset is the first non-metadata block's start — the
1540    // analogue of [`first_content_offset`] for the top-level list. With nothing
1541    // but frontmatter it's the end of that frontmatter, and 0 for an empty
1542    // document ([`hidden_prefix_end`]).
1543    let content_start = blocks.first().map_or(hidden_end, |m| m.span.start);
1544    let stops = collect_stops(&b.rows);
1545    let mark_ends = collect_mark_ends(&b.rows);
1546    label_media_boundaries(&mut b.rows);
1547    let code_blocks = code_block_spans(&b.rows);
1548    let media = media_spans(&b.rows);
1549    let directives = directive_spans(&b.rows);
1550    VisualMap {
1551        rows: b.rows,
1552        content_start,
1553        stops,
1554        mark_ends,
1555        tables: b.tables,
1556        code_blocks,
1557        media,
1558        directives,
1559    }
1560}
1561
1562/// The fast path for a single-block edit: patch the previous [`VisualMap`] in
1563/// place rather than reassembling it. Returns `Some(new_map)` when it applies,
1564/// or `None` to tell the caller to fall back to [`build_cached`] (always
1565/// correct). Consumes `prev` either way — on `None` the caller rebuilds from
1566/// scratch and doesn't need it.
1567///
1568/// It applies only when `dirty` (twig's dirty byte range) falls inside exactly
1569/// one top-level block AND the block structure around it is unchanged — verified
1570/// by matching the new `top` list against the previous [`Layout`] block for
1571/// block: kinds unchanged, spans before the edit identical, spans after it
1572/// shifted by the byte delta, count unchanged. Any deviation — a block split or
1573/// merged, a fence opened to swallow later blocks, a table anywhere, a
1574/// multi-block edit — fails the match and returns `None`. That check is what
1575/// makes the byte-range trustworthy: twig's dirty range is exact about *bytes*
1576/// but silent about *reparse*, and the structural match catches the reparse
1577/// effects it can't see.
1578///
1579/// When it applies, the unchanged prefix rows move verbatim, the suffix rows
1580/// shift by the delta *in place* (integer adds, no glyph copy), and only the one
1581/// dirty block is re-marshalled and re-rendered; stops splice the same way by
1582/// offset. So the cost is O(rows after the edit), and nothing before the edit is
1583/// touched. The hash-keyed entry cache is left alone — a later [`build_cached`]
1584/// will miss on the changed block, re-render it, and evict the stale entry, so
1585/// chained splices neither corrupt nor grow it.
1586// One builder, and every one of these is a distinct input to the same layout
1587// pass — a struct of them would be built at the one call site and unpacked
1588// here, which is the same arguments with an extra name in the way.
1589#[allow(clippy::too_many_arguments)]
1590pub fn build_spliced(
1591    prev: VisualMap,
1592    source: &str,
1593    wrap: Option<usize>,
1594    preserve_soft: bool,
1595    top: &[QueryMatch],
1596    dirty: Range<usize>,
1597    media_rows: &HashMap<String, usize>,
1598    reveal: Option<Range<usize>>,
1599    cache: &mut BlockCache,
1600    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1601) -> Option<VisualMap> {
1602    let wrap = wrap.map(|w| w.max(8));
1603    // A width change invalidates every cached row — a full rebuild's job.
1604    if cache.wrap != Some(wrap) {
1605        return None;
1606    }
1607    // So does a moved reveal line, and for the same reason: this path reuses
1608    // every row outside the dirty block, and those rows encode which line was
1609    // showing its raw markup when they were built. Typing almost always moves
1610    // the caret, so under `MarkupMode::Full` this bails to `build_cached` on
1611    // most keystrokes — still block-cached, so only the edited block and the
1612    // revealed one actually re-render.
1613    if cache.layout.reveal != reveal {
1614        return None;
1615    }
1616    // Take the previous layout; on any bail below the caller rebuilds it (and the
1617    // map) via `build_cached`, so leaving it empty is fine. A table or a block
1618    // that renders outside its span (a degenerate inline span) makes shifting
1619    // unsound, so those force the full-rebuild path.
1620    let prev_layout = std::mem::take(&mut cache.layout);
1621    if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
1622        return None;
1623    }
1624    // The layout addresses `prev` by row index, so it is only usable against the
1625    // map it was built from. A frontend is free to hold the map it was handed and
1626    // present it differently — leaf-ratatui splices blank filler rows under an
1627    // oversized heading so the raster has somewhere to stand — and if one of those
1628    // comes back here the row arithmetic below lands on the wrong rows: the
1629    // re-rendered block is laid over a filler and the rows it really occupied
1630    // survive into the suffix, stranding a stale copy of the edited line and
1631    // pushing everything after it one row down, once per keystroke. A row count
1632    // that doesn't match what this layout describes is the tell, and the honest
1633    // answer is the full rebuild.
1634    let described_rows = prev_layout
1635        .blocks
1636        .iter()
1637        .map(|pl| pl.sep_rows + pl.content_rows)
1638        .sum::<usize>()
1639        + prev_layout.trailing_rows;
1640    if described_rows != prev.rows.len() {
1641        return None;
1642    }
1643
1644    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1645    if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
1646        return None;
1647    }
1648    let delta = source.len() as isize - prev_layout.built_len as isize;
1649
1650    // The single block whose NEW span contains the whole dirty range. A dirty
1651    // range straddling a block boundary (or a separator) finds none → bail.
1652    let k = blocks
1653        .iter()
1654        .position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
1655
1656    // Structural match: every OTHER block is unchanged — same kind throughout,
1657    // span identical before the edit and shifted by `delta` after it. A mismatch
1658    // means the reparse reshaped the block structure, which only a full rebuild
1659    // renders correctly.
1660    for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
1661        if m.kind != pl.kind {
1662            return None;
1663        }
1664        if i == k {
1665            continue;
1666        }
1667        let want = if i < k {
1668            pl.span.clone()
1669        } else {
1670            (pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
1671        };
1672        if m.span != want {
1673            return None;
1674        }
1675    }
1676    // The dirty block itself: start unchanged (the edit is inside it, past its
1677    // start), end moved by exactly the delta.
1678    let pk_start = prev_layout.blocks[k].span.start;
1679    let pk_end = prev_layout.blocks[k].span.end;
1680    let pk_sep = prev_layout.blocks[k].sep_rows;
1681    let pk_content = prev_layout.blocks[k].content_rows;
1682    if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
1683    {
1684        return None;
1685    }
1686
1687    // Re-render the dirty block from its subtree. A table makes the splice
1688    // bookkeeping unsafe, so bail if one appears.
1689    let subtree = fetch_subtree(blocks[k].node_id);
1690    if subtree.is_empty() {
1691        return None;
1692    }
1693    let mut sub = Builder {
1694        nodes: &subtree,
1695        source,
1696        wrap,
1697        rows: Vec::new(),
1698        tables: Vec::new(),
1699        last_off: 0,
1700        stepped_over: 0,
1701        media_rows,
1702        break_glyph: Cell::new(' '),
1703        preserve_soft,
1704        reveal: reveal.clone(),
1705        pending_mark_ends: RefCell::new(Vec::new()),
1706    };
1707    sub.block(0, &[], &[]);
1708    // A table, or content that renders outside the block's span (a degenerate
1709    // inline span), makes the shift bookkeeping unsound — fall back.
1710    if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
1711        return None;
1712    }
1713    let new_content = sub.rows;
1714    let new_content_len = new_content.len();
1715    let new_stops = collect_stops(&new_content);
1716    let new_mark_ends = collect_mark_ends(&new_content);
1717
1718    // Row span of the dirty block's CONTENT. Its leading separator stays in the
1719    // prefix: the gap before block k is unchanged, since k's start didn't move.
1720    let content_start_row: usize = prev_layout.blocks[..k]
1721        .iter()
1722        .map(|pl| pl.sep_rows + pl.content_rows)
1723        .sum::<usize>()
1724        + pk_sep;
1725    let content_end_row = content_start_row + pk_content;
1726
1727    // Splice rows: [prefix | new content | suffix + delta]. The prefix moves
1728    // untouched; the suffix shifts in place — integer adds, no glyph copy.
1729    let mut rows = prev.rows;
1730    let mut suffix = rows.split_off(content_end_row);
1731    rows.truncate(content_start_row);
1732    for row in &mut suffix {
1733        shift_row_in_place(row, delta);
1734    }
1735    rows.reserve(new_content_len + suffix.len());
1736    rows.extend(new_content);
1737    rows.extend(suffix);
1738
1739    // Splice stops by offset. The old dirty block covered `[pk_start, pk_end]`:
1740    // prefix stops fall below it, suffix stops above it (shift by delta), the new
1741    // content supplies the middle. The three ranges stay disjoint and ascending,
1742    // so the result needs no re-sort.
1743    let p1 = prev.stops.partition_point(|&s| s < pk_start);
1744    let p2 = prev.stops.partition_point(|&s| s <= pk_end);
1745    let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
1746    stops.extend_from_slice(&prev.stops[..p1]);
1747    stops.extend(new_stops);
1748    for &s in &prev.stops[p2..] {
1749        stops.push((s as isize + delta) as usize);
1750    }
1751    // The mark ends splice the same way: they are offsets in the same
1752    // coordinates, cut at the same block.
1753    let m1 = prev.mark_ends.partition_point(|&s| s < pk_start);
1754    let m2 = prev.mark_ends.partition_point(|&s| s <= pk_end);
1755    let mut mark_ends = Vec::with_capacity(m1 + new_mark_ends.len() + (prev.mark_ends.len() - m2));
1756    mark_ends.extend_from_slice(&prev.mark_ends[..m1]);
1757    mark_ends.extend(new_mark_ends);
1758    for &s in &prev.mark_ends[m2..] {
1759        mark_ends.push((s as isize + delta) as usize);
1760    }
1761
1762    // Record the patched layout for the next splice: spans move to the new
1763    // coordinates, and the dirty block takes its new content-row count.
1764    let mut new_blocks = prev_layout.blocks;
1765    for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
1766        pl.span = m.span.clone();
1767    }
1768    new_blocks[k].content_rows = new_content_len;
1769    cache.layout = Layout {
1770        blocks: new_blocks,
1771        trailing_rows: prev_layout.trailing_rows,
1772        built_len: source.len(),
1773        has_tables: false,
1774        // Every prefix/suffix block was shift-safe last build (we bailed
1775        // otherwise) and the re-rendered block was just checked, so the patched
1776        // document is still entirely shift-safe.
1777        all_shift_safe: true,
1778        reveal,
1779    };
1780
1781    label_media_boundaries(&mut rows);
1782    let code_blocks = code_block_spans(&rows);
1783    let media = media_spans(&rows);
1784    let directives = directive_spans(&rows);
1785    Some(VisualMap {
1786        rows,
1787        content_start: blocks[0].span.start,
1788        stops,
1789        mark_ends,
1790        tables: Vec::new(),
1791        code_blocks,
1792        media,
1793        directives,
1794    })
1795}
1796
1797/// A persistent, content-keyed cache of the rows each top-level block renders
1798/// to — the [`VisualMap`] analogue of the GUI's ShapedLine cache, one level
1799/// down. Held by a [`crate::Doc`] and threaded into [`build_cached`], it is what
1800/// makes a rebuild after a keystroke cost "re-render the edited block + shift
1801/// the rest" instead of re-rendering the whole document.
1802///
1803/// A top-level block's rows are a pure function of its source bytes and the wrap
1804/// width, so an unchanged block's rows are cloned and their source offsets
1805/// shifted by the edit's byte delta rather than rebuilt glyph by glyph. Two
1806/// things make that purity hold: at the top level the render prefix is always
1807/// empty (nesting prefixes — a quote gutter, a list indent — exist only *inside*
1808/// a top-level block, within its cached unit), and a block's output never reads
1809/// the incoming `last_off` (it writes `last_off` from its own content before any
1810/// nested separator reads it). So the only thing that differs between two
1811/// positions of an unchanged block is a uniform offset shift. Keyed by a fast
1812/// hash of the block's bytes with the bytes kept for a verify-on-hit — exactly
1813/// the shape cache's weak-hash-then-compare, so a collision costs a re-render,
1814/// never a wrong row.
1815///
1816/// Tables are never cached (a block that emits any table row is always rebuilt):
1817/// their rows are cross-referenced from the map's `tables` side-table by row
1818/// index, which a blind offset-shift wouldn't fix up, and they are rare enough
1819/// that the simplicity beats the reuse.
1820#[derive(Default)]
1821pub struct BlockCache {
1822    /// The wrap width every entry was built at; a change invalidates all of
1823    /// them. `None` before the first build (distinct from `Some(None)`, the
1824    /// unwrapped GUI width).
1825    wrap: Option<Option<usize>>,
1826    /// Bumped once per [`build_cached`]. An entry reused or inserted this build
1827    /// carries the current value; stale entries are dropped at the end of it.
1828    generation: u64,
1829    /// `hash(bytes)` → the block(s) sharing that hash — a bucket because
1830    /// distinct blocks can collide, while two *identical* blocks share one entry
1831    /// (free dedup).
1832    entries: HashMap<u64, Vec<CachedBlock>>,
1833    /// The row/stop decomposition of the last build, which [`build_spliced`]
1834    /// patches in place for a single-block edit. Kept in step with whatever
1835    /// [`VisualMap`] was last produced; empty before the first build.
1836    layout: Layout,
1837}
1838
1839/// How the last build's [`VisualMap`] decomposes into top-level blocks — the
1840/// bookkeeping [`build_spliced`] needs to splice one block's rows and stops
1841/// without rebuilding the whole map. Every field describes the *previous* build,
1842/// in that build's coordinates.
1843#[derive(Default)]
1844struct Layout {
1845    /// One entry per rendered (metadata-filtered) top-level block, in order.
1846    blocks: Vec<BlockLayout>,
1847    /// Trailing blank rows past the last block (from `emit_trailing_blank_lines`).
1848    trailing_rows: usize,
1849    /// The source length this layout was built at — the reference for the edit's
1850    /// byte delta.
1851    built_len: usize,
1852    /// Whether the last build drew any table. A table's cross-referenced row
1853    /// indices don't survive a blind splice, so their presence makes
1854    /// [`build_spliced`] bail to a full rebuild.
1855    has_tables: bool,
1856    /// Whether every block rendered strictly inside its own span (see
1857    /// [`rows_within`]). A block that doesn't — a malformed Markdown inline node
1858    /// that twig leaves with a degenerate `0..0` span renders at a fixed offset
1859    /// outside its block — can't be shifted correctly, so its presence makes
1860    /// [`build_spliced`] bail to a full rebuild.
1861    all_shift_safe: bool,
1862    /// The reveal line this layout was built under (see [`Builder::reveal`]).
1863    /// A splice reuses every row it isn't re-rendering, so a reveal line that
1864    /// has moved would leave the old line still showing its delimiters and the
1865    /// new one still hiding them — [`build_spliced`] bails when this changes.
1866    reveal: Option<Range<usize>>,
1867}
1868
1869/// One top-level block's contribution to the last build: its span and kind (for
1870/// the structural match that proves only one block changed) and how many
1871/// separator and content rows it emitted (to locate its slice of the row
1872/// vector).
1873struct BlockLayout {
1874    span: Range<usize>,
1875    kind: Kind,
1876    sep_rows: usize,
1877    content_rows: usize,
1878}
1879
1880/// One cached block: the rows it rendered to, plus what a reuse at a new
1881/// position needs to shift them. Offsets are stored absolute (as built) and
1882/// shifted by `new_start - built_start` on reuse.
1883struct CachedBlock {
1884    /// The block's exact source bytes, compared on a hash hit so a collision
1885    /// can never hand back another block's rows.
1886    bytes: Box<[u8]>,
1887    /// The offset the rows were built at (the block's `span.start`).
1888    built_start: usize,
1889    /// The block's rows, offsets absolute as built.
1890    rows: Vec<VRow>,
1891    /// `last_off` after this block was emitted, absolute as built — restored
1892    /// (shifted) on reuse so the following separator lands correctly.
1893    last_off: usize,
1894    /// Where the reveal line fell *within this block* when the rows were built,
1895    /// as a block-relative byte range — see [`reveal_key`]. Compared alongside
1896    /// `bytes` on a hit, because identical source renders to different rows
1897    /// depending on whether the caret's line is inside it: the same `*em*`
1898    /// shows its asterisks on the revealed line and hides them everywhere else.
1899    ///
1900    /// Block-relative rather than absolute so an unaffected block still hits
1901    /// after an edit shifts it, and `None` for the overwhelmingly common
1902    /// no-reveal case — which is why an entry stored under `MarkupMode::None`
1903    /// keeps hitting for every block that isn't the caret's.
1904    reveal: Option<Range<usize>>,
1905    /// The build that last reused or inserted this entry (see `generation`).
1906    generation: u64,
1907}
1908
1909/// Where `reveal` falls inside a block, in block-relative bytes — the extra key
1910/// a cached block is stored and matched under.
1911///
1912/// `None` when the block doesn't meet the reveal line at all, which is every
1913/// block on every build in the two hidden modes, and all but one of them under
1914/// [`crate::MarkupMode::Full`]. So the cache keeps its hit rate as the caret
1915/// moves: only the line the caret leaves and the line it arrives at re-render.
1916fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
1917    let r = reveal.as_ref()?;
1918    // The same generous intersection test `Builder::revealed` uses, so a block
1919    // is keyed as revealed exactly when its glyphs will be built that way.
1920    (span.start <= r.end && r.start <= span.end).then(|| {
1921        let start = r.start.max(span.start) - span.start;
1922        let end = r.end.min(span.end) - span.start;
1923        start..end
1924    })
1925}
1926
1927impl BlockCache {
1928    /// Look up a block by hash, verify its bytes and reveal key, and on a hit
1929    /// stamp it used this build and hand back a borrow to shift-and-clone from.
1930    /// `None` on a miss (unknown hash, a collision whose bytes differ, or the
1931    /// same bytes built under a different reveal).
1932    fn reuse(
1933        &mut self,
1934        hash: u64,
1935        bytes: &[u8],
1936        reveal: &Option<Range<usize>>,
1937    ) -> Option<&CachedBlock> {
1938        let g = self.generation;
1939        let bucket = self.entries.get_mut(&hash)?;
1940        let e = bucket
1941            .iter_mut()
1942            .find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
1943        e.generation = g;
1944        Some(&*e)
1945    }
1946
1947    /// Cache the rows a freshly-rendered block produced (or refresh an existing
1948    /// entry for the same bytes and reveal — an identical block elsewhere, or a
1949    /// re-render).
1950    fn store(
1951        &mut self,
1952        hash: u64,
1953        bytes: &[u8],
1954        built_start: usize,
1955        rows: Vec<VRow>,
1956        last_off: usize,
1957        reveal: Option<Range<usize>>,
1958    ) {
1959        let g = self.generation;
1960        let bucket = self.entries.entry(hash).or_default();
1961        if let Some(e) = bucket
1962            .iter_mut()
1963            .find(|e| &*e.bytes == bytes && e.reveal == reveal)
1964        {
1965            e.built_start = built_start;
1966            e.rows = rows;
1967            e.last_off = last_off;
1968            e.generation = g;
1969        } else {
1970            bucket.push(CachedBlock {
1971                bytes: bytes.into(),
1972                built_start,
1973                rows,
1974                last_off,
1975                reveal,
1976                generation: g,
1977            });
1978        }
1979    }
1980}
1981
1982/// The source bytes a top-level block covers — the block cache's key material.
1983///
1984/// Clamped to the source rather than sliced by the span as twig gives it,
1985/// because that span can end *past* the last byte: the final block of a document
1986/// with no trailing newline is closed on the virtual newline the parser supplies
1987/// at EOF, so its `span.end` is `source.len() + 1`. Slicing by such a range
1988/// yields `None`, and the obvious `unwrap_or(&[])` reads that as *this block has
1989/// no bytes* — the wrong answer twice over.
1990///
1991/// Two blocks whose spans both overrun then key alike, and the second is served
1992/// the first one's rows. That is not hypothetical: a footnote definition is a
1993/// root beside `doc` merged back into the top level by [`top_blocks`], while the
1994/// `section` above it spans the definition's bytes too, so both end at EOF —
1995/// and a document ending in `[^note]: …` renders that definition as a second
1996/// copy of the heading. Even alone, a block that keeps hashing empty as the user
1997/// types in it is served the stale rows built before the edit.
1998///
1999/// Clamping hands back the bytes the block really covers, which tells both cases
2000/// apart, and costs nothing for a span that was in range to begin with.
2001fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
2002    let bytes = source.as_bytes();
2003    let start = span.start.min(bytes.len());
2004    &bytes[start..span.end.clamp(start, bytes.len())]
2005}
2006
2007/// A fast, allocation-free content hash (FNV-1a) for a block's bytes. Weak by
2008/// design — the bytes are compared on a hit — so its only job is to spread
2009/// blocks across buckets cheaply. SipHash over every block's bytes on every
2010/// keystroke would cost more than it saves, the same lesson the shape cache
2011/// learned when it stopped hashing through the standard hasher.
2012fn block_hash(bytes: &[u8]) -> u64 {
2013    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
2014    for &x in bytes {
2015        h ^= x as u64;
2016        h = h.wrapping_mul(0x0000_0100_0000_01b3);
2017    }
2018    h
2019}
2020
2021/// Clone a cached row with every source offset advanced by `delta` — the whole
2022/// cost of reusing an unchanged block: integer adds where a rebuild would
2023/// re-shape every glyph.
2024fn shift_row(row: &VRow, delta: isize) -> VRow {
2025    let shift = |off: usize| (off as isize + delta) as usize;
2026    VRow {
2027        glyphs: row
2028            .glyphs
2029            .iter()
2030            .map(|g| Glyph {
2031                ch: g.ch,
2032                style: g.style,
2033                src: shift(g.src),
2034                stop: g.stop,
2035            })
2036            .collect(),
2037        end_src: shift(row.end_src),
2038        decoration: row.decoration,
2039        code: row.code,
2040        code_lang: row.code_lang.clone(),
2041        directive: row.directive,
2042        directive_label: row.directive_label.clone(),
2043        media: row.media.clone(),
2044        // A tick, not an offset — reuse carries it as-is, like `code_lang`.
2045        task: row.task,
2046        leaf_directive: row.leaf_directive.clone(),
2047        heading: row.heading,
2048        // Structure, not offsets: a reused block's rows divide the same blocks
2049        // wherever the edit above moved them to.
2050        boundary: row.boundary,
2051        mark_ends: row.mark_ends.iter().map(|&o| shift(o)).collect(),
2052    }
2053}
2054
2055/// Advance a row's source offsets by `delta` in place — the suffix half of
2056/// [`build_spliced`], where the rows are already owned and only need shifting,
2057/// not copying.
2058fn shift_row_in_place(row: &mut VRow, delta: isize) {
2059    for g in &mut row.glyphs {
2060        g.src = (g.src as isize + delta) as usize;
2061    }
2062    row.end_src = (row.end_src as isize + delta) as usize;
2063    for o in &mut row.mark_ends {
2064        *o = (*o as isize + delta) as usize;
2065    }
2066}
2067
2068/// Whether every source offset a block's rows carry falls inside the block's own
2069/// span — the precondition for reusing the block by a uniform offset shift. It
2070/// holds for well-formed blocks (their glyphs and row ends address bytes within
2071/// the block, synthetic glyphs point at the block start). It fails when a node
2072/// renders *outside* its block, which today means a malformed Markdown inline
2073/// node twig leaves with a degenerate `0..0` span: that content lands at a fixed
2074/// offset that doesn't move with the block. Such a block is re-rendered every
2075/// build instead of shifted, so the incremental map still matches a fresh one —
2076/// see [`build_cached`] and [`build_spliced`].
2077fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
2078    rows.iter().all(|r| {
2079        r.end_src >= span.start
2080            && r.end_src <= span.end
2081            && r.glyphs
2082                .iter()
2083                .all(|g| g.src >= span.start && g.src <= span.end)
2084    })
2085}
2086
2087/// Where the rendered document begins when a leading `metadata` block is all
2088/// there is — the end of that hidden frontmatter, past the newline that closes
2089/// its last line so the floor sits at the start of the (empty) body rather than
2090/// on the closing `---`.
2091///
2092/// With a real block after it the frontmatter's end is never needed: the floor
2093/// is that block's start, and the rows begin there. With nothing after it, both
2094/// the caret floor and the trailing-blank-line count would otherwise fall back
2095/// to offset 0 — inside the hidden frontmatter — which put the caret *before*
2096/// the metadata and made typing land ahead of the opening `---`.
2097fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
2098    let Some(end) = meta_end else { return 0 };
2099    let end = end.min(source.len());
2100    let rest = &source[end..];
2101    if rest.starts_with("\r\n") {
2102        end + 2
2103    } else if rest.starts_with('\n') {
2104        end + 1
2105    } else {
2106        end
2107    }
2108}
2109
2110/// The end of the document's hidden frontmatter: the last `metadata` child of
2111/// `doc`, which is what [`top_level`] and [`Builder::blocks`] drop. `None` when
2112/// there is none.
2113fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
2114    let mut end = None;
2115    let mut child = nodes[doc].first_child;
2116    while let Some(cid) = child {
2117        let n = &nodes[cid.0 as usize];
2118        if n.kind == Kind::Metadata {
2119            end = Some(n.span.end);
2120        }
2121        child = n.next_sibling;
2122    }
2123    end
2124}
2125
2126/// The document's rendered top-level blocks, as node indices in source order.
2127///
2128/// Not simply `doc`'s children, for two reasons. Frontmatter (a leading
2129/// `metadata` block) is document metadata rather than prose and is dropped, the
2130/// way [`Builder::blocks`] drops it. And a **footnote definition** (`[^1]: …`)
2131/// is not a child of `doc` at all: twig parses it as a root of its own, a
2132/// *sibling* of the document node with `parent == None`. A walk that starts at
2133/// `doc` therefore never reaches one, which is why a definition — and every
2134/// byte of its body — used to render as nothing at all. Merging the roots back
2135/// in by `span.start` puts each definition on screen exactly where it was
2136/// written, which is what keeps rows, stops, and offsets monotonic.
2137///
2138/// A **link reference definition** (`[foo]: /url`) is a root of the same kind,
2139/// and is merged for the opposite reason: it draws *nothing*, and the walk has
2140/// to know where it stands to step over it. A definition closing a README —
2141/// the `[links]: …` block under the prose — left no block over its lines, so
2142/// the separator logic read them as blank lines and drew an empty paragraph
2143/// per definition. Merged in, it is a hidden block like a comment, and
2144/// [`Builder::block_or_hidden`] moves the walk past it. One with no span
2145/// (`0..0`, what twig before 3.3.3 reported for every one) has nowhere to be
2146/// merged, and is left out as before.
2147///
2148/// Only those roots are merged. twig also leaves stray orphan `str` nodes
2149/// parented to nothing (the `*` of an emphasis run, for one); those are already
2150/// rendered as part of the subtree that owns their bytes, and re-emitting them
2151/// here would double them.
2152fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
2153    let mut out = Vec::new();
2154    let mut child = nodes[doc].first_child;
2155    while let Some(cid) = child {
2156        let n = &nodes[cid.0 as usize];
2157        if n.kind != Kind::Metadata {
2158            out.push(cid.0 as usize);
2159        }
2160        child = n.next_sibling;
2161    }
2162    out.extend(
2163        nodes
2164            .iter()
2165            .enumerate()
2166            .filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
2167            .map(|(i, _)| i),
2168    );
2169    out.sort_by_key(|&i| nodes[i].span.start);
2170    out
2171}
2172
2173/// Is a parentless node of `kind` at `span` a definition the top-level walk
2174/// merges in — a footnote definition, or a link reference definition that
2175/// knows where it stands? Shared by [`top_level`] and [`top_blocks`] so the
2176/// two walks cannot disagree about what the top-level blocks are.
2177fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
2178    match *kind {
2179        Kind::Footnote => true,
2180        Kind::Reference => span.end > span.start,
2181        _ => false,
2182    }
2183}
2184
2185/// The top-level blocks to hand [`build_cached`] / [`build_spliced`] — the
2186/// incremental path's twin of [`top_level`], which the two must agree with block
2187/// for block or the render paths diverge.
2188///
2189/// `child_spans(None)` gives `doc`'s children, which is all of them for an
2190/// ordinary document. A **footnote definition** is not one: twig parses `[^1]: …`
2191/// as a root beside `doc` with no parent, and indexes it at no offset either —
2192/// `node_at` inside its bytes answers `doc`, and a `query("footnote")` selector
2193/// finds nothing. Leaf used to discover them by marshalling the whole arena with
2194/// `nodes()` — the very cost the incremental path exists to avoid — behind a
2195/// byte-scan gate that gave documents with no `[^…]:` line a substring search
2196/// instead. twig 3.0's `definitions()` asks the library the question directly,
2197/// so both the marshal and the gate are gone.
2198///
2199/// The link reference definitions `definitions()` also reports are merged on
2200/// the same terms as [`top_level`] merges them — see [`is_placed_definition`].
2201///
2202/// This is the one part of the render that needs an [`Editor`] rather than a
2203/// marshalled node array. The builders themselves stay editor-free; this only
2204/// prepares their input.
2205pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
2206    let mut top = editor.child_spans(None).unwrap_or_default();
2207    let defs: Vec<QueryMatch> = definitions(editor)
2208        .into_iter()
2209        .filter(|m| is_placed_definition(&m.kind, &m.span))
2210        .collect();
2211    if defs.is_empty() {
2212        return top;
2213    }
2214    top.extend(defs);
2215    // Source order — what every offset-keyed thing downstream (rows, stops, the
2216    // splice path's block-for-block match) is built to assume.
2217    top.sort_by_key(|m| m.span.start);
2218    top
2219}
2220
2221/// Every `[^label]: …` definition in the document, in whatever order twig
2222/// reports them.
2223///
2224/// Filtered to [`Kind::Footnote`]: `definitions()` also reports the *link*
2225/// reference definitions (`[foo]: /url`), which are [`top_blocks`]'s business
2226/// and not [`crate::Doc::footnote_at_caret`]'s.
2227///
2228/// Empty when the document can't be walked, which leaves [`top_blocks`] with
2229/// the ordinary top-level children and [`crate::Doc::footnote_at_caret`] with an
2230/// undefined reference — in both cases the same answer as a document that has
2231/// no definitions, which is the right way to degrade.
2232pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2233    definitions(editor)
2234        .into_iter()
2235        .filter(|m| m.kind == Kind::Footnote)
2236        .collect()
2237}
2238
2239/// Every definition twig resolves by label rather than by position — footnote
2240/// and link reference definitions both — or nothing when the document can't be
2241/// walked.
2242fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2243    let Ok(mut doc) = editor.document() else {
2244        return Vec::new();
2245    };
2246    doc.definitions().unwrap_or_default()
2247}
2248
2249/// The label of the footnote definition starting at `start` — the `1` in
2250/// `[^1]: …`. twig gives the `footnote` node no label of its own (no `text`, no
2251/// `name`), and the bytes that spell it belong to no child node either — the
2252/// body `para` starts its *content* past them — so the source is the only place
2253/// to read it from. `None` when what's there isn't a definition after all.
2254pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
2255    let rest = source.get(start..)?.strip_prefix("[^")?;
2256    let end = rest.find("]:")?;
2257    Some(&rest[..end])
2258}
2259
2260/// Where the body of the footnote definition spanning `span` sits in `source` —
2261/// everything past the `[^1]:` marker, which is the part a reader actually wants
2262/// when they follow a reference.
2263///
2264/// Source bytes, verbatim but for the whitespace trimmed off each end: a note
2265/// that says `see *later*` answers with the asterisks in. Rendering that body is
2266/// a frontend's business the same way painting a [`Role`] is, and a caller that
2267/// wants it laid out already has the definition on screen where it was written.
2268///
2269/// The trim is what makes the common case read right — `[^1]: text` has a space
2270/// after the colon that belongs to the marker, not the note, and a definition's
2271/// span runs to the newline ending it.
2272///
2273/// The span is taken at its word, which it has only been safe to do since twig
2274/// 3.1: a djot definition's span used to run *past* its own last line, through
2275/// the blank line separating it from the next block and into that block's first
2276/// byte, so `[^2a]: a note.` came back as `"a note.\n\n["` and the offsets named
2277/// the following note's rows as well as this one's — a reader asking about one
2278/// footnote was shown two. leaf measured the body itself to get around that, and
2279/// paid for it: the scan stopped at the first blank line, so a note with a second
2280/// indented paragraph lost it. Both halves go away with the fix, since a blank
2281/// line *inside* a definition was always interior to the span and still is.
2282///
2283/// A range rather than a slice because "go to note" needs the *position* as much
2284/// as the text, and it needs the position of the body specifically: a
2285/// definition's `[^1]:` marker is decoration the caret can't occupy (the rich
2286/// view draws it as `[1] ` and gives it no stop), so aiming a caret at the
2287/// definition's first byte lands it on the nearest real stop instead — which is
2288/// up in the paragraph *above* the note. The body's first byte is a stop, and is
2289/// where a reader following a reference wants to arrive anyway.
2290pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
2291    let rest = source.get(span.clone())?.strip_prefix("[^")?;
2292    let marker = rest.find("]:")?;
2293    // `span.start` + `[^` + the label + `]:`.
2294    let after_marker = span.start + 2 + marker + 2;
2295    let raw = source.get(after_marker..span.end)?;
2296    // Written as a start plus a length so an all-whitespace body lands on an
2297    // empty range at the end rather than an inverted one.
2298    let start = after_marker + (raw.len() - raw.trim_start().len());
2299    Some(start..start + raw.trim().len())
2300}
2301
2302/// The label of the footnote *reference* spanning `span` — the `1` in `[^1]`.
2303///
2304/// The peer of [`footnote_label`] for the other half of the pair, and needed for
2305/// the same reason: a reference whose node carries neither a `content_span` nor
2306/// a `text` still spells its label plainly in the source. `None` when the bytes
2307/// aren't a reference after all.
2308pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
2309    let rest = source.get(span)?.strip_prefix("[^")?;
2310    let end = rest.find(']')?;
2311    Some(&rest[..end])
2312}
2313
2314/// Where a heading's *content* starts — past the `#`s and the space the rich
2315/// view hides, for an ATX heading; the block's own start for a setext one (which
2316/// has no leading marker) and for a format that spells headings some other way.
2317///
2318/// Only an empty heading needs asking: with any content at all, the row ends on
2319/// its last glyph. Bounded to the heading's own first line so a marker-less
2320/// heading can't scan into the text under it.
2321fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
2322    let end = span.end.min(source.len());
2323    let Some(line) = source.get(span.start..end) else {
2324        return span.start;
2325    };
2326    let line = line.split('\n').next().unwrap_or("");
2327    let hashes = line.len() - line.trim_start_matches('#').len();
2328    if hashes == 0 {
2329        return span.start;
2330    }
2331    let after = &line[hashes..];
2332    span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
2333}
2334
2335struct Builder<'a> {
2336    nodes: &'a [FlatNode],
2337    /// The document source, consulted to place blank-line rows at the source
2338    /// offsets the caret should occupy on them (the AST drops blank lines).
2339    source: &'a str,
2340    /// The word-wrap column budget, or `None` to emit each block as a single
2341    /// unwrapped row (the frontend wraps).
2342    wrap: Option<usize>,
2343    rows: Vec<VRow>,
2344    /// Built alongside `rows`, never instead of them — see [`TableInfo`].
2345    tables: Vec<TableInfo>,
2346    /// The end offset of the last content emitted — the anchor for blank
2347    /// separator rows so the caret never snaps onto one.
2348    last_off: usize,
2349    /// The end of the last block the walk stepped over without drawing — a
2350    /// comment, which the rich view hides. `last_off` moves past it too, for the
2351    /// separators; this is kept apart so the trailing blank lines can be counted
2352    /// from it without also being counted from a code block's closing fence,
2353    /// which `last_off` likewise ends after. `0` until a hidden block is met.
2354    stepped_over: usize,
2355    /// How many rows each block image reserves, keyed by its destination — the
2356    /// frontend's per-image height, threaded in from [`crate::Doc::set_media_rows`]
2357    /// so [`Builder::block_media`] can size the placeholder without core doing any
2358    /// I/O. A destination absent from the map (or a `0`/`1` entry) reserves the
2359    /// bare one-row placeholder, which is the whole-document default and what
2360    /// every existing test — passing an empty map — still gets.
2361    media_rows: &'a HashMap<String, usize>,
2362    /// The glyph a hard break renders as while the current inline run is built:
2363    /// a space in prose (a break folds into the flow the frontend wraps), but a
2364    /// newline (`\n`) inside a table cell, where a row is one source line and the
2365    /// only break it can carry is an explicit one that must show as a line of its
2366    /// own. Set around [`Builder::row_cells`] and otherwise left at `' '`.
2367    break_glyph: Cell<char>,
2368    /// Render a soft break (a bare newline inside a paragraph) as a line break
2369    /// where it was written, rather than folding it into the reflowed paragraph
2370    /// — the `LineFlow::Preserve` behaviour. A soft break emits a `'\n'` glyph
2371    /// (like a hard break in a cell), which [`Builder::emit_wrapped`] turns into
2372    /// a fresh visual row. `false` is the flowing-prose default. Inside a table
2373    /// cell (where `break_glyph` is already `'\n'`) it has no effect: a cell is
2374    /// one line and folds its own soft breaks regardless.
2375    preserve_soft: bool,
2376    /// The source byte range of the one line that should render its markup
2377    /// *raw* — the caret's line under `MarkupMode::Full` (see
2378    /// [`crate::Doc::reveal_line`]). `None` in every other mode and view, which
2379    /// is the delimiters-always-hidden behaviour every build had before the
2380    /// preference existed.
2381    ///
2382    /// Read only by [`Builder::revealed`], which every delimiter-bearing arm of
2383    /// [`Builder::inline`] consults. A range rather than a bare caret offset
2384    /// because the decision is per-*node*, not per-caret: a node is revealed
2385    /// when its span meets this line, so `*em*` shows both its asterisks even
2386    /// with the caret at one end of it.
2387    reveal: Option<Range<usize>>,
2388    /// The content ends of the hidden marks rendered since the last row was
2389    /// pushed — recorded as the inline walk meets each mark, and drained onto
2390    /// the rows as they are emitted (see [`Builder::take_mark_ends`]). A cell
2391    /// rather than a `&mut`, for the reason `break_glyph` is: the inline walk
2392    /// borrows the builder shared.
2393    pending_mark_ends: RefCell<Vec<usize>>,
2394}
2395
2396impl Builder<'_> {
2397    /// Note that the mark `id` closes with a hidden delimiter, so its content
2398    /// end is a caret home — unless the mark is empty, where the end is the
2399    /// start and there is nothing to extend.
2400    fn note_mark_end(&self, id: usize) {
2401        let node = &self.nodes[id];
2402        if let Some(content) = &node.content_span
2403            && content.end < node.span.end
2404            && !content.is_empty()
2405        {
2406            self.pending_mark_ends.borrow_mut().push(content.end);
2407        }
2408    }
2409
2410    /// The pending mark ends at or before `end_src`, for the row ending there
2411    /// — every mark rendered so far that closes on it. A mark's end never
2412    /// exceeds the end of the row its last glyph is on, so the leftovers are
2413    /// those of rows still to come.
2414    fn take_mark_ends(&self, end_src: usize) -> Vec<usize> {
2415        let mut pending = self.pending_mark_ends.borrow_mut();
2416        let (taken, kept): (Vec<usize>, Vec<usize>) =
2417            pending.drain(..).partition(|&o| o <= end_src);
2418        *pending = kept;
2419        taken
2420    }
2421    /// Whether `span` belongs to the line that is showing its raw markup. True
2422    /// only when a reveal line is set (`MarkupMode::Full`) and the two ranges
2423    /// actually meet.
2424    ///
2425    /// Touching at an endpoint counts: an emphasis ending exactly where the line
2426    /// does is on that line, and a zero-length reveal range (the caret alone on
2427    /// a blank line) still meets a node that starts there. The test is
2428    /// deliberately generous — the failure it avoids is revealing one delimiter
2429    /// of a pair while hiding the other, which looks like corruption rather than
2430    /// like markup.
2431    fn revealed(&self, span: &Range<usize>) -> bool {
2432        self.reveal
2433            .as_ref()
2434            .is_some_and(|r| span.start <= r.end && r.start <= span.end)
2435    }
2436
2437    /// The `(opening, closing)` source byte ranges of a node's delimiters — the
2438    /// bytes its `span` holds that its `content_span` doesn't.
2439    ///
2440    /// This is how *every* inline delimiter is recovered, rather than a table of
2441    /// spellings per kind: twig gives `*em*` a span of `13..17` and a content
2442    /// span of `14..16`, so the gaps at each end are the delimiters, whatever
2443    /// they happen to be. That matters because one kind has many spellings —
2444    /// `*em*` and `_em_` are both emphasis, `` `x` `` and ``` ``x`` ``` both
2445    /// verbatim — and re-deriving the text from the source is the only way to
2446    /// show back what the author actually typed. It also gets a link's
2447    /// asymmetric `[` / `](dest)` right for free.
2448    ///
2449    /// `None` when the node has no content span, or when content and span
2450    /// coincide (nothing was elided, so there is nothing to reveal).
2451    fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
2452        let node = &self.nodes[id];
2453        let content = node.content_span.clone()?;
2454        let span = node.span.clone();
2455        // A content span that escapes its own node's span means the two are
2456        // describing different things; reveal nothing rather than slice wildly.
2457        if content.start < span.start || content.end > span.end {
2458            return None;
2459        }
2460        let (open, close) = (span.start..content.start, content.end..span.end);
2461        // A delimiter that spans a newline isn't this line's to reveal — a setext
2462        // heading's `\n=====` underline is the case that arises in practice. It
2463        // would also inject a `'\n'` glyph, which `emit_wrapped` reads as a hard
2464        // row break, so the row would split where the author wrote no break.
2465        let multiline =
2466            |r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
2467        if multiline(&open) || multiline(&close) {
2468            return None;
2469        }
2470        (!open.is_empty() || !close.is_empty()).then_some((open, close))
2471    }
2472
2473    /// Emit the source bytes of `range` as revealed markup — real glyphs, each
2474    /// mapped to its own source byte and each a caret stop, so a delimiter shown
2475    /// is a delimiter that can be selected, edited and deleted like any other
2476    /// text. Styled [`Role::Delimiter`] on top of the run's own style, which is
2477    /// how a frontend tells scaffolding from prose and dims it.
2478    ///
2479    /// Deliberately *not* [`push_escaped_text`]: this is raw source, not parsed
2480    /// text, so there is no escape-driven drift between the two to correct.
2481    fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
2482        let Some(text) = self.source.get(range.clone()) else {
2483            return;
2484        };
2485        push_text(out, text, range.start, base.role(Role::Delimiter));
2486    }
2487
2488    /// Render an inline node's children wrapped in its raw delimiters when the
2489    /// node is on the revealed line, and bare (delimiters resolved away) when it
2490    /// isn't — the shared body of every delimiter-bearing arm of
2491    /// [`inline`](Self::inline).
2492    ///
2493    /// `style` is the resolved styling the content still gets in *both* modes:
2494    /// revealing `*em*` shows the asterisks *and* keeps the text italic, the
2495    /// live-preview behaviour. Showing the markup is not the same as turning the
2496    /// rendering off — that is what [`crate::View::Source`] is for.
2497    fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
2498        let show = self
2499            .revealed(&self.nodes[id].span)
2500            .then(|| self.delims(id))
2501            .flatten();
2502        if let Some((open, _)) = &show {
2503            self.push_delim(out, open, style);
2504        }
2505        self.recurse(id, style, out);
2506        match &show {
2507            Some((_, close)) => self.push_delim(out, close, style),
2508            // Hidden, so the content's end has no glyph after it: give the
2509            // caret its home there.
2510            None => self.note_mark_end(id),
2511        }
2512    }
2513
2514    fn children(&self, id: usize) -> Vec<usize> {
2515        let mut out = Vec::new();
2516        let mut c = self.nodes[id].first_child;
2517        while let Some(cid) = c {
2518            out.push(cid.0 as usize);
2519            c = self.nodes[cid.0 as usize].next_sibling;
2520        }
2521        out
2522    }
2523
2524    /// Render a node's block children, a blank separator between each. `tight`
2525    /// suppresses the *fabricated* separator between adjacent children that share
2526    /// a source line boundary — a tight list item and the sub-list nested in it —
2527    /// while a real blank source line between them still opens a gap.
2528    fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
2529        // Frontmatter (a leading `metadata` block) is document metadata, not
2530        // prose: hide it entirely in the rich-text view. Skipping it here means
2531        // no phantom blank rows for its lines and no separator before the first
2532        // real block — the document opens straight into its content.
2533        let kids: Vec<usize> = self
2534            .children(id)
2535            .into_iter()
2536            .filter(|&c| self.nodes[c].kind != Kind::Metadata)
2537            .collect();
2538        let mut above: Option<BlockClass> = None;
2539        for child in kids {
2540            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2541            let before_sep = self.rows.len();
2542            if let Some(above) = above {
2543                self.emit_separators_before(
2544                    self.nodes[child].span.start,
2545                    pc,
2546                    !tight,
2547                    Boundary { above, below },
2548                );
2549            }
2550            // The first *drawn* child wears the first-row prefix (a bullet, a
2551            // footnote label), not the first child: a comment opening a list
2552            // item draws nothing, and the bullet belongs to what follows it.
2553            let first = if above.is_none() { pf } else { pc };
2554            if self.block_or_hidden(child, before_sep, first, pc) {
2555                above = Some(below);
2556            }
2557        }
2558    }
2559
2560    /// Render `child` after the separator [`Builder::emit_separators_before`]
2561    /// spelled for it from row `before_sep` on, and say whether it drew
2562    /// anything.
2563    ///
2564    /// A block that draws no rows — an HTML comment, which the rich view hides
2565    /// the way it hides frontmatter — is still *there* in the source, and the
2566    /// walk has to step over it: `last_off` moves past it so the next separator
2567    /// counts the blank lines from its end, not from wherever the last drawn
2568    /// block stopped. Left where it was, the separator counted every line of the
2569    /// comment as a blank row; and the cached path, whose per-block builder
2570    /// starts at offset 0, handed back a `last_off` of 0 and counted every line
2571    /// of the *document* — one phantom blank row per source line, once per
2572    /// comment. The separator drawn for it is taken back too, so a hidden block
2573    /// leaves no gap of its own: what stands either side of it meets across one
2574    /// boundary, as if the comment were not there.
2575    fn block_or_hidden(
2576        &mut self,
2577        child: usize,
2578        before_sep: usize,
2579        pf: &[Glyph],
2580        pc: &[Glyph],
2581    ) -> bool {
2582        let after_sep = self.rows.len();
2583        self.block(child, pf, pc);
2584        if self.rows.len() > after_sep {
2585            return true;
2586        }
2587        self.rows.truncate(before_sep);
2588        let end = self.nodes[child].span.end;
2589        self.last_off = self.last_off.max(end);
2590        self.stepped_over = self.stepped_over.max(end);
2591        false
2592    }
2593
2594    /// Render an explicit, ordered list of top-level blocks — [`Builder::blocks`]
2595    /// for a walk that isn't "the children of one node". The document's top level
2596    /// no longer is: a footnote definition is a root beside `doc`, not under it,
2597    /// and [`top_level`] merges it into this list by source position.
2598    ///
2599    /// The separator between blocks is spelled by the same
2600    /// [`Builder::emit_separators_before`] the incremental top-level walk in
2601    /// [`build_cached`] uses, so the two paths can't drift on how a boundary
2602    /// looks.
2603    ///
2604    /// Returns the class of the last block that drew anything — what the
2605    /// trailing blank lines close — or `None` when nothing did.
2606    fn top_blocks(&mut self, ids: &[usize]) -> Option<BlockClass> {
2607        let mut above: Option<BlockClass> = None;
2608        for &child in ids {
2609            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2610            let before_sep = self.rows.len();
2611            if let Some(above) = above {
2612                self.emit_separators_before(
2613                    self.nodes[child].span.start,
2614                    &[],
2615                    true,
2616                    Boundary { above, below },
2617                );
2618            }
2619            if self.block_or_hidden(child, before_sep, &[], &[]) {
2620                above = Some(below);
2621            }
2622        }
2623        above
2624    }
2625
2626    /// Emit the blank separator row(s) that sit between a block ending at the
2627    /// current `last_off` and the next block starting at `next_start`, wearing
2628    /// the continuation prefix `pc`. Shared by [`Builder::blocks`] and the
2629    /// incremental top-level walk so the two can't drift on how a boundary is
2630    /// spelled.
2631    ///
2632    /// The blank line(s) between two blocks are real caret stops, each needing
2633    /// its *own* source offset — one strictly past the previous block's content,
2634    /// else it collides with that block's last row and `pos_of_offset`
2635    /// (first-match-wins) would resolve the caret onto the wrong row, pinning
2636    /// downward motion there.
2637    ///
2638    /// One row *per* blank source line, not a single collapsed separator: an
2639    /// empty paragraph opened between two blocks (Enter in the gap,
2640    /// `…\n\n\n\n…`) must be a navigable empty row, not vanish — else the caret
2641    /// in it snaps onto the *next* block's start and Enter looks like it did
2642    /// nothing.
2643    fn emit_separators_before(
2644        &mut self,
2645        next_start: usize,
2646        pc: &[Glyph],
2647        synthetic: bool,
2648        boundary: Boundary,
2649    ) {
2650        let mut offs = self.blank_rows_between(self.last_off, next_start);
2651        if offs.is_empty() {
2652            if !synthetic {
2653                // A tight list item's own text sits directly above the sub-list
2654                // nested in it — no fabricated gap. The "breathe" row belongs
2655                // between free-standing blocks, not between an item and its
2656                // child list, which the source writes on the very next line. A
2657                // real blank source line (a loose list) still lands a gap below,
2658                // because `blank_rows_between` found it and we never reach here.
2659                return;
2660            }
2661            // A tight gap with no blank line (e.g. a heading directly above its
2662            // text): keep the one conventional separator row so blocks still
2663            // breathe, as they always have.
2664            offs.push(self.blank_line_offset(self.last_off, next_start));
2665        }
2666        let last = offs.len() - 1;
2667        for (k, end_src) in offs.into_iter().enumerate() {
2668            // Only the drawn-only rows carry the boundary: the navigable blank
2669            // lines between them (and every blank line under preserve-soft flow)
2670            // are somewhere text can go, not a gap between blocks, and a frontend
2671            // that shrank one would be shrinking a line the author is typing on.
2672            let drawn = !self.preserve_soft && (k == 0 || k == last);
2673            // The blank line a boundary is *drawn* with isn't a place text can
2674            // go. The first one closes the block above and the last one opens the
2675            // block below — with a single blank line, the usual case, doing both
2676            // at once. Typing on either just continues the paragraph it abuts,
2677            // since the blank line it would need to be a paragraph of its own is
2678            // the very line being typed on. So they're a gap, like a table's
2679            // border: drawn, clickable, never a caret's home.
2680            //
2681            // The lines *between* them are the real ones. That's what Enter
2682            // opens: it inserts a paragraph break (`\n\n`), which leaves a blank
2683            // line spare on each side and the caret on the navigable line
2684            // between them.
2685            //
2686            // Preserve flow is the exception: there a bare `\n` is a visible line
2687            // break the author edits directly, so a lone blank line *is* a caret
2688            // home — typing on it makes the soft break the mode exists to show,
2689            // and Enter at a line's end lands the caret on exactly this row. So no
2690            // separator is drawn-only; every blank line is navigable.
2691            self.rows.push(VRow {
2692                glyphs: pc.to_vec(),
2693                end_src,
2694                decoration: drawn,
2695                code: false,
2696                code_lang: None,
2697                directive: false,
2698                directive_label: None,
2699                media: None,
2700                task: None,
2701                leaf_directive: None,
2702                heading: None,
2703                boundary: drawn.then_some(boundary),
2704                mark_ends: Vec::new(),
2705            });
2706        }
2707    }
2708
2709    fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2710        let node = &self.nodes[id];
2711        match node.kind.as_str() {
2712            "doc" | "section" => self.blocks(id, pf, pc, false),
2713            "heading" => {
2714                // A heading whose only visible content is a single image — a
2715                // banner set in an `<h1>` (`<h1><picture><img></picture></h1>`),
2716                // or `# ![](banner.png)` — is a block picture, not text. Render
2717                // it as one; anything with real heading text falls through.
2718                if let Some((m, kind)) = self.media_only(id) {
2719                    self.block_media(m, kind, id, pf);
2720                    return;
2721                }
2722                let level = node.level.unwrap_or(1);
2723                let style = heading_style(level);
2724                let mut glyphs = Vec::new();
2725                // On the revealed line the `# ` comes back as real, editable
2726                // text in front of the heading. Only the opening marker: a
2727                // closing `#`-run (`## title ##`) is covered by the same
2728                // `delims` pair, and a setext underline is excluded there for
2729                // being on another line entirely.
2730                if let Some((open, close)) =
2731                    self.revealed(&node.span).then(|| self.delims(id)).flatten()
2732                {
2733                    self.push_delim(&mut glyphs, &open, style);
2734                    glyphs.extend(self.inline_children_with_trailing(id, style));
2735                    self.push_delim(&mut glyphs, &close, style);
2736                } else {
2737                    glyphs = self.inline_children_with_trailing(id, style);
2738                }
2739                // An *empty* heading — `# ` with nothing typed after it, which is
2740                // what the toolbar's H1 leaves on a blank line — has no glyph for
2741                // its row to end on, so the fallback below is the row's whole
2742                // extent: its only caret stop, and the offset every row after it
2743                // is measured from. The block's start is the wrong answer for
2744                // both, because it sits *in front of* the `# ` the rich view
2745                // hides: the caret drew (and typed) before the hashes, and the
2746                // rows below inherited an offset short by the marker's length,
2747                // which put the caret on one of them the moment the heading grew
2748                // text. Its content's start is where the caret belongs.
2749                let home = heading_content_start(self.source, &node.span);
2750                let first = self.rows.len();
2751                self.emit_wrapped(glyphs, home, pf, pc);
2752                // Stamp the level on every row the heading just emitted — a
2753                // wrapped heading's continuation rows as much as its first, and
2754                // an empty one's single glyphless row, which is the whole point
2755                // (see [`VRow::heading`]).
2756                for row in &mut self.rows[first..] {
2757                    row.heading = Some(level.min(255) as u8);
2758                }
2759            }
2760            "block_quote" => {
2761                let (start, end) = (node.span.start, node.span.end);
2762                let gutter = synth("│ ", Role::QuoteGutter, start);
2763                let f = concat(pf, &gutter);
2764                let c = concat(pc, &gutter);
2765                // A childless quote — a bare `> ` on an otherwise blank line,
2766                // which is what the toolbar's Quote button leaves there — has no
2767                // inner block to carry the gutter or a caret home, so `blocks`
2768                // emitted *nothing at all*: the quote didn't merely draw
2769                // unstyled, it disappeared, and a document that was only `> `
2770                // rendered zero rows with the caret nowhere to stand. Emit the
2771                // gutter row itself, ending just past the marker, exactly as an
2772                // empty `list_item` emits its bare bullet.
2773                if self.children(id).is_empty() {
2774                    self.push_row_at(f, end.min(self.source.len()));
2775                } else {
2776                    self.blocks(id, &f, &c, false);
2777                    self.emit_quote_trailing_lines(&c, end);
2778                }
2779            }
2780            // A generic `:::name{.class}` fenced-div container (twig's
2781            // `directive`, container form). Core is agnostic of `name` — it's
2782            // the host app's vocabulary (diaryx's `vis` for audience
2783            // visibility, say) and isn't available here regardless: twig only
2784            // threads an `element`'s tag name through `FlatNode::name`, not a
2785            // directive's own identifier. Every row gets marked `directive` (a
2786            // frontend draws a tinted panel around each maximal run, the
2787            // `code`/`code_block` recipe) and the first row carries a label —
2788            // the way a code fence's language rides only its first row.
2789            //
2790            // The label reads BOTH attribute conventions diaryx content
2791            // actually uses: twig's own dot-prefixed classes (`{.public
2792            // .family}`, one combined `class` attr) and bare pandoc-style
2793            // words with no leading dot (`{public family}` — the syntax
2794            // `diaryx_core::visibility`'s hand-rolled publish-time filter and
2795            // apps/web's directive serializer both write; twig parses each
2796            // bare word as its own attribute with an empty value, per
2797            // `languages/markdown/attributes.zig`). Reading only `.class`
2798            // would leave every *existing* diaryx `:::vis{...}` block
2799            // unlabeled.
2800            // Only the *container* form is the panel below. A `text` directive
2801            // is inline and never reaches the block walker (see `is_inline`); a
2802            // `leaf` one is a standalone block with no body, drawn as a
2803            // placeholder the way an image is.
2804            "container"
2805                if container_is_directive(node)
2806                    && node.directive_form == Some(DirectiveForm::Leaf) =>
2807            {
2808                self.block_directive(id, pf);
2809            }
2810            "container" if container_is_directive(node) => {
2811                let label = directive_attr_label(&node.attrs);
2812                let start_row = self.rows.len();
2813                self.blocks(id, pf, pc, false);
2814                for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
2815                    row.directive = true;
2816                    if i == 0 {
2817                        row.directive_label = label.clone();
2818                    }
2819                }
2820                // Anchor the block's end past its closing `:::` fence, exactly as
2821                // the code-block arm anchors past its ```` ``` ````. A container's
2822                // last content row ends at its last *child*, before the fence and
2823                // the blank line under it, so the separator logic counted the
2824                // fence line as a blank row of its own and drew a second boundary
2825                // — one gap's worth of margin twice, under every fenced div.
2826                self.last_off = node.span.end;
2827            }
2828            "bullet_list" | "ordered_list" | "task_list" => {
2829                let ordered = node.kind == Kind::OrderedList;
2830                let mut item_no = 0usize;
2831                let kids = self.children(id);
2832                for (i, child) in kids.iter().copied().enumerate() {
2833                    let kind = &self.nodes[child].kind;
2834                    if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
2835                        let start = self.nodes[child].span.start;
2836                        item_no += 1;
2837                        // A task item's box replaces the bullet rather than
2838                        // joining it. The `[ ] ` that spells it is markup twig
2839                        // has already consumed — the item's paragraph *content*
2840                        // starts past it — so without a drawn box a task item
2841                        // was indistinguishable from a plain bullet, ticked or
2842                        // not. `☐`/`☑` is the marker for the same reason `•` is:
2843                        // it stands where the source's own marker stands. Which
2844                        // way it faces is `checked`, straight off the node.
2845                        let checked = self.nodes[child].checked;
2846                        let marker = match (checked, ordered) {
2847                            (Some(true), _) => "☑ ".to_string(),
2848                            (Some(false), _) => "☐ ".to_string(),
2849                            (None, true) => format!("{item_no}. "),
2850                            (None, false) => "• ".to_string(),
2851                        };
2852                        let bullet = synth(&marker, Role::ListMarker, start);
2853                        let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
2854                        let first_row = self.rows.len();
2855                        self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
2856                        // On the item's first row, the way `code_lang` rides the
2857                        // first row of its block.
2858                        if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
2859                            row.task = Some(c);
2860                        }
2861                    } else {
2862                        // twig can nest a *following* top-level block as a direct
2863                        // child of the list rather than a sibling of it — e.g.
2864                        // `- item\n\n> quote` parses the block quote under the
2865                        // `bullet_list`. It isn't a list item, so render it de-nested:
2866                        // no bullet, at the list's own prefix, with the usual block
2867                        // separator — never `• │ quote`.
2868                        if i > 0 {
2869                            self.emit_separators_before(
2870                                self.nodes[child].span.start,
2871                                pc,
2872                                true,
2873                                Boundary {
2874                                    above: BlockClass::from_node_kind(
2875                                        &self.nodes[kids[i - 1]].kind,
2876                                    ),
2877                                    below: BlockClass::from_node_kind(&self.nodes[child].kind),
2878                                },
2879                            );
2880                        }
2881                        self.block(child, pc, pc);
2882                    }
2883                }
2884            }
2885            "list_item" | "task_list_item" => {
2886                // A childless item — the empty bullet you get the instant you
2887                // press Enter to open a new one — has no inner block to carry the
2888                // marker prefix or a caret home, so `blocks` would emit nothing
2889                // and the new bullet simply wouldn't appear until something was
2890                // typed into it. Emit the prefixed row itself, ending at a caret
2891                // stop just past the marker (the item's `span.end`), the way an
2892                // empty paragraph emits its one prefixed row via `emit_wrapped`.
2893                if self.children(id).is_empty() {
2894                    let home = self.nodes[id].span.end.min(self.source.len());
2895                    self.push_row_at(pf.to_vec(), home);
2896                } else {
2897                    // Tight: an item's text and the list nested under it butt
2898                    // together (`• a` / `  • b`), no fabricated blank row between —
2899                    // a loose item's real blank line still parts them.
2900                    self.blocks(id, pf, pc, true);
2901                }
2902            }
2903            // A footnote *definition* (`[^1]: the note`). It reaches this walker
2904            // only because [`top_level`] merges it back in — twig hangs it off no
2905            // parent at all, so a walk from `doc` never sees one and every byte
2906            // of its body used to render as nothing.
2907            //
2908            // Drawn as a hanging-indent item, the way a list item is: the marker
2909            // reads `[1] `, matching the `[1]` its references render as, so the
2910            // two can be paired by eye, and the body wraps under it. The marker
2911            // is synthetic decoration (one shared offset, never a caret stop) —
2912            // the `[^1]: ` that spells it in the source is markup, hidden like a
2913            // heading's `# `.
2914            "footnote" => {
2915                let (start, end) = (node.span.start, node.span.end);
2916                let source = self.source;
2917                let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
2918                let indent = " ".repeat(text_width(&marker));
2919                let f = concat(pf, &synth(&marker, Role::ListMarker, start));
2920                let c = concat(pc, &synth(&indent, Role::Body, start));
2921                if self.children(id).is_empty() {
2922                    // A definition with no body yet — the instant `[^1]: ` has
2923                    // been typed and nothing after it. `blocks` would emit
2924                    // nothing and the definition simply wouldn't appear, so emit
2925                    // the marker row itself with a caret home just past it,
2926                    // exactly as an empty list item does.
2927                    self.push_row_at(f, end.min(source.len()));
2928                } else {
2929                    self.blocks(id, &f, &c, false);
2930                }
2931            }
2932            // A link reference definition (`[foo]: /url`): resolved by label
2933            // into the links that use it, and drawn nowhere — the rich view has
2934            // no more use for its line than for a comment's. It is walked at all
2935            // (see [`top_level`]) so [`Builder::block_or_hidden`] can step the
2936            // walk past its bytes rather than count them as blank lines.
2937            "reference" => {}
2938            "table" => self.table(id, pf, pc),
2939            "code_block" => {
2940                let style = Style::default().role(Role::Code);
2941                let text = node.text.clone().unwrap_or_default();
2942                // Cut the block's *terminator*, not every trailing newline. A
2943                // block whose last line is empty spells that as a second `\n`,
2944                // and `trim_end_matches` ate it along with the terminator: the
2945                // Return that made the line got no row, so the caret placed on
2946                // it fell through to the paragraph below and typing landed
2947                // outside the block. twig's `content_span` is `text` less
2948                // exactly this one newline, so cutting one and no more is also
2949                // what keeps `code_line_offsets` lined up.
2950                let lines: Vec<&str> = text
2951                    .strip_suffix('\n')
2952                    .unwrap_or(text.as_str())
2953                    .split('\n')
2954                    .collect();
2955                // Each line at its own source offset, so the caret can walk the
2956                // code a character at a time like any other text. Where the
2957                // lines can't be lined up with the source there's no honest
2958                // offset to give, so the block maps coarsely to its start (and
2959                // stays a source-view job, as all of it once was).
2960                let offs = node
2961                    .content_span
2962                    .as_ref()
2963                    .and_then(|c| self.code_line_offsets(c, &lines));
2964                // The fence's info string, carried on the block's first row as
2965                // its language label (`None` for an indented block or a bare
2966                // fence). Kept on the row so it rides the block cache.
2967                let lang = code_language(self.source, node.span.start);
2968                // The block's syntax highlighting, a token per byte range of
2969                // each line — `None` unless the fence names a language the
2970                // grammars know (and unless the `syntax` feature is on). Done
2971                // here, once per build of the block, because the rows it
2972                // colours ride the block cache: an edit elsewhere in the
2973                // document reuses them, tokens and all.
2974                let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
2975                for (i, raw) in lines.iter().enumerate() {
2976                    let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
2977                    // No gutter glyph: the block is set apart by the border and
2978                    // tint a frontend draws around the whole run of `code` rows,
2979                    // not by a per-line mark. Just the block prefix (a list
2980                    // indent, a quote gutter) and the code text.
2981                    let mut glyphs: Vec<Glyph> = pf.to_vec();
2982                    match tokens.as_ref().and_then(|t| t.get(i)) {
2983                        Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
2984                        None => push_text(&mut glyphs, raw, at, style),
2985                    }
2986                    // Explicitly past the line's *text*: a blank code line has no
2987                    // glyph, and any prefix's offset would put the row's end
2988                    // inside the next line.
2989                    self.push_row_at(glyphs, at + raw.len());
2990                    if let Some(row) = self.rows.last_mut() {
2991                        row.code = true;
2992                        if i == 0 {
2993                            row.code_lang = lang.clone();
2994                        }
2995                    }
2996                }
2997                // Anchor the block's end past its closing fence. Its last content
2998                // row ends at the last code line, before the ``` and the blank
2999                // line under it; without this the separator logic would count the
3000                // closing-fence line as its own blank row and open a phantom
3001                // second gap below the block.
3002                self.last_off = node.span.end;
3003            }
3004            "thematic_break" => {
3005                let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
3006                let w = full.saturating_sub(prefix_width(pf)).max(4);
3007                let mut glyphs = pf.to_vec();
3008                for _ in 0..w {
3009                    glyphs.push(Glyph {
3010                        ch: '─',
3011                        style: Style::default().role(Role::Rule),
3012                        src: node.span.start,
3013                        // A rule is a block the caret can sit on, as it always
3014                        // has; it maps coarsely to the block's start.
3015                        stop: true,
3016                    });
3017                }
3018                // The dashes share one caret home in front of the atomic block,
3019                // while the row's end is the second home just past its source.
3020                // Without that trailing stop a final rule made the document end
3021                // unreachable: Right could not cross it and a click in the
3022                // empty space below it snapped back before the rule.
3023                let after_line = node.span.end
3024                    + self.source[node.span.end..]
3025                        .strip_prefix("\r\n")
3026                        .map_or_else(
3027                            || usize::from(self.source[node.span.end..].starts_with('\n')),
3028                            |_| 2,
3029                        );
3030                self.push_row_at(glyphs, after_line);
3031            }
3032            // A block-level image node with no wrapping paragraph — a promoted
3033            // top-level HTML `<img>` lands as a direct `doc` child like this
3034            // (a Markdown `![](…)` comes wrapped in a `para`, handled below).
3035            "image" => self.block_media(id, MediaKind::Image, id, pf),
3036            // The same case for a promoted top-level `<video>`/`<audio>`, which
3037            // arrives as a generic `container` rather than a node kind of its
3038            // own. It can't be found by the `media_only` scan below the way a
3039            // wrapped one is: that scan looks at a wrapper's *children*, and here
3040            // the media element is itself the block.
3041            "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3042                let kind = match element_tag(node) {
3043                    Some("audio") => MediaKind::Audio,
3044                    _ => MediaKind::Video,
3045                };
3046                self.block_media(id, kind, id, pf);
3047            }
3048            _ => {
3049                // A container of blocks, or an inline-bearing paragraph.
3050                let kids = self.children(id);
3051                // A block-level image: a paragraph (or other wrapper — a
3052                // `<picture>`, an `<h1>` banner) whose only visible content is a
3053                // single `image` node. Render it as a placeholder row + record an
3054                // [`MediaInfo`] a capable frontend replaces. An image mixed with
3055                // real text or other images on the line isn't block-level and
3056                // falls through to the inline path below, still as its alt text.
3057                if let Some((m, kind)) = self.media_only(id) {
3058                    self.block_media(m, kind, id, pf);
3059                    return;
3060                }
3061                let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
3062                if inline || kids.is_empty() {
3063                    let glyphs = self.inline_children_with_trailing(id, Style::default());
3064                    if !glyphs.is_empty() {
3065                        self.emit_wrapped(glyphs, node.span.start, pf, pc);
3066                    }
3067                } else {
3068                    self.blocks(id, pf, pc, false);
3069                }
3070            }
3071        }
3072    }
3073
3074    /// Render a table as a box-drawn grid: every column as wide as its widest
3075    /// cell, the header bold and ruled off, each cell padded to its column's
3076    /// alignment. This is the *default* monospace rendering (see
3077    /// [`VisualMap::rows`]); the same cells are also published structurally as
3078    /// [`TableInfo`], so a frontend that lays the grid out in its own units draws
3079    /// from there and skips the picture built here.
3080    ///
3081    /// The alignment comes from twig's `cell.alignment` — the delimiter row
3082    /// (`|:--|--:|`) that spells it out is consumed by the parser and leaves no
3083    /// node, so the snapshot is the only source for it.
3084    ///
3085    /// Borders and padding are *decoration*: they carry the source offset of the
3086    /// text they surround, so a click lands in that cell, but they're never
3087    /// caret stops — the caret steps cell-to-cell instead of into the box art.
3088    fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
3089        let node_end = self.nodes[id].span.end;
3090        // twig's shape is `[caption, row, row, …]`: the caption is always
3091        // present (usually empty in Markdown) and is not part of the grid.
3092        let row_ids: Vec<usize> = self
3093            .children(id)
3094            .into_iter()
3095            .filter(|&c| self.nodes[c].kind == Kind::Row)
3096            .collect();
3097        if row_ids.is_empty() {
3098            return;
3099        }
3100        // Lay every cell out first — the column widths depend on all of them.
3101        let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
3102        let heads: Vec<bool> = row_ids
3103            .iter()
3104            .map(|&r| self.nodes[r].head.unwrap_or(false))
3105            .collect();
3106        let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
3107        if cols == 0 {
3108            return;
3109        }
3110        let mut widths = vec![0usize; cols];
3111        for row in &grid {
3112            for (c, cell) in row.iter().enumerate() {
3113                widths[c] = widths[c].max(cell_width(&cell.glyphs));
3114            }
3115        }
3116        // Every column at its widest cell is only the *wish*; a grid wider than
3117        // the surface has its far side hanging off the edge where no amount of
3118        // caret motion can reach it. Cut it down to what's actually there, and
3119        // let the cells wrap into the space they're given.
3120        if let Some(w) = self.wrap {
3121            fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
3122        }
3123
3124        // Where the picture starts, so a frontend drawing its own grid knows
3125        // which rows to skip. Recorded before the first border goes down.
3126        let rows_start = self.rows.len();
3127
3128        let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
3129        self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
3130        for (ri, row) in grid.iter().enumerate() {
3131            self.push_table_row(row, &widths, pc);
3132            // The rule under the header: only where the head actually ends.
3133            let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
3134            if ends_head {
3135                let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
3136                self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
3137            }
3138        }
3139        self.push_rule(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
3140
3141        // The same cells the picture above was drawn from, published unwrapped
3142        // and unpadded for a frontend that lays them out in pixels.
3143        self.tables.push(TableInfo {
3144            rows_span: rows_start..self.rows.len(),
3145            end_src: node_end,
3146            // The *continuation* prefix: `pf` opens the block and only its first
3147            // row wears it, but every row of a grid is a continuation of the
3148            // block the table sits in.
3149            prefix: pc.to_vec(),
3150            grid: grid
3151                .into_iter()
3152                .zip(heads)
3153                .map(|(cells, head)| TableRow { head, cells })
3154                .collect(),
3155        });
3156        // The table's own end anchors whatever separator follows it; the border
3157        // rows deliberately don't move `last_off` (they hold no content).
3158        self.last_off = node_end;
3159    }
3160
3161    /// One row of laid-out cells, in column order.
3162    fn row_cells(&self, row: usize) -> Vec<TableCell> {
3163        // A cell is one source line, so a break within it is an explicit line
3164        // break (an inline `<br>`) that must render as a line of its own — not the
3165        // flow-folding space a break is in prose.
3166        self.break_glyph.set('\n');
3167        let cells = self
3168            .children(row)
3169            .into_iter()
3170            .filter(|&c| self.nodes[c].kind == Kind::Cell)
3171            .enumerate()
3172            .map(|(col, c)| {
3173                let n = &self.nodes[c];
3174                let style = if n.head.unwrap_or(false) {
3175                    Style::default().bold()
3176                } else {
3177                    Style::default()
3178                };
3179                // Only `content_span` bounds a cell's text, and an EMPTY cell
3180                // has none at all — twig records no interior for it — so both
3181                // offsets would fall back to the cell's `span.start`: on the
3182                // pipe that opens it, or (under a twig that gave every cell
3183                // the whole row's span) the row's start, where every empty
3184                // cell collapses onto one spot before the first `│` and a
3185                // caret there types *before* the table. Derive the interior
3186                // from the span's own pipes and this cell's column instead,
3187                // so each empty cell has a distinct, editable caret home.
3188                let span = n.content_span.clone().unwrap_or_else(|| {
3189                    let off = empty_cell_offset(
3190                        &self.source[n.span.start.min(self.source.len())
3191                            ..n.span.end.min(self.source.len())],
3192                        n.span.start,
3193                        col,
3194                    );
3195                    off..off
3196                });
3197                TableCell {
3198                    glyphs: self.inline_children(c, style),
3199                    start: span.start,
3200                    end: span.end,
3201                    align: n.alignment.unwrap_or(Alignment::Default),
3202                }
3203            })
3204            .collect();
3205        self.break_glyph.set(' ');
3206        cells
3207    }
3208
3209    /// A horizontal rule between/around rows — entirely decoration.
3210    fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3211        let glyphs = concat(prefix, &synth(text, Role::Rule, src));
3212        self.rows.push(VRow {
3213            glyphs,
3214            end_src: src,
3215            decoration: true,
3216            code: false,
3217            code_lang: None,
3218            directive: false,
3219            directive_label: None,
3220            media: None,
3221            task: None,
3222            leaf_directive: None,
3223            heading: None,
3224            boundary: None,
3225            mark_ends: Vec::new(),
3226        });
3227    }
3228
3229    /// One `│ a │ b │` row of the grid: real cell text between decoration.
3230    ///
3231    /// A row of cells is not a row of the screen — a cell wrapped to its column
3232    /// spans several, each one `│`-divided across the full width so the grid
3233    /// stays square. Cells in the same row are laid out independently and run
3234    /// out at their own heights; a column that has run dry pads out as
3235    /// decoration while its neighbours keep going.
3236    fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
3237        let fallback = cells.last().map(|c| c.end).unwrap_or(0);
3238        let laid: Vec<Vec<Vec<Glyph>>> = cells
3239            .iter()
3240            .enumerate()
3241            .map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
3242            .collect();
3243        let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
3244
3245        for j in 0..height {
3246            let mut glyphs = prefix.to_vec();
3247            for (ci, &w) in widths.iter().enumerate() {
3248                let cell = cells.get(ci);
3249                let line = laid.get(ci).and_then(|l| l.get(j));
3250                // The divider before this column belongs to the cell it
3251                // introduces, so clicking it lands in that cell — on this line
3252                // of it, which is what's next to the divider being clicked.
3253                let at = line
3254                    .and_then(|l| l.first().map(|g| g.src))
3255                    .or_else(|| cell.map(|c| c.start))
3256                    .unwrap_or(fallback);
3257                glyphs.extend(synth("│", Role::Rule, at));
3258                match (cell, line) {
3259                    (Some(cell), Some(line)) => {
3260                        let pad = w.saturating_sub(glyphs_width(line));
3261                        let (lead, trail) = match cell.align {
3262                            Alignment::Right => (pad, 0),
3263                            Alignment::Center => (pad / 2, pad - pad / 2),
3264                            Alignment::Left | Alignment::Default => (0, pad),
3265                        };
3266                        // Every line renders at least one space after its text
3267                        // (the gutter before `│`), so there is always somewhere
3268                        // to put the "after the last character" caret a line
3269                        // needs. It's the one padding glyph that is a stop: on
3270                        // the cell's last line that's the cell's end, and on any
3271                        // other it's the space the wrap consumed.
3272                        let last = laid[ci].len() == j + 1;
3273                        let end = match last {
3274                            true => cell.end,
3275                            false => line
3276                                .last()
3277                                .map(|g| g.src + g.ch.len_utf8())
3278                                .unwrap_or(cell.end),
3279                        };
3280                        glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
3281                        glyphs.extend(line.iter().cloned());
3282                        glyphs.push(Glyph {
3283                            ch: ' ',
3284                            style: Style::default(),
3285                            src: end,
3286                            stop: true,
3287                        });
3288                        glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
3289                    }
3290                    // A ragged row, or a column whose cell ended higher up: pad
3291                    // it out so the grid stays square.
3292                    _ => {
3293                        let at = cell.map(|c| c.end).unwrap_or(fallback);
3294                        glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
3295                    }
3296                }
3297            }
3298            glyphs.extend(synth("│", Role::Rule, fallback));
3299            // The row ends where its last stop does. A table row has no gap
3300            // between its final cell and the border, so inventing an end past
3301            // that would be a stop with nothing under it.
3302            let end_src = glyphs
3303                .iter()
3304                .rev()
3305                .find(|g| g.stop)
3306                .map_or(fallback, |g| g.src);
3307            let mark_ends = self.take_mark_ends(end_src);
3308            self.rows.push(VRow {
3309                glyphs,
3310                end_src,
3311                decoration: false,
3312                code: false,
3313                code_lang: None,
3314                directive: false,
3315                directive_label: None,
3316                media: None,
3317                task: None,
3318                leaf_directive: None,
3319                heading: None,
3320                boundary: None,
3321                mark_ends,
3322            });
3323        }
3324    }
3325
3326    /// Render a block-level image, video, or audio as one placeholder row: the
3327    /// `🖼 alt` / `🎬 alt` / `🔊 alt` label styled [`Role::Image`], every glyph
3328    /// mapped to the media's start offset and a caret stop there (they share the
3329    /// offset, so the stop table dedups them to a single home in front of it, as
3330    /// a rule's dashes do), and the row's end stop set past it so the caret can
3331    /// also rest after it. The row carries a [`MediaMark`] so [`media_spans`]
3332    /// publishes it as a [`MediaInfo`] a capable frontend replaces with the real
3333    /// picture or player; a plain surface paints the label as-is. `pf` is the
3334    /// block prefix (a list indent, a quote gutter) the row opens with, exactly
3335    /// as every other block honours it.
3336    fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
3337        let node = &self.nodes[img];
3338        let start = node.span.start;
3339        let end = node.span.end;
3340        // An `image`'s URL is twig's `destination`; a `<video>`/`<audio>` is a
3341        // generic element, so its URL is the `src` attribute — and may be absent
3342        // entirely, the element naming its candidates in child `<source>`s.
3343        let destination = match kind {
3344            MediaKind::Image => node.destination.clone().unwrap_or_default(),
3345            MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
3346        };
3347        let poster = match kind {
3348            MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
3349            MediaKind::Image | MediaKind::Audio => String::new(),
3350        };
3351        // The `<source>`s under the media element itself, not under `wrapper`: a
3352        // `<video>` is its own container, unlike an `<img>`, whose `<picture>`
3353        // alternatives are its *siblings* and so only reachable from the wrapper.
3354        let sources = match kind {
3355            MediaKind::Image => self.media_sources(wrapper),
3356            MediaKind::Video | MediaKind::Audio => self.media_sources(img),
3357        };
3358        let alt = self.image_alt(img);
3359        let sigil = kind.sigil();
3360        let label = if alt.is_empty() {
3361            // With no alt, name the file — but a `<video>` with neither `src` nor
3362            // alt has only its `<source>`s to be named by, so fall back to the
3363            // first candidate rather than labelling the row a bare sigil.
3364            let named = if destination.is_empty() {
3365                sources
3366                    .first()
3367                    .map(|s| s.srcset.as_str())
3368                    .unwrap_or_default()
3369            } else {
3370                &destination
3371            };
3372            format!("{sigil} {}", media_label(named))
3373        } else {
3374            format!("{sigil} {alt}")
3375        };
3376        let style = Style::default().role(Role::Image);
3377        let mut glyphs = pf.to_vec();
3378        for ch in label.chars() {
3379            glyphs.push(Glyph {
3380                ch,
3381                style,
3382                src: start,
3383                stop: true,
3384            });
3385        }
3386        // How many rows the frontend wants for this picture: the label row plus
3387        // the blank fillers below it. Absent (a GUI that lays images out in
3388        // pixels, an image that didn't resolve, or a plain surface) means the
3389        // bare one-row placeholder.
3390        let rows = self
3391            .media_rows
3392            .get(&destination)
3393            .copied()
3394            .unwrap_or(1)
3395            .max(1);
3396        // End past the image so the caret has a stop after it: the last glyph's
3397        // offset is the image *start*, not its extent, so `push_row`'s
3398        // last-glyph rule would strand the end stop inside the markup.
3399        self.push_row_at(glyphs, end);
3400        if let Some(row) = self.rows.last_mut() {
3401            row.media = Some(MediaMark {
3402                kind,
3403                destination,
3404                sources,
3405                alt,
3406                poster,
3407                rows,
3408            });
3409        }
3410        // Reserve the picture's remaining height as blank `decoration` rows: drawn
3411        // (so the frontend has the vertical room to paint the raster over them),
3412        // but holding no caret and contributing no stops — vertical motion steps
3413        // over them and the caret's only homes stay the stop in front of the image
3414        // and the one just past it, both on the label row above. They anchor at the
3415        // image's end offset so a click on the picture's lower half lands after it,
3416        // the nearest caret home. Mirrors how a table's box-rule rows reserve space
3417        // without ever holding the caret.
3418        for _ in 1..rows {
3419            self.rows.push(VRow {
3420                glyphs: Vec::new(),
3421                end_src: end,
3422                decoration: true,
3423                code: false,
3424                code_lang: None,
3425                directive: false,
3426                directive_label: None,
3427                media: None,
3428                task: None,
3429                leaf_directive: None,
3430                heading: None,
3431                boundary: None,
3432                mark_ends: Vec::new(),
3433            });
3434        }
3435        self.last_off = end;
3436    }
3437
3438    /// The `<picture>` alternatives inside block-image `wrapper`, in document
3439    /// order — every `<source>` element in its subtree. Empty when there's no
3440    /// `<picture>`. Each is a `<source>`'s `media` + `srcset`; core keeps them
3441    /// verbatim and picks none (see [`MediaSource`]). A `<source>` with no
3442    /// `srcset` is dropped (nothing to load); its `media` may be empty (an
3443    /// unconditional override), which a frontend treats as always-matching.
3444    ///
3445    /// It scans the wrapper's whole subtree (via the forward `first_child` /
3446    /// `next_sibling` links, the reliable ones) rather than the `<img>`'s parent,
3447    /// for two reasons. A `<picture>` reaches core in two shapes: twig promotes a
3448    /// block `<picture>` to an `element(picture)` wrapping `[source, img]`, but
3449    /// leaves an inline one's tags as raw siblings — `[raw "<picture>", source,
3450    /// img, raw "</picture>"]` — so the `<source>`s sit at different depths in
3451    /// the two. And the editor's flat arena leaves a promoted inline node's
3452    /// `parent` back-pointer dangling on a phantom root, so only the wrapper
3453    /// (known at the call site) is a trustworthy anchor. A block image is the
3454    /// sole visible content of its wrapper, so every `<source>` under it is its
3455    /// picture's.
3456    fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
3457        let mut out = Vec::new();
3458        self.collect_sources(wrapper, &mut out);
3459        out
3460    }
3461
3462    fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
3463        for c in self.children(id) {
3464            let node = &self.nodes[c];
3465            if node.name.as_deref() == Some("source") {
3466                // `<picture>` spells its candidate `srcset`, `<video>`/`<audio>`
3467                // spell it `src`. Both mean "the URL to load", so they normalise
3468                // onto one field; `srcset` wins where (illegally) both appear.
3469                let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
3470                if let Some(srcset) = url {
3471                    out.push(MediaSource {
3472                        media: attr_of(node, "media").unwrap_or_default(),
3473                        srcset,
3474                        mime: attr_of(node, "type").unwrap_or_default(),
3475                    });
3476                }
3477            }
3478            self.collect_sources(c, out);
3479        }
3480    }
3481
3482    /// The single block-level media `id`'s subtree resolves to, or `None`.
3483    ///
3484    /// A wrapper is a block picture when the only *visible* thing under it is one
3485    /// image: whitespace-only text and structure-only elements (a `<picture>`'s
3486    /// `<source>`, which declares an alternate but paints nothing) don't count,
3487    /// and the search descends through wrapping elements (`<picture>`, a linking
3488    /// `<a>`). This is what makes `<p><img></p>`, a bare `<img>`, and
3489    /// `<h1><picture>…<img></picture></h1>` all render as one framed picture.
3490    /// Any real text, or a second image, means it isn't image-only — it falls
3491    /// back to inline rendering, where the image still shows as its alt text.
3492    ///
3493    /// [`FlatNode`]'s snapshot doesn't carry an element's tag name, so a
3494    /// `<source>` can't be skipped by name — but it needs no special case:
3495    /// contributing no image and no text, it's simply invisible to the scan.
3496    fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
3497        let mut found = None;
3498        let mut count = 0usize;
3499        let mut has_text = false;
3500        self.scan_visual(id, &mut found, &mut count, &mut has_text);
3501        (count == 1 && !has_text).then(|| found.unwrap())
3502    }
3503
3504    /// Walk `id`'s subtree tallying visible leaves for [`media_only`]: each
3505    /// image, `<video>`, or `<audio>` (remembering the last, counting the total)
3506    /// and whether any non-whitespace text appears. Media isn't descended into —
3507    /// an image's inline children are alt text, and a `<video>`'s are its
3508    /// no-support fallback and its `<source>` declarations, none of which is
3509    /// document content.
3510    ///
3511    /// [`media_only`]: Self::media_only
3512    fn scan_visual(
3513        &self,
3514        id: usize,
3515        found: &mut Option<(usize, MediaKind)>,
3516        count: &mut usize,
3517        has_text: &mut bool,
3518    ) {
3519        for c in self.children(id) {
3520            let node = &self.nodes[c];
3521            match node.kind.as_str() {
3522                "image" => {
3523                    *found = Some((c, MediaKind::Image));
3524                    *count += 1;
3525                }
3526                // A `<video>`/`<audio>` reaches core as a generic `container`
3527                // (twig gives neither a semantic node, so `html_elements`
3528                // promotion leaves the tag name on `name`). Counted as media and
3529                // *not* descended into, so its `<source>` children and its
3530                // "your browser does not support…" fallback text neither add a
3531                // second count nor make the block look like text.
3532                "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3533                    let kind = match element_tag(node) {
3534                        Some("audio") => MediaKind::Audio,
3535                        _ => MediaKind::Video,
3536                    };
3537                    *found = Some((c, kind));
3538                    *count += 1;
3539                }
3540                // Text leaves: only non-whitespace counts as visible content.
3541                // (Twig keeps the whitespace `str`s between HTML tags — the
3542                // newlines and indentation inside a `<picture>` — as real nodes.)
3543                "str" | "smart_punctuation" | "verbatim" | "inline_math" => {
3544                    if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
3545                        *has_text = true;
3546                    }
3547                }
3548                // Structural breaks carry no visible glyph of their own.
3549                "soft_break" | "hard_break" | "non_breaking_space" => {}
3550                // Any other wrapper (emphasis, a link, a `<picture>`) is
3551                // transparent to the scan — descend into it.
3552                _ => self.scan_visual(c, found, count, has_text),
3553            }
3554        }
3555    }
3556
3557    /// A leaf directive (`::name{…}`) as one placeholder row — the
3558    /// [`block_media`](Self::block_media) recipe, for the same reason: it is a
3559    /// block that renders as *a thing*, not as text, and the frontend paints
3560    /// whatever the host app's vocabulary makes of it.
3561    ///
3562    /// The row's glyphs are a `⧉ label` (or `⧉ name`) stand-in a plain surface
3563    /// paints as-is, every glyph anchored at the directive's start with a caret
3564    /// stop there, and the row ending past it so the caret can also rest after
3565    /// it. It carries a [`DirectiveMark`] for [`directive_spans`], and is marked
3566    /// [`directive`](VRow::directive) so a frontend already drawing the
3567    /// container form's panel frames this one identically for free.
3568    ///
3569    /// Before this, a leaf directive emitted no rows at all: it was invisible,
3570    /// held no caret, and vertical motion crossed a void where it stood.
3571    fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
3572        let node = &self.nodes[id];
3573        let (start, end) = (node.span.start, node.span.end);
3574        let name = node.name.clone().unwrap_or_default();
3575        let attrs = node.attrs.clone();
3576        let label = self.image_alt(id); // its `[label]` children, flattened
3577        let shown = if label.is_empty() { &name } else { &label };
3578        let style = Style::default().role(Role::Image);
3579        let mut glyphs = pf.to_vec();
3580        for ch in format!("⧉ {shown}").chars() {
3581            glyphs.push(Glyph {
3582                ch,
3583                style,
3584                src: start,
3585                stop: true,
3586            });
3587        }
3588        // End past the directive so the caret has a stop after it — the same
3589        // reason `block_media` anchors its row at the image's end.
3590        self.push_row_at(glyphs, end);
3591        if let Some(row) = self.rows.last_mut() {
3592            row.directive = true;
3593            row.leaf_directive = Some(DirectiveMark {
3594                name,
3595                attrs,
3596                label,
3597                rows: 1,
3598            });
3599        }
3600        self.last_off = end;
3601    }
3602
3603    /// An image's alt text: the flattened text of its inline descendants (an
3604    /// image's children *are* its alt content), empty when it has none. Also a
3605    /// leaf directive's `[label]`, which is the same shape — inline children
3606    /// standing for the block.
3607    fn image_alt(&self, id: usize) -> String {
3608        let mut out = String::new();
3609        self.collect_text(id, &mut out);
3610        out
3611    }
3612
3613    /// Append every descendant's `text` to `out`, in document order. Inline text
3614    /// (`str`) nodes are leaves, so a node never contributes both its own text and
3615    /// a child's — no double counting.
3616    fn collect_text(&self, id: usize, out: &mut String) {
3617        for c in self.children(id) {
3618            if let Some(t) = &self.nodes[c].text {
3619                out.push_str(t);
3620            }
3621            self.collect_text(c, out);
3622        }
3623    }
3624
3625    fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
3626        let mut out = Vec::new();
3627        for c in self.children(id) {
3628            self.inline(c, base, &mut out);
3629        }
3630        out
3631    }
3632
3633    /// [`inline_children`](Self::inline_children) plus any trailing whitespace the
3634    /// block carries past its inline content (see [`trailing_ws_glyphs`]). Used
3635    /// for the leaf inline blocks — paragraphs and headings — whose own `span`
3636    /// bounds exactly one line of text, so the trailing gap is theirs. *Not* for
3637    /// a table cell, whose `span` is the whole row and would swallow the
3638    /// delimiters and neighbours between it and the row's end.
3639    ///
3640    /// [`trailing_ws_glyphs`]: Self::trailing_ws_glyphs
3641    fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
3642        let mut out = self.inline_children(id, base);
3643        out.extend(self.trailing_ws_glyphs(id, base));
3644        out
3645    }
3646
3647    /// Glyphs for whatever trailing whitespace a block's source carries past its
3648    /// last inline node — the space(s) at the end of `hello ` that Markdown and
3649    /// Djot drop from the `str` node as insignificant. twig still records them:
3650    /// a block's `content_span` ends at its last meaningful character while its
3651    /// `span` runs to the end of the line's text (before the terminating
3652    /// newline), so the gap between the two *is* that trailing whitespace.
3653    ///
3654    /// Emitting it as real caret-stop glyphs is what lets the caret be drawn
3655    /// past the last visible character. Without it, typing a space at the end of
3656    /// a paragraph moved the caret in the source but not on screen — the caret
3657    /// stuck on the last glyph until the next visible character reparsed the
3658    /// space into an interior `str` node that finally carried it.
3659    ///
3660    /// Restricted to spaces: only they are safe to synthesize one-cell-per-byte,
3661    /// and only they are what the parser silently strips. Anything else in the
3662    /// gap means the span accounting isn't what this assumes, so it's left alone.
3663    fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
3664        let node = &self.nodes[id];
3665        let Some(content) = &node.content_span else {
3666            return Vec::new();
3667        };
3668        let (from, to) = (content.end, node.span.end);
3669        let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
3670            return Vec::new();
3671        };
3672        if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
3673            return Vec::new();
3674        }
3675        slice
3676            .bytes()
3677            .enumerate()
3678            .map(|(i, _)| Glyph {
3679                ch: ' ',
3680                style,
3681                src: from + i,
3682                stop: true,
3683            })
3684            .collect()
3685    }
3686
3687    fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
3688        let node = &self.nodes[id];
3689        match node.kind.as_str() {
3690            "str" | "smart_punctuation" => push_escaped_text(
3691                out,
3692                node.text.as_deref().unwrap_or(""),
3693                node.span.clone(),
3694                self.source,
3695                base,
3696            ),
3697            "soft_break" | "hard_break" | "non_breaking_space" => {
3698                // A break renders as a real, caret-navigable glyph — but twig
3699                // gives it no span of its own (`0..0`), so the offset comes from
3700                // the text in front of it: one *past* the last glyph, which is
3701                // the newline the break stands for. Past, not on: sharing the
3702                // previous glyph's offset would put two stops on one byte, and a
3703                // caret that can't change offset can't move.
3704                let src = if node.span.start != 0 {
3705                    node.span.start
3706                } else {
3707                    out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
3708                };
3709                // A *hard* break renders as this run's break glyph — a newline
3710                // inside a table cell (its own line), the same space in prose the
3711                // frontend re-wraps. A soft break normally folds into a space;
3712                // under `LineFlow::Preserve` it renders as a `'\n'` too, so the
3713                // author's line break shows where it was written. Never inside a
3714                // cell (`break_glyph` is `'\n'` there): a cell is one line and
3715                // folds its own soft breaks regardless.
3716                let ch = if node.kind == Kind::HardBreak {
3717                    self.break_glyph.get()
3718                } else if node.kind == Kind::SoftBreak
3719                    && self.preserve_soft
3720                    && self.break_glyph.get() == ' '
3721                {
3722                    '\n'
3723                } else {
3724                    ' '
3725                };
3726                out.push(Glyph {
3727                    ch,
3728                    style: base,
3729                    src,
3730                    stop: true,
3731                });
3732            }
3733            // A cell's only spelling for an in-line break is a raw `<br>`; read it
3734            // back as one (outside a cell it stays the literal text it falls to
3735            // below). The tag's bytes carry no stop of their own — the line it
3736            // ends stops just before it, the next just after.
3737            "raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
3738                out.push(Glyph {
3739                    ch: '\n',
3740                    style: base,
3741                    src: node.span.start,
3742                    stop: true,
3743                });
3744            }
3745            "emph" => self.inline_delimited(id, base.italic(), out),
3746            "strong" => self.inline_delimited(id, base.bold(), out),
3747            // A coloured highlight's emoji is spelling, not content: twig strips
3748            // it and records the colour on the node, so the glyphs are the
3749            // author's words and the colour rides the role. Revealed markup
3750            // still shows the emoji, because `delims` reads the source bytes
3751            // between the span and the content span — which is exactly the
3752            // `==🔴 ` the author typed.
3753            "mark" => {
3754                let color = MarkColor::from_attrs(&node.attrs);
3755                self.inline_delimited(id, base.role(Role::Mark(color)), out)
3756            }
3757            "insert" => self.inline_delimited(id, base.underline(), out),
3758            "delete" => self.inline_delimited(id, base.strikethrough(), out),
3759            // The one pair whose whole meaning is *where the glyphs sit*. Drawn
3760            // in the surrounding style otherwise, so `^**2**^` stays bold and a
3761            // superscript inside a heading keeps the heading's role — which is
3762            // exactly why this is a `Baseline` and not a `Role`.
3763            "superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
3764            "subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
3765            "verbatim" | "inline_math" => {
3766                // The interior begins at `content_span.start` — past however many
3767                // backticks the fence used, which `span.start + 1` only guessed
3768                // right for a single one. Fall back to that guess if it's absent.
3769                let at = node
3770                    .content_span
3771                    .as_ref()
3772                    .map_or(node.span.start + 1, |c| c.start);
3773                let style = base.role(Role::Code);
3774                // Not `inline_delimited`: verbatim has no child nodes to recurse
3775                // into — its content is its own `text` — so the fences bracket a
3776                // `push_text` instead. The fences themselves keep `Role::Code`'s
3777                // sibling treatment via `push_delim`'s role override.
3778                let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
3779                if let Some((open, _)) = &show {
3780                    self.push_delim(out, open, style);
3781                }
3782                push_text(out, node.text.as_deref().unwrap_or(""), at, style);
3783                match &show {
3784                    Some((_, close)) => self.push_delim(out, close, style),
3785                    None => self.note_mark_end(id),
3786                }
3787            }
3788            // A text directive (`:name[label]{…}`) — the inline form of a generic
3789            // directive. Its `[label]` children are the visible text; the name and
3790            // the `{…}` attributes are the host app's vocabulary (diaryx's
3791            // `:vis[…]`) and stay hidden markup, exactly as a link's `](dest)` is.
3792            // Drawn in the surrounding style: a role of its own would need one
3793            // every frontend maps, and the bug this fixes is that the text was
3794            // invisible, not that it was unstyled.
3795            "container" if container_is_directive(node) && !self.children(id).is_empty() => {
3796                self.recurse(id, base, out)
3797            }
3798            // No `[label]`, so there are no children to render and recursing
3799            // emitted *nothing*: the directive's bytes vanished from the document
3800            // and left no caret stop behind. What to draw instead turns on
3801            // whether the syntax looks deliberate.
3802            //
3803            // Bare `:word` almost never is. twig matches a colon followed by any
3804            // letter-led word (`scanTextDirective`, deliberately matching remark),
3805            // so ordinary prose is full of them — `:see below`, a `:smile:`
3806            // shortcode, a stray colon before a word. Those are prose, and prose
3807            // renders as itself: every byte visible, every byte a caret stop, so a
3808            // colon typed by accident can be seen and deleted. Hiding them behind
3809            // a placeholder would be the invisible-and-unreachable failure this
3810            // arm exists to fix, just wearing a nicer glyph.
3811            "container" if container_is_directive(node) && node.attrs.is_empty() => {
3812                let span = node.span.clone();
3813                push_text(
3814                    out,
3815                    self.source.get(span.clone()).unwrap_or(""),
3816                    span.start,
3817                    base,
3818                );
3819            }
3820            // `{…}` attributes, though, are unmistakably deliberate — nobody
3821            // types `:vis{.family}` by accident, and diaryx writes exactly that
3822            // inline. So an attribute-bearing directive with no label draws as a
3823            // chip on `block_directive`'s recipe (`⧉ name attrs`, `Role::Image`),
3824            // the inline peer of the leaf form's placeholder row.
3825            //
3826            // Only the first glyph is a caret stop, and the whole chip shares the
3827            // directive's start offset: the caret treats it as one atomic thing
3828            // rather than walking hidden markup a byte at a time, and a paragraph
3829            // holding nothing but a chip still has a stop to be navigated to.
3830            "container" if container_is_directive(node) => {
3831                let start = node.span.start;
3832                let name = node.name.clone().unwrap_or_default();
3833                let shown = match directive_attr_label(&node.attrs) {
3834                    Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
3835                    Some(attrs) => format!("⧉ {attrs}"),
3836                    None => format!("⧉ {name}"),
3837                };
3838                let style = base.role(Role::Image);
3839                for (i, ch) in shown.chars().enumerate() {
3840                    out.push(Glyph {
3841                        ch,
3842                        style,
3843                        src: start,
3844                        stop: i == 0,
3845                    });
3846                }
3847            }
3848            // A footnote reference (`[^1]`). The label bracketed is what a reader
3849            // needs — bare, `note1` reads as a typo rather than a reference — so
3850            // the `^` is hidden as the spelling artefact it is (a link's
3851            // `](dest)` goes the same way) and the brackets are kept as
3852            // decoration: one shared offset, never a caret stop, like a table's
3853            // borders, so the caret walks the label alone.
3854            //
3855            // Styled `Role::Link`: a reference *is* a link to its definition, and
3856            // every frontend already paints that role. A role of its own would
3857            // need one in each of them, and what a frontend needs to tell the two
3858            // apart is not a paint colour but an answer to "what does clicking
3859            // here do" — which is [`Doc::footnote_at_caret`]'s job, not a glyph's.
3860            //
3861            // Raised, though, because that a reference is *set* differently from
3862            // the prose it interrupts is exactly what makes it read as a
3863            // reference. `[1]` at body size reads as bracketed text.
3864            "footnote_reference" => {
3865                let style = base.role(Role::Link);
3866                // Revealed, the reference is just its source bytes: the `^` that
3867                // is normally elided comes back and every byte becomes a real
3868                // stop, so the brackets stop being decoration and start being
3869                // text. That's the whole point of the mode, and it replaces the
3870                // hand-built chip below rather than decorating it — including the
3871                // raised baseline, since what's on screen there is source, and
3872                // source is set as prose.
3873                if self.revealed(&node.span) {
3874                    self.push_delim(out, &node.span, style);
3875                    return;
3876                }
3877                let style = style.baseline(Baseline::Super);
3878                // The label's own span, so its glyphs map to their true bytes.
3879                // Absent one, it starts past the `[^` that opens the reference.
3880                let (label, at) = match &node.content_span {
3881                    Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
3882                    None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
3883                };
3884                out.push(Glyph {
3885                    ch: '[',
3886                    style,
3887                    src: node.span.start,
3888                    stop: false,
3889                });
3890                push_text(out, label, at, style);
3891                out.push(Glyph {
3892                    ch: ']',
3893                    style,
3894                    src: node.span.end.saturating_sub(1),
3895                    stop: false,
3896                });
3897            }
3898            "link" | "url" | "email" => {
3899                let style = base.role(Role::Link);
3900                if self.children(id).is_empty() {
3901                    // A bare autolink (`<a@b.c>`, a naked URL): the destination
3902                    // *is* the visible text, so there is nothing elided to
3903                    // reveal and both modes draw the same thing.
3904                    push_text(
3905                        out,
3906                        node.destination
3907                            .as_deref()
3908                            .or(node.text.as_deref())
3909                            .unwrap_or("link"),
3910                        node.span.start,
3911                        style,
3912                    );
3913                } else {
3914                    // An inline link reveals asymmetrically — `[` before the
3915                    // label, `](dest)` after it — which the generic
3916                    // span-minus-content derivation already produces.
3917                    self.inline_delimited(id, style, out);
3918                }
3919            }
3920            _ => {
3921                if self.children(id).is_empty() {
3922                    if let Some(t) = &node.text {
3923                        push_text(out, t, node.span.start, base);
3924                    }
3925                } else {
3926                    self.recurse(id, base, out);
3927                }
3928            }
3929        }
3930    }
3931
3932    fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
3933        for c in self.children(id) {
3934            self.inline(c, style, out);
3935        }
3936    }
3937
3938    /// Lay a block's inline `glyphs` into visual rows, prefixing the first with
3939    /// `pf` and the rest with `pc`. A preserved soft break arrives as a `'\n'`
3940    /// glyph (see the `soft_break` arm): a hard row boundary that splits the
3941    /// glyphs so each run lays out on its own and the author's line structure
3942    /// shows on screen. The `'\n'` is dropped from the row it closes and its
3943    /// source offset becomes that row's end stop — exactly how a table cell's
3944    /// in-line `<br>` is handled — so the caret can rest at the line's end
3945    /// without a zero-width control char leaking into what the frontends render.
3946    /// With no `'\n'` present (the folding default, and every build that isn't
3947    /// `LineFlow::Preserve`) there is one run and this is byte-identical to
3948    /// laying the glyphs out directly.
3949    fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
3950        if !glyphs.iter().any(|g| g.ch == '\n') {
3951            self.emit_line(glyphs, block_start, pf, pc, None);
3952            return;
3953        }
3954        // Each run up to a '\n' is a line of its own: the first wears the block's
3955        // opening prefix, every later one the continuation prefix, and the break's
3956        // own offset ends the run's last row. The break glyph is dropped. A
3957        // trailing '\n' flushes its run and leaves nothing behind, so no spurious
3958        // blank row follows it.
3959        let mut run: Vec<Glyph> = Vec::new();
3960        let mut first = true;
3961        for g in glyphs {
3962            if g.ch == '\n' {
3963                let lead = if first { pf } else { pc };
3964                self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
3965                first = false;
3966            } else {
3967                run.push(g);
3968            }
3969        }
3970        if !run.is_empty() {
3971            let lead = if first { pf } else { pc };
3972            self.emit_line(run, block_start, lead, pc, None);
3973        }
3974    }
3975
3976    /// Word-wrap a single line of `glyphs` (no interior line breaks) to the
3977    /// available width and push the visual rows, prefixing the first with `pf`
3978    /// and the rest with `pc`. `end`, when set, is the source offset that ends
3979    /// the line's final row — the offset of the break that terminated it, which
3980    /// the caller has already stripped from `glyphs`; when `None` the row ends
3981    /// just past its last glyph, as an unbroken block's does.
3982    fn emit_line(
3983        &mut self,
3984        glyphs: Vec<Glyph>,
3985        block_start: usize,
3986        pf: &[Glyph],
3987        pc: &[Glyph],
3988        end: Option<usize>,
3989    ) {
3990        // The line's final row ends at `end` when a break gave one, else just
3991        // past its last glyph (`push_row`'s default).
3992        let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
3993            Some(e) => b.push_row_at(row, e),
3994            None => b.push_row(row, block_start),
3995        };
3996
3997        // No column budget: emit the whole line as one row and let the frontend
3998        // wrap it at its own (pixel) width.
3999        let Some(width) = self.wrap else {
4000            let row = if glyphs.is_empty() {
4001                pf.to_vec()
4002            } else {
4003                concat(pf, &glyphs)
4004            };
4005            push_last(self, row);
4006            return;
4007        };
4008
4009        // Split into words (maximal non-space runs), each carrying the space
4010        // glyph that followed it (so its source offset is preserved).
4011        let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4012        let mut word: Vec<Glyph> = Vec::new();
4013        for g in glyphs {
4014            if g.ch == ' ' {
4015                words.push((std::mem::take(&mut word), Some(g)));
4016            } else {
4017                word.push(g);
4018            }
4019        }
4020        if !word.is_empty() {
4021            words.push((word, None));
4022        }
4023        if words.is_empty() {
4024            // An empty block (or an empty preserved line) still occupies one
4025            // (prefixed) row.
4026            push_last(self, pf.to_vec());
4027            return;
4028        }
4029
4030        let mut line: Vec<Glyph> = Vec::new();
4031        let mut used = 0usize;
4032        let mut first = true;
4033        for (w, space) in words {
4034            let avail = width
4035                .saturating_sub(prefix_width(if first { pf } else { pc }))
4036                .max(1);
4037            let cells = glyphs_width(&w);
4038            if used > 0 && used + cells > avail {
4039                let row = concat(if first { pf } else { pc }, &line);
4040                self.push_row(row, block_start);
4041                line = Vec::new();
4042                used = 0;
4043                first = false;
4044            }
4045            used += cells;
4046            line.extend(w);
4047            if let Some(sp) = space {
4048                used += 1;
4049                line.push(sp);
4050            }
4051        }
4052        let row = concat(if first { pf } else { pc }, &line);
4053        push_last(self, row);
4054    }
4055
4056    /// The source offset of each line of a code block's `text`.
4057    ///
4058    /// `content` is the block's `content_span` — where twig says the body lives
4059    /// in the source, fences already excluded. Its lines run 1:1 with the
4060    /// rendered `text` lines, so no search is needed; each is anchored at the
4061    /// *end* of its source line, which places it past whatever indent `text` had
4062    /// stripped (a fenced block's fences, an indented one's leading spaces)
4063    /// without having to know how much there was.
4064    ///
4065    /// `None` when the body and the rendered lines don't line up — a coarse
4066    /// fallback the caller turns into the block's start offset.
4067    fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
4068        let mut src_lines: Vec<(usize, &str)> = Vec::new();
4069        let mut at = content.start;
4070        for l in self.source.get(content.start..content.end)?.split('\n') {
4071            src_lines.push((at, l));
4072            at += l.len() + 1;
4073        }
4074        if src_lines.len() != lines.len() {
4075            return None;
4076        }
4077        Some(
4078            lines
4079                .iter()
4080                .zip(&src_lines)
4081                .map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
4082                .collect(),
4083        )
4084    }
4085
4086    fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
4087        // Step past the character the *source* holds at the last glyph's offset,
4088        // not past the glyph's own `ch`. The two agree for ordinary text, but a
4089        // glyph is not always the character it stands on: `synth` decoration and
4090        // a substituted run (an image's `⧉ label`) share one offset by design.
4091        // Trusting `ch` there yields an offset inside a multi-byte character,
4092        // which every later slice of `source` panics on.
4093        let end_src = glyphs
4094            .last()
4095            .map(|g| {
4096                let at = g.src.min(self.source.len());
4097                at + self.source[at..].chars().next().map_or(0, char::len_utf8)
4098            })
4099            .unwrap_or(fallback);
4100        self.push_row_at(glyphs, end_src);
4101    }
4102
4103    /// Push a row with an explicit end stop, for content that knows its own
4104    /// extent better than its last glyph does.
4105    fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
4106        self.last_off = end_src;
4107        let mark_ends = self.take_mark_ends(end_src);
4108        self.rows.push(VRow {
4109            glyphs,
4110            end_src,
4111            decoration: false,
4112            code: false,
4113            code_lang: None,
4114            directive: false,
4115            directive_label: None,
4116            media: None,
4117            task: None,
4118            leaf_directive: None,
4119            heading: None,
4120            boundary: None,
4121            mark_ends,
4122        });
4123    }
4124
4125    /// The quote's own trailing marker lines: the `>` / `> ` lines that lie past
4126    /// its last child but inside its span, one gutter row each.
4127    ///
4128    /// Pressing Enter at the end of `> a` writes `> a\n>\n> \n` — twig's
4129    /// spelling, and the right one. Those last two lines hold no block (a
4130    /// `block_quote`'s `content_span` still stops at its last child) so the
4131    /// children walk never reaches them, and they used to fall all the way to
4132    /// the document-level [`Builder::emit_trailing_blank_lines`], which knows no
4133    /// prefix: the gutter simply stopped, and a writer adding a line to a quote
4134    /// watched it draw as plain prose.
4135    ///
4136    /// This is only answerable since twig 3.2.0, where a Markdown `block_quote`'s
4137    /// span covers its own trailing marker lines (it reported `0..3` for that
4138    /// source and now reports `0..8`). Before that the lines belonged to no node
4139    /// at any level, and the only way to draw them was to sniff `>` off the raw
4140    /// source and re-derive the nesting depth by counting markers — format
4141    /// inference this crate exists to keep out of the render path.
4142    ///
4143    /// Each row is a real caret home rather than a decoration gap: the writer
4144    /// spelled every one of these lines with a marker of its own, so each is a
4145    /// line of the quote to stand on, not the spacing between two blocks (which
4146    /// is [`Builder::emit_separators_before`]'s, and falls *between* children
4147    /// where this never looks).
4148    fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
4149        let end = end.min(self.source.len());
4150        let mut at = self.rows.last().map_or(0, |r| r.end_src);
4151        // Walk line by line from the last child's end to the quote's, taking each
4152        // line's *end* as the row's offset — the caret home at the end of a line
4153        // is where one on an empty quoted line belongs, and it keeps every row's
4154        // offset distinct from its neighbours'.
4155        while at < end {
4156            let Some(k) = self.source[at..end].find('\n') else {
4157                break;
4158            };
4159            let line_start = at + k + 1;
4160            let line_end = self.source[line_start..end]
4161                .find('\n')
4162                .map_or(end, |i| line_start + i);
4163            self.push_row_at(pc.to_vec(), line_end);
4164            at = line_end;
4165        }
4166    }
4167
4168    /// The source offset the caret rests at on the blank line separating a block
4169    /// that ends at `prev_end` from the next block starting at `next_start`:
4170    /// just past the newline that terminates the previous block, but kept
4171    /// strictly before the next block so the offset is unique to this row.
4172    fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
4173        let after_nl = self.source[prev_end..]
4174            .find('\n')
4175            .map_or(prev_end, |p| prev_end + p + 1);
4176        after_nl.min(next_start.saturating_sub(1)).max(prev_end)
4177    }
4178
4179    /// The source offset of each blank row between a block ending at `prev_end`
4180    /// and content starting at `next_start` — one per blank source line. The
4181    /// first newline terminates the previous block's line; every line it opens up
4182    /// to (but not including) the line that holds `next_start` is a blank row the
4183    /// caret can occupy. Offsets are unique and ascending so `pos_of_offset`
4184    /// resolves each to its own row. Empty when the two blocks are tight (no
4185    /// blank line between them).
4186    fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
4187        // Spans aren't always in tidy source order (e.g. a block after
4188        // frontmatter can start *before* the previous block's rendered content
4189        // ends). There's no blank line to place then — fall back to the clamped
4190        // single separator (an empty return) rather than slicing an inverted
4191        // range.
4192        if next_start <= prev_end {
4193            return Vec::new();
4194        }
4195        let gap = &self.source[prev_end..next_start];
4196        let Some(nl) = gap.find('\n') else {
4197            return Vec::new();
4198        };
4199        // The line holding `next_start` belongs to the next block; blank rows
4200        // stop before it.
4201        let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
4202        let mut offs = Vec::new();
4203        let mut start = prev_end + nl + 1;
4204        while start < next_line_start {
4205            offs.push(start);
4206            match self.source[start..next_start].find('\n') {
4207                Some(k) => start += k + 1,
4208                None => break,
4209            }
4210        }
4211        offs
4212    }
4213
4214    /// Blank lines the user typed past the end of the last block (e.g. two
4215    /// `Enter`s to open a fresh paragraph) leave no AST node, so nothing renders
4216    /// and the caret appears stuck on the old line. Reconstruct one empty row
4217    /// per extra trailing newline from the source, each at its own offset, so
4218    /// the caret rides down onto the new line the moment it's created.
4219    ///
4220    /// `above` is the class of the last block in the document — the one this gap
4221    /// closes. A document with no blocks at all has nothing above these rows, and
4222    /// [`BlockClass::Paragraph`] is the honest answer there too: what they are is
4223    /// empty paragraphs, on both sides of the gap.
4224    fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
4225        // With no rows at all the count starts past any hidden frontmatter, not
4226        // at 0: its newlines are not trailing blank lines, and counting them
4227        // opened phantom rows *inside* the metadata for a frontmatter-only file.
4228        //
4229        // Or past the last hidden block, if that is later: a closing comment
4230        // draws no row, and its lines are not blank lines the author opened.
4231        let last_end = self
4232            .rows
4233            .last()
4234            .map_or(hidden_end, |r| r.end_src)
4235            .max(self.stepped_over);
4236        if last_end >= self.source.len() {
4237            return;
4238        }
4239        // The first newline after the last content just terminates that line, so
4240        // a lone trailing `\n` (an ordinary file ending) opens no blank row. A
4241        // *second* newline opens an empty paragraph: render it the way a block
4242        // boundary is rendered — a blank spacer row, then the empty paragraph row
4243        // the caret rests on — so the just-pressed-Enter view already shows the
4244        // gap it will keep once text is typed, and typing doesn't shift the line
4245        // down. One row per trailing newline (each its own caret offset), the
4246        // last landing at the document end where the caret sits.
4247        let extra = self.source[last_end..].matches('\n').count();
4248        if extra < 2 {
4249            return;
4250        }
4251        for k in 1..=extra {
4252            self.rows.push(VRow {
4253                glyphs: Vec::new(),
4254                end_src: last_end + k,
4255                // As between two blocks: the first blank row is the gap that
4256                // closes the block above, not somewhere to type. Nothing follows
4257                // to need a gap of its own, though, so every row after it is a
4258                // real empty paragraph — the end of the document bounds the last
4259                // one the way a following block would. Preserve flow makes even
4260                // that first row navigable, as it does every blank line.
4261                decoration: !self.preserve_soft && k == 1,
4262                code: false,
4263                code_lang: None,
4264                directive: false,
4265                directive_label: None,
4266                media: None,
4267                task: None,
4268                leaf_directive: None,
4269                heading: None,
4270                // The one drawn row here is a block boundary like any other —
4271                // "rendered the way a block boundary is rendered" is the whole
4272                // point of it — so it says so, and a frontend spacing boundaries
4273                // spaces this one the same. The rows below it are navigable empty
4274                // paragraphs, not gaps.
4275                boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
4276                    above,
4277                    below: BlockClass::Paragraph,
4278                }),
4279                mark_ends: Vec::new(),
4280            });
4281        }
4282    }
4283}
4284
4285// ── display width ────────────────────────────────────────────────────────────
4286//
4287// Two things a row can be counted in, and they are not the same number:
4288//
4289//   *glyphs*, one per codepoint — how the text is stored here, and what an
4290//   index into `VRow::glyphs` means; and
4291//   *columns*, one per terminal cell — where the text is drawn, and what every
4292//   `col` in this crate means.
4293//
4294// `你` is one glyph in two columns. Counting columns with `glyphs.len()` (or,
4295// in the source view, `chars().count()`) is the same number only for the ASCII
4296// that most fixtures are written in, and drifts one cell per wide character
4297// everywhere else — the caret drawn a column short of the text it types into.
4298// Everything below converts between the two; nothing else should have to.
4299
4300/// The display width of `s` in terminal cells.
4301///
4302/// Measured per grapheme cluster, because that is the unit a surface advances
4303/// by: `👨‍👩‍👧` is five codepoints measuring 2 + 0 + 2 + 0 + 2 cells one at a
4304/// time, but the character they spell is drawn in 2. Both frontends already
4305/// measure it that way — ratatui asks `unicode-width` per cluster, and the GUI
4306/// asks its own text system — so the caret only lands where the text is if this
4307/// agrees with them.
4308pub fn text_width(s: &str) -> usize {
4309    UnicodeWidthStr::width(s)
4310}
4311
4312/// One grapheme cluster of a laid-out row: the glyphs that spell it, and the
4313/// cells it is drawn in.
4314///
4315/// The cluster, not the glyph, is what has a width. A row's glyphs are one per
4316/// codepoint, so an accented letter or an emoji is several of them drawn in one
4317/// character's worth of cells — the glyph that opens the cluster claims those
4318/// cells, and the ones continuing it are drawn *inside* them rather than beside
4319/// them. It's the same cluster the stop table is built on: the opening glyph is
4320/// the one a caret can rest on, and so the only one whose column it can be
4321/// drawn at.
4322struct Cluster {
4323    /// Index of the glyph that opens it.
4324    glyph: usize,
4325    /// The display column it starts at.
4326    col: usize,
4327    /// How many cells it is drawn in. Zero for a cluster with no width of its
4328    /// own (a lone joiner), which therefore sits at no column at all.
4329    cells: usize,
4330}
4331
4332/// Walk a row's glyphs as the clusters they spell, in column order.
4333fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
4334    let text: String = glyphs.iter().map(|g| g.ch).collect();
4335    let mut out = Vec::new();
4336    let (mut glyph, mut col) = (0, 0);
4337    for cluster in text.graphemes(true) {
4338        let cells = text_width(cluster);
4339        out.push(Cluster { glyph, col, cells });
4340        // One glyph per codepoint, so a cluster spans exactly its own.
4341        glyph += cluster.chars().count();
4342        col += cells;
4343    }
4344    out
4345}
4346
4347/// The display width of a run of glyphs.
4348fn glyphs_width(glyphs: &[Glyph]) -> usize {
4349    clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
4350}
4351
4352/// A cell's display width — the widest of its lines, since an in-cell `\n` break
4353/// splits it into several. Sizes the column that must hold every line.
4354fn cell_width(glyphs: &[Glyph]) -> usize {
4355    glyphs
4356        .split(|g| g.ch == '\n')
4357        .map(glyphs_width)
4358        .max()
4359        .unwrap_or(0)
4360}
4361
4362/// Whether a raw inline HTML tag is a line break (`<br>`, `<br/>`, `<br />`,
4363/// case-insensitively) — the one tag a table cell reads as an in-cell break.
4364fn is_br(text: Option<&str>) -> bool {
4365    let Some(t) = text else { return false };
4366    matches!(
4367        t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
4368        "<br>" | "<br/>"
4369    )
4370}
4371
4372impl VRow {
4373    /// The row's width in display columns — and so the column of the caret
4374    /// placed past its last glyph, which is the rightmost column it can occupy.
4375    fn width(&self) -> usize {
4376        glyphs_width(&self.glyphs)
4377    }
4378
4379    /// The display column glyph `i` is drawn at. Glyphs continuing a cluster
4380    /// report the column of the glyph that opened it, since that is where they
4381    /// are drawn; none of them is ever a stop, so no caret is placed by it.
4382    fn col_of_glyph(&self, i: usize) -> usize {
4383        clusters(&self.glyphs)
4384            .iter()
4385            .rev()
4386            .find(|c| c.glyph <= i)
4387            .map_or(0, |c| c.col)
4388    }
4389
4390    /// The glyph drawn at display column `col`, or `None` past the row's last
4391    /// cell.
4392    ///
4393    /// A column landing on the *second* cell of a wide glyph resolves to that
4394    /// glyph: half a character is not a place to be, so clicking either cell of
4395    /// `你` means `你`, and the caret comes to rest at its start — the column it
4396    /// would be drawn at anyway. That rule is what makes the mapping invertible:
4397    /// every offset has one column, and every column has one offset.
4398    fn glyph_at_col(&self, col: usize) -> Option<usize> {
4399        clusters(&self.glyphs)
4400            .into_iter()
4401            .find(|c| col < c.col + c.cells)
4402            .map(|c| c.glyph)
4403    }
4404}
4405
4406// ── helpers ──────────────────────────────────────────────────────────────────
4407
4408/// The caret home inside an *empty* table cell (`col`, 0-based) whose node
4409/// `span` is `src` starting at byte `start`. twig gives an empty cell no
4410/// `content_span`, so its interior is read from the pipes: the home is one
4411/// space past the pipe that opens the cell — mimicking the `| ` padding a
4412/// filled cell has — and never at or past the pipe that closes it. So
4413/// `|  |  |` gives the two cells distinct, editable homes instead of both
4414/// collapsing onto the row's start.
4415///
4416/// Two shapes of span are read. A twig from 3.3.3 gave every cell of a row
4417/// the *row's* span, so the cell's own pipes are the `col`-th and
4418/// `col+1`-th unescaped `|` in it; a later twig spans a cell from the pipe
4419/// that opens it to the one that closes it, exclusive, so the span holds at
4420/// most that one pipe, at its start, and the closing one is the byte past
4421/// its end. The two are told apart by the pipes the span holds — a row's
4422/// span has several, or one that is not at its start.
4423fn empty_cell_offset(src: &str, start: usize, col: usize) -> usize {
4424    let bytes = src.as_bytes();
4425    let mut pipes = Vec::new();
4426    for (i, &b) in bytes.iter().enumerate() {
4427        if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
4428            pipes.push(i);
4429        }
4430    }
4431    let whole_row = pipes.len() > 1 || pipes.first().is_some_and(|&i| i != 0);
4432    let (open, close) = if whole_row {
4433        (pipes.get(col).copied(), pipes.get(col + 1).copied())
4434    } else {
4435        (pipes.first().copied(), Some(src.len()))
4436    };
4437    match (open, close) {
4438        (Some(open), Some(close)) => {
4439            let lo = open + 1; // just inside the opening pipe
4440            let hi = close.saturating_sub(1); // just inside the closing pipe
4441            let inside = if hi < lo {
4442                lo
4443            } else {
4444                (open + 2).clamp(lo, hi)
4445            };
4446            start + inside
4447        }
4448        (Some(open), None) => start + open + 1,
4449        _ => start,
4450    }
4451}
4452
4453/// One laid-out table cell: its rendered text, the source range that text
4454/// occupies (`start`/`end` are the caret anchors decoration points at), and the
4455/// column alignment its padding honours.
4456///
4457/// `glyphs` is the cell's inline content *unwrapped* — the box-drawn rows wrap
4458/// it to a column width, but a frontend laying the grid out itself needs the
4459/// text before that decision was made.
4460#[derive(Clone)]
4461pub struct TableCell {
4462    pub glyphs: Vec<Glyph>,
4463    pub start: usize,
4464    pub end: usize,
4465    pub align: Alignment,
4466}
4467
4468/// One row of a table's grid, as the document spells it — not as it's drawn.
4469#[derive(Clone)]
4470pub struct TableRow {
4471    /// A header row: drawn bold, and ruled off from the body below it.
4472    pub head: bool,
4473    pub cells: Vec<TableCell>,
4474}
4475
4476/// A table's structure, published alongside the box-drawn rows that spell it.
4477///
4478/// The rows in [`VisualMap::rows`] are the *default monospace* picture of a
4479/// table: every border a `│`, every column a whole number of character cells.
4480/// That picture is exactly right on any monospace surface, and unfixable off one
4481/// — in a proportional font the `│`s of two rows land at different x and the grid
4482/// shears. So a frontend that draws its own geometry reads this instead: the
4483/// cells, their alignment, and which rows are the head, with no opinion about
4484/// how wide a column is or what a border looks like.
4485///
4486/// Both are always built. The TUI paints `rows` and ignores this; the GUI skips
4487/// `rows` for the span in `rows_span` and draws from here. They describe the
4488/// same cells, so the caret lands on the same offsets either way.
4489#[derive(Clone)]
4490pub struct TableInfo {
4491    /// The `VisualMap::rows` this table's picture occupies, borders included —
4492    /// what a frontend drawing its own table skips over.
4493    pub rows_span: Range<usize>,
4494    /// The source span of the table node, and the offset its trailing caret
4495    /// stop sits at.
4496    pub end_src: usize,
4497    /// The block prefix every row of this table carries — a blockquote's `│ `
4498    /// gutter, a list item's indent. Empty for a table at the top level.
4499    ///
4500    /// A frontend drawing its own grid has to render this and start the table
4501    /// past it, exactly as the picture does; a table nested in a quote that
4502    /// draws flush at the left margin has left the quote.
4503    pub prefix: Vec<Glyph>,
4504    pub grid: Vec<TableRow>,
4505}
4506
4507/// A fenced or indented code block, named by the [`VisualMap::rows`] it occupies.
4508///
4509/// Unlike a table, the rows *are* the block's content — a frontend still paints
4510/// them, it just draws a border and a tinted background around the whole span
4511/// and lets the code inside scroll horizontally instead of wrapping. So this
4512/// carries only the row range; there's no structural alternative to the picture
4513/// the way [`TableInfo`] is one. Derived from [`VRow::code`] — see
4514/// [`code_block_spans`].
4515#[derive(Clone, Debug, PartialEq, Eq)]
4516pub struct CodeBlockInfo {
4517    /// The contiguous run of [`VisualMap::rows`] this code block spans, blank
4518    /// code lines included.
4519    pub rows_span: Range<usize>,
4520    /// The block's language, from a fenced block's info string — what a frontend
4521    /// paints as a small label on the box (`` ```rust `` → `Some("rust")`).
4522    /// `None` for a fence written without one, or an indented block. Editing it
4523    /// goes through [`crate::Doc::set_code_language`], which re-finds the fence
4524    /// in the AST, so this stays a display string.
4525    pub lang: Option<String>,
4526}
4527
4528/// A block-level image (`![alt](url)` on its own line), named by the single
4529/// [`VisualMap::rows`] row it occupies.
4530///
4531/// Like [`CodeBlockInfo`], the row *is* the block's default rendering — a plain
4532/// surface paints the `🖼 alt` placeholder glyphs as-is. An image-capable
4533/// frontend instead **skips the row in `rows_span`** and paints the resolved
4534/// picture there, exactly as it skips a [`TableInfo`]'s box-drawn rows. Derived
4535/// from [`VRow::media`] by [`media_spans`], so it survives the row reuse of
4536/// [`BlockCache`] and [`build_spliced`].
4537#[derive(Clone, Debug, PartialEq, Eq)]
4538pub struct MediaInfo {
4539    /// The [`VisualMap::rows`] rows this media's placeholder occupies — what a
4540    /// capable frontend replaces with the picture or player.
4541    pub rows_span: Range<usize>,
4542    /// Whether this is a picture, a movie, or a sound — which widget the
4543    /// frontend builds over [`rows_span`](MediaInfo::rows_span). A frontend that
4544    /// handles only some kinds leaves the rest as core's placeholder rows, which
4545    /// already read sensibly on their own.
4546    pub kind: MediaKind,
4547    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
4548    /// the AST. A frontend resolves a relative path against the document's own
4549    /// directory; core does no I/O. For a `<picture>` this is the `<img>`
4550    /// fallback — the source used when no [`sources`](MediaInfo::sources) media
4551    /// query matches (or the frontend has no theme). Empty when a `<video>`/
4552    /// `<audio>` carries no `src` and names its candidates in `<source>`s
4553    /// instead; [`resolve`](MediaInfo::resolve) already accounts for that.
4554    pub destination: String,
4555    /// The `<source>` alternatives in document order, or empty for a plain
4556    /// image. See [`MediaSource`]; a theme- or codec-aware frontend picks one and
4557    /// otherwise loads [`destination`](MediaInfo::destination).
4558    pub sources: Vec<MediaSource>,
4559    /// The media's alt text, flattened from its inline children (empty when it
4560    /// has none).
4561    pub alt: String,
4562    /// A `<video poster="…">`'s still frame, or empty when there is none — an
4563    /// image destination, resolved exactly as [`destination`] is.
4564    ///
4565    /// [`destination`]: MediaInfo::destination
4566    pub poster: String,
4567}
4568
4569/// One leaf directive (`::name{…}`) as a frontend sees it: which rows its
4570/// placeholder occupies, its type, and its attributes. A plain surface paints
4571/// the `⧉ name` placeholder glyphs as-is; a frontend that knows the host app's
4572/// vocabulary **skips the rows in `rows_span`** and paints the real thing there,
4573/// exactly as an image-capable one does with [`MediaInfo`]. Derived from
4574/// [`VRow::leaf_directive`] by [`directive_spans`].
4575///
4576/// Core resolves nothing here — it has no idea what an `embed` or a `toc` is,
4577/// and deliberately so: the directive vocabulary belongs to the app on top.
4578#[derive(Clone, Debug, PartialEq, Eq)]
4579pub struct DirectiveInfo {
4580    /// The [`VisualMap::rows`] rows this directive's placeholder occupies — the
4581    /// label row plus any blank fillers under it.
4582    pub rows_span: Range<usize>,
4583    /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
4584    pub name: String,
4585    /// Its `{…}` attributes in source order; a bare one has a `None` value.
4586    pub attrs: Vec<(String, Option<String>)>,
4587    /// Its `[label]` text, flattened from its inline children (empty when it has
4588    /// none) — what the placeholder row shows.
4589    pub label: String,
4590}
4591
4592impl DirectiveInfo {
4593    /// The value of attribute `key`, if it has one with a value. The convenience
4594    /// a frontend reaches for first (`info.attr("src")`), since almost every
4595    /// directive that draws as something real is pointed at by one attribute.
4596    pub fn attr(&self, key: &str) -> Option<&str> {
4597        self.attrs
4598            .iter()
4599            .find(|(k, _)| k == key)
4600            .and_then(|(_, v)| v.as_deref())
4601    }
4602}
4603
4604impl MediaInfo {
4605    /// The image URL to load under `scheme`: the first [`sources`] `<source>`
4606    /// whose media query matches, else the [`destination`] `<img>` fallback. The
4607    /// pick is a `<source>`'s first `srcset` URL or the destination — a frontend
4608    /// resolves whichever it gets against the document directory exactly as it
4609    /// resolves `destination`, and reserves/keys the picture under `destination`
4610    /// regardless, so a theme switch just re-picks without disturbing the layout.
4611    ///
4612    /// Only `prefers-color-scheme` is understood (that's what a light/dark banner
4613    /// uses); a `<source>` with any other media query is skipped, and one with no
4614    /// media at all always matches (an unconditional override). With no matching
4615    /// source — including every frontend that can't/doesn't theme and passes
4616    /// [`ColorScheme::Light`] to a dark-only picture — it's the plain `<img>`.
4617    ///
4618    /// [`sources`]: MediaInfo::sources
4619    /// [`destination`]: MediaInfo::destination
4620    pub fn resolve(&self, scheme: ColorScheme) -> &str {
4621        if let Some(url) = self
4622            .sources
4623            .iter()
4624            .find(|s| media_matches(&s.media, scheme))
4625            .and_then(|s| first_srcset_url(&s.srcset))
4626        {
4627            return url;
4628        }
4629        // A `<video>`/`<audio>` may carry no `src` of its own, naming its
4630        // candidates only in child `<source>`s — none of which matched above,
4631        // because a codec-typed `<source>` has no media query and core judges no
4632        // MIME types. Falling through to an empty destination would hand the
4633        // frontend nothing to load, so take the first candidate URL instead and
4634        // let the frontend reject it if it can't decode it. An `<img>` never
4635        // reaches this: its `src` is the picture.
4636        if self.destination.is_empty()
4637            && let Some(url) = self
4638                .sources
4639                .iter()
4640                .find_map(|s| first_srcset_url(&s.srcset))
4641        {
4642            return url;
4643        }
4644        &self.destination
4645    }
4646
4647    /// The **still picture** that stands for this media under `scheme`, for a
4648    /// frontend that can rasterize an image but not play a movie — a terminal, or
4649    /// a GUI still growing its player. `None` when there is no picture to draw,
4650    /// which is the honest answer for audio and for a poster-less video: the
4651    /// caller leaves core's labelled placeholder row, which already reads as
4652    /// *a thing that isn't text*.
4653    ///
4654    /// This exists so those frontends never hand a `.mp4` to an image decoder.
4655    /// That fails harmlessly today (a failed decode falls back to the same
4656    /// placeholder), but it spends a file read and a decode attempt per frame to
4657    /// arrive where this gets in one match.
4658    pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
4659        match self.kind {
4660            MediaKind::Image => Some(self.resolve(scheme)),
4661            // A `poster` is an image destination, so it resolves the same way —
4662            // but it is named directly and has no `<source>` alternatives of its
4663            // own, so it needs no theme matching.
4664            MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
4665            MediaKind::Video | MediaKind::Audio => None,
4666        }
4667    }
4668}
4669
4670/// A frontend's active color scheme — what a `<picture>`'s `prefers-color-scheme`
4671/// `<source>`s are matched against by [`MediaInfo::resolve`]. A frontend with no
4672/// notion of theme passes [`Light`](ColorScheme::Light), the web's own default.
4673#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4674pub enum ColorScheme {
4675    Light,
4676    Dark,
4677}
4678
4679/// Whether a `<source media="…">` query applies under `scheme`. Empty media is
4680/// an unconditional `<source>` (always matches); otherwise only a
4681/// `prefers-color-scheme: dark|light` feature is understood — anything else
4682/// (a width query, `print`, …) doesn't match, so resolution falls through to the
4683/// next source or the `<img>`. Deliberately lax about the surrounding syntax
4684/// (`(prefers-color-scheme: dark)`, `screen and (prefers-color-scheme:dark)`):
4685/// it keys off the feature and its value, which is all the theme case needs.
4686fn media_matches(media: &str, scheme: ColorScheme) -> bool {
4687    let media = media.trim();
4688    if media.is_empty() {
4689        return true;
4690    }
4691    let lower = media.to_ascii_lowercase();
4692    let Some(after) = lower
4693        .split_once("prefers-color-scheme")
4694        .map(|(_, rest)| rest)
4695    else {
4696        return false;
4697    };
4698    // Skip the `:` and any spaces to reach the value word.
4699    let value = after.trim_start_matches([':', ' ', '\t']);
4700    let wanted = match scheme {
4701        ColorScheme::Light => "light",
4702        ColorScheme::Dark => "dark",
4703    };
4704    value.starts_with(wanted)
4705}
4706
4707/// The first URL in a `srcset`: its first comma-separated candidate, before any
4708/// `1x`/`2x`/width descriptor. The theme case only ever puts one URL per
4709/// `<source>`, so the first candidate is the picture.
4710fn first_srcset_url(srcset: &str) -> Option<&str> {
4711    let first = srcset.split(',').next()?.trim();
4712    first.split_whitespace().next().filter(|u| !u.is_empty())
4713}
4714
4715/// The narrowest a column may be squeezed. Below a few characters a column
4716/// stops carrying text and just shreds it one letter per line, which is worse
4717/// than letting the grid run wide.
4718const MIN_COL_WIDTH: usize = 3;
4719
4720/// Shrink `widths` until the grid fits `avail` screen columns, taking from the
4721/// widest column each time so the loss is shared out rather than falling on
4722/// whichever column happens to be last. No column goes below
4723/// [`MIN_COL_WIDTH`]; a table with more columns than the surface has room for
4724/// still overflows, which is the honest outcome — there's nothing left to give.
4725fn fit_widths(widths: &mut [usize], avail: usize) {
4726    // Chrome: each column is its content plus a gutter either side, and every
4727    // column is closed by a `│` — with one more opening the row.
4728    let budget = avail.saturating_sub(3 * widths.len() + 1);
4729    while widths.iter().sum::<usize>() > budget {
4730        let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
4731            return;
4732        };
4733        *w -= 1;
4734    }
4735}
4736
4737/// Word-wrap `glyphs` into lines of at most `width` columns, hard-breaking any
4738/// single word too long to fit.
4739///
4740/// Unlike a paragraph — where an overlong word just trails off the end of the
4741/// line — a table column is a hard boundary: a glyph past it lands on top of
4742/// the border, or on the next cell. So the width here is a promise, and a word
4743/// that won't keep it is broken.
4744///
4745/// The space at a break is dropped rather than hung past the edge. Its offset
4746/// isn't lost: the caller gives every line an end stop just past its last
4747/// glyph, which is exactly where that space was.
4748///
4749/// `width` is in display columns, and a break only ever falls between grapheme
4750/// clusters. Both matter to more than the picture: the caller anchors each
4751/// line's end stop just past its last glyph, so a line cut mid-cluster would
4752/// put a caret stop inside a character — reachable by Down or a click, and the
4753/// next Backspace would take the cluster apart from the middle.
4754///
4755/// An explicit in-cell break (a `\n` glyph, from a `<br>`) is a hard boundary:
4756/// each run between the breaks wraps on its own and the results stack. The break
4757/// glyphs are dropped — the caller's per-line end stop already sits exactly where
4758/// each break was, so no offset is lost.
4759fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
4760    if glyphs.iter().any(|g| g.ch == '\n') {
4761        return glyphs
4762            .split(|g| g.ch == '\n')
4763            .flat_map(|seg| wrap_segment(seg, width))
4764            .collect();
4765    }
4766    wrap_segment(glyphs, width)
4767}
4768
4769/// [`wrap_glyphs`] for a run with no explicit breaks — the word-wrap proper.
4770fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
4771    let width = width.max(1);
4772    // Words are maximal non-space runs, each carrying the space that followed it
4773    // — which survives only if the next word joins it on this line.
4774    let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4775    let mut word: Vec<Glyph> = Vec::new();
4776    for g in glyphs {
4777        if g.ch == ' ' {
4778            words.push((std::mem::take(&mut word), Some(g.clone())));
4779        } else {
4780            word.push(g.clone());
4781        }
4782    }
4783    if !word.is_empty() {
4784        words.push((word, None));
4785    }
4786
4787    let mut lines: Vec<Vec<Glyph>> = Vec::new();
4788    let mut line: Vec<Glyph> = Vec::new();
4789    let mut used = 0usize;
4790    let mut gap: Option<Glyph> = None;
4791    for (word, space) in words {
4792        for chunk in hard_break(&word, width) {
4793            let sep = gap.is_some() as usize;
4794            let cells = glyphs_width(chunk);
4795            if !line.is_empty() && used + sep + cells > width {
4796                lines.push(std::mem::take(&mut line));
4797                used = 0;
4798                gap = None; // the break swallows the space
4799            }
4800            if let Some(sp) = gap.take() {
4801                line.push(sp);
4802                used += 1;
4803            }
4804            line.extend_from_slice(chunk);
4805            used += cells;
4806        }
4807        gap = space;
4808    }
4809    // An empty cell is still one (empty) line — it has an end the caret can
4810    // sit at, which is how you type into it.
4811    if !line.is_empty() || lines.is_empty() {
4812        lines.push(line);
4813    }
4814    lines
4815}
4816
4817/// Break a single word into pieces of at most `width` columns, cutting only
4818/// between grapheme clusters — the replacement for slicing it into fixed runs
4819/// of glyphs, which measures a wide character as one column and can cut an
4820/// emoji in half.
4821///
4822/// A cluster wider than the whole column still gets a piece to itself: there is
4823/// nowhere legal to cut it, and overflowing by a cell is better than splitting a
4824/// character. An empty word yields no pieces at all, which is what keeps a
4825/// double space from opening a line of its own.
4826fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
4827    let mut out = Vec::new();
4828    if word.is_empty() {
4829        return out;
4830    }
4831    let (mut start, mut used) = (0usize, 0usize);
4832    for c in clusters(word) {
4833        if used > 0 && used + c.cells > width {
4834            out.push(&word[start..c.glyph]);
4835            start = c.glyph;
4836            used = 0;
4837        }
4838        used += c.cells;
4839    }
4840    out.push(&word[start..]);
4841    out
4842}
4843
4844/// A table rule spanning `widths`, e.g. `┌──────┬─────┐`. Each column is its
4845/// content width plus the one-space gutter on either side.
4846fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
4847    let mut s = String::new();
4848    s.push(left);
4849    for (i, w) in widths.iter().enumerate() {
4850        if i > 0 {
4851            s.push(mid);
4852        }
4853        for _ in 0..w + 2 {
4854            s.push('─');
4855        }
4856    }
4857    s.push(right);
4858    s
4859}
4860
4861/// Push real document text: each glyph maps to its own source byte, and the one
4862/// that opens a grapheme cluster is the caret stop for the whole cluster.
4863///
4864/// Per cluster rather than per codepoint because a cluster is the character the
4865/// user sees, and it's the unit backspace and delete already step by. A stop
4866/// inside 👨‍👩‍👧 — five codepoints strung together with joiners — is a caret
4867/// parked in the middle of a character: one press of Right lands there, and the
4868/// next Backspace severs a joiner from what it joined, leaving a dangling ZWJ in
4869/// the source. The rest of the cluster still gets its glyph (it has to be
4870/// drawn); it just isn't somewhere to stand.
4871fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
4872    for (gi, cluster) in text.grapheme_indices(true) {
4873        for (ci, ch) in cluster.char_indices() {
4874            out.push(Glyph {
4875                ch,
4876                style,
4877                src: base_src + gi + ci,
4878                stop: ci == 0,
4879            });
4880        }
4881    }
4882}
4883
4884/// [`push_text`] for one line of a highlighted code block: the same glyphs at
4885/// the same offsets, each additionally carrying the [`Token`] of the span it
4886/// falls in — `spans` being the line's entry from [`code_tokens`], ascending
4887/// byte ranges *into `text`*. A byte between spans keeps `style` as it is.
4888///
4889/// Offsets are what matters here: a token changes how a glyph is painted and
4890/// nothing about where it is or which source byte it stands on, so a caret
4891/// walks a highlighted block exactly as it walks an unhighlighted one.
4892fn push_code_text(
4893    out: &mut Vec<Glyph>,
4894    text: &str,
4895    base_src: usize,
4896    style: Style,
4897    spans: &[(Range<usize>, Token)],
4898) {
4899    let mut spans = spans.iter().peekable();
4900    for (gi, cluster) in text.grapheme_indices(true) {
4901        // Spans are ascending, so the one covering this cluster's first byte
4902        // is at or after the one that covered the last; step past those ended.
4903        while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
4904            spans.next();
4905        }
4906        let token = spans
4907            .peek()
4908            .filter(|(r, _)| r.contains(&gi))
4909            .map(|(_, t)| *t);
4910        // A cluster is classed whole, by its first byte: a grammar that split
4911        // an emoji's scalars between two tokens would otherwise split the
4912        // glyph, and no grammar means to.
4913        let style = style.token(token);
4914        for (ci, ch) in cluster.char_indices() {
4915            out.push(Glyph {
4916                ch,
4917                style,
4918                src: base_src + gi + ci,
4919                stop: ci == 0,
4920            });
4921        }
4922    }
4923}
4924
4925/// One line's highlighting — `crate::syntax::LineTokens`, spelled here so the
4926/// shape exists whether or not the feature that fills it does.
4927type LineTokens = Vec<(Range<usize>, Token)>;
4928
4929/// The syntax highlighting for a code block's lines, or `None` when the fence's
4930/// language is not one the grammars know. Without the `syntax` feature nothing
4931/// is known, and every code glyph draws in the plain code colour.
4932#[cfg(feature = "syntax")]
4933fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
4934    crate::syntax::highlight(lang, lines)
4935}
4936
4937#[cfg(not(feature = "syntax"))]
4938fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
4939    None
4940}
4941
4942/// Emit an inline `str`/`smart_punctuation` run, mapping every visible char back
4943/// to its *true* source byte even when the source carries backslash escapes the
4944/// parsed `text` dropped (`\*` → `*`). The naive `span.start + text_offset`
4945/// mapping [`push_text`] uses drifts by one byte after each escape, so a caret or
4946/// click past an escaped `*` would land on the wrong character; walking the text
4947/// against its source keeps them aligned, and the hidden escape backslash gets no
4948/// glyph of its own (it is a spelling artefact, not something the caret lands on).
4949fn push_escaped_text(
4950    out: &mut Vec<Glyph>,
4951    text: &str,
4952    span: Range<usize>,
4953    source: &str,
4954    style: Style,
4955) {
4956    let end = span.end.min(source.len());
4957    let src = source.get(span.start..end).unwrap_or("");
4958    // Fast path — no dropped bytes, so text and source align 1:1 (the common
4959    // case: prose with no escapes). Byte lengths equal ⇒ no backslash was eaten.
4960    if src.len() == text.len() {
4961        push_text(out, text, span.start, style);
4962        return;
4963    }
4964    // Slow path: some `\` was consumed. Walk char-by-char, skipping a backslash
4965    // in the source exactly when it escapes the next visible char (a real escape),
4966    // never when it is a literal backslash the parse kept (that case has equal
4967    // lengths and takes the fast path above).
4968    let sb = src.as_bytes();
4969    let mut si = 0usize;
4970    'text: for (_, cluster) in text.grapheme_indices(true) {
4971        for (ci, ch) in cluster.char_indices() {
4972            // The text outlasted the source it is being mapped onto. In a
4973            // consistent document that cannot happen on this path: the slow path
4974            // is only entered when the two lengths differ, and everything that
4975            // makes them differ makes the *source* the longer one — an escape
4976            // backslash the parse ate, or source folded into a neighbouring node.
4977            // A `smart_punctuation` node reports its canonical ASCII spelling
4978            // (`--`, `...`, `"`), which is never longer than what was written.
4979            //
4980            // So reaching here means `span` was measured against a document that
4981            // `source` is no longer, and there is no honest offset left to give
4982            // the remaining characters. Stop: the row comes out short, which is
4983            // a wrong picture of a document that is already inconsistent. The
4984            // alternative was `si` stepping past the end and the slice below
4985            // panicking — which is what it did, in a paint loop.
4986            if si >= sb.len() {
4987                break 'text;
4988            }
4989            // Advance to the source character this one came from, stepping over
4990            // whatever the parse dropped on the way. An escape backslash is the
4991            // common case, but not the only one: a span can cover source that
4992            // was folded into a neighbouring node (smart punctuation next to a
4993            // bracket gives `text: "]"` over a source span of `"…]"`). Advancing
4994            // by the *text* character's length assumed escapes were the only
4995            // divergence, so one dropped multi-byte character desynchronized
4996            // every glyph after it — placing `]` inside the `…` before it.
4997            while si < sb.len() && !src[si..].starts_with(ch) {
4998                si += src[si..].chars().next().map_or(1, char::len_utf8);
4999            }
5000            out.push(Glyph {
5001                ch,
5002                style,
5003                src: span.start + si.min(src.len()),
5004                stop: ci == 0,
5005            });
5006            si += src[si..]
5007                .chars()
5008                .next()
5009                .map_or(ch.len_utf8(), char::len_utf8);
5010        }
5011    }
5012}
5013
5014/// Build synthetic decoration glyphs (a bullet, a gutter) all pointing at `src`,
5015/// each carrying `role` so the frontend can style it (`Role::Body` for plain
5016/// padding). Synthetic glyphs are never caret stops — they share one offset, so
5017/// the caret steps over them (a click still lands at `src`).
5018fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
5019    let style = Style::default().role(role);
5020    text.chars()
5021        .map(|ch| Glyph {
5022            ch,
5023            style,
5024            src,
5025            stop: false,
5026        })
5027        .collect()
5028}
5029
5030fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
5031    let mut v = a.to_vec();
5032    v.extend_from_slice(b);
5033    v
5034}
5035
5036/// The columns a row's prefix (a bullet, a quote gutter, an indent) takes up
5037/// before the text it introduces — what the wrap budget has left to spend.
5038fn prefix_width(prefix: &[Glyph]) -> usize {
5039    glyphs_width(prefix)
5040}
5041
5042/// The label shown for an image with no alt text: the final path segment of its
5043/// destination (`img/cat.png` → `cat.png`), the whole destination when it has no
5044/// separator, and `"image"` when it's empty. A `data:` URI (which has no useful
5045/// tail) shows its scheme so the placeholder isn't a wall of base64.
5046fn media_label(dest: &str) -> String {
5047    if dest.is_empty() {
5048        return "image".to_string();
5049    }
5050    if dest.starts_with("data:") {
5051        return "data:…".to_string();
5052    }
5053    // Trim a query/fragment so a URL's `?v=2#frag` doesn't ride along.
5054    let clean = dest.split(['?', '#']).next().unwrap_or(dest);
5055    let tail = clean
5056        .trim_end_matches('/')
5057        .rsplit(['/', '\\'])
5058        .next()
5059        .unwrap_or(clean);
5060    if tail.is_empty() {
5061        dest.to_string()
5062    } else {
5063        tail.to_string()
5064    }
5065}
5066
5067/// A directive's attributes read as a human label — what a frontend puts on a
5068/// container's tinted panel, and what an attribute-bearing inline directive
5069/// shows in its chip.
5070///
5071/// Reads BOTH conventions diaryx content actually uses: twig's own dot-prefixed
5072/// classes (`{.public .family}`, arriving as one combined `class` attr) and bare
5073/// pandoc-style words with no leading dot (`{public family}` — what
5074/// `diaryx_core::visibility`'s publish-time filter and apps/web's directive
5075/// serializer both write, and which twig parses as one valueless attribute
5076/// each). Reading only `.class` would leave every *existing* diaryx `:::vis{…}`
5077/// block unlabeled. A `key=value` attr is configuration rather than a name, so
5078/// it contributes nothing. `None` when nothing readable is left.
5079fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
5080    let mut parts: Vec<String> = Vec::new();
5081    for (k, v) in attrs {
5082        if k == "class" {
5083            if let Some(v) = v
5084                && !v.is_empty()
5085            {
5086                parts.push(v.clone());
5087            }
5088        } else if v.as_deref().unwrap_or("").is_empty() {
5089            parts.push(k.clone());
5090        }
5091    }
5092    (!parts.is_empty()).then(|| parts.join(" "))
5093}
5094
5095fn heading_style(level: u32) -> Style {
5096    // Just the role — a frontend decides how a heading of this level *looks*
5097    // (the terminal cycles a color and bolds it, the GUI scales the font). The
5098    // author wrote no emphasis here, so core records none. `level as u8` is safe:
5099    // Markdown/Djot cap headings at 6.
5100    Style::default().role(Role::Heading(level.min(255) as u8))
5101}
5102
5103/// Is this `container` node a *directive* (`:::note{…}`, `::embed{…}`,
5104/// `:vis[…]`) rather than an HTML element (`<video>`, `<picture>`, `<div>`)?
5105///
5106/// twig 2.8 folded `div`/`span`/`directive`/`element` into one `container` kind,
5107/// and left nothing that separated them: `kind`, `name` and `directive_form` all
5108/// agree, field for field, on an HTML `<div>` and a Markdown `:::div`. Leaf
5109/// answered it by sniffing the span for whichever of `:` or `<` came first.
5110/// twig 3.0 records the answer at parse time as [`ContainerOrigin`], so this is
5111/// now the parser's own knowledge rather than a guess rebuilt from the bytes it
5112/// consumed.
5113pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
5114    node.origin == Some(ContainerOrigin::Directive)
5115}
5116
5117/// The tag a `container` node carries when it is an HTML element rather than a
5118/// directive — `Some("video")` for a promoted `<video>`, `None` for a `:::note`
5119/// or for any node that is not a container at all.
5120pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
5121    (node.origin == Some(ContainerOrigin::Element))
5122        .then_some(node.name.as_deref())
5123        .flatten()
5124}
5125
5126pub(crate) fn is_inline(node: &FlatNode) -> bool {
5127    // A directive is inline only in its `text` form (`:name[label]{…}`); the
5128    // `leaf` and `container` forms are blocks. All three report the same `kind`,
5129    // so the form is the only thing telling them apart — and getting it wrong
5130    // costs a whole paragraph: a text directive misread as a block makes its
5131    // paragraph fail the "all children inline" test in `block`, and the line is
5132    // then walked as a container of blocks, rendering as empty rows with no
5133    // caret home at all.
5134    //
5135    // An HTML element shares the `container` kind but never the `text` form, so
5136    // it answers `false` here and is walked as the block it is.
5137    if node.kind == Kind::Container {
5138        return container_is_directive(node) && node.directive_form == Some(DirectiveForm::Text);
5139    }
5140    is_inline_kind(&node.kind)
5141}
5142
5143/// [`is_inline`] by kind alone — for the ancestor walks, whose `QueryMatch`es
5144/// carry no `directive_form`. It answers `false` for every directive, which its
5145/// callers must (and do) reconcile: they pair it with `is_block_container`,
5146/// which claims every directive, so the pair's verdict is the same one a form
5147/// would have given. Anything looking at a *directive itself* wants [`is_inline`]
5148/// and a real node.
5149pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
5150    matches!(
5151        kind,
5152        Kind::Str
5153            | Kind::SoftBreak
5154            | Kind::HardBreak
5155            | Kind::NonBreakingSpace
5156            | Kind::Emph
5157            | Kind::Strong
5158            | Kind::Mark
5159            | Kind::Insert
5160            | Kind::Delete
5161            | Kind::Verbatim
5162            | Kind::InlineMath
5163            | Kind::DisplayMath
5164            | Kind::Url
5165            | Kind::Email
5166            | Kind::Link
5167            | Kind::Image
5168            | Kind::SmartPunctuation
5169            | Kind::Superscript
5170            | Kind::Subscript
5171            | Kind::FootnoteReference
5172    )
5173}
5174
5175/// Assert two maps are identical down to every glyph, stop, and table span — the
5176/// contract `build_cached` and `build_spliced` must hold against `build`. Lives
5177/// at module scope (not in `mod tests`) so the Doc-driven differential test in
5178/// `doc.rs` can reach it and the private `stops` field it compares.
5179#[cfg(test)]
5180pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
5181    assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
5182    for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
5183        assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
5184        assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
5185        // The incremental walk labels a boundary from a query match's kind
5186        // string and the whole-arena walk from a `FlatNode`'s; this is what says
5187        // the two doors reach the same answer.
5188        assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
5189        assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
5190        assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
5191        assert_eq!(
5192            ra.glyphs.len(),
5193            rb.glyphs.len(),
5194            "row {i} glyph count ({ctx})"
5195        );
5196        for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
5197            assert_eq!(
5198                (ga.ch, ga.src, ga.stop, ga.style),
5199                (gb.ch, gb.src, gb.stop, gb.style),
5200                "row {i} glyph {j} ({ctx})"
5201            );
5202        }
5203    }
5204    assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
5205    assert_eq!(a.stops, b.stops, "stops ({ctx})");
5206    assert_eq!(a.mark_ends, b.mark_ends, "mark_ends ({ctx})");
5207    assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
5208    for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
5209        assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
5210        assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
5211    }
5212    assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
5213    assert_eq!(a.media, b.media, "images ({ctx})");
5214}
5215
5216#[cfg(test)]
5217mod tests {
5218    use super::*;
5219    use twig::{Editor, Format, NodeId};
5220
5221    fn map(src: &str) -> VisualMap {
5222        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5223        build_t(&ed.nodes().unwrap(), src, Some(80))
5224    }
5225
5226    /// [`map`] over a Djot source. Djot is the format that spells superscript
5227    /// and subscript at all — Markdown has no syntax for either.
5228    fn map_djot(src: &str) -> VisualMap {
5229        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5230        build_t(&ed.nodes().unwrap(), src, Some(80))
5231    }
5232
5233    /// The baseline every glyph spelling `ch` was built with, in row order —
5234    /// how a test reads a raised or lowered run off the map without caring
5235    /// which row it landed on.
5236    fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
5237        m.rows
5238            .iter()
5239            .flat_map(|r| r.glyphs.iter())
5240            .filter(|g| g.ch == ch)
5241            .map(|g| g.style.baseline)
5242            .collect()
5243    }
5244
5245    /// [`map`] at a chosen wrap width.
5246    fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
5247        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5248        build_t(&ed.nodes().unwrap(), src, wrap)
5249    }
5250
5251    /// [`map`], but with twig's `directives` extension on (off by twig's own
5252    /// default) — the `:::name{.class}` fenced-div containers leaf-core's
5253    /// `"directive"` wysiwyg arm renders.
5254    fn map_directives(src: &str) -> VisualMap {
5255        let mut ed = Editor::new_ext(
5256            src.as_bytes(),
5257            Format::Markdown,
5258            twig::MarkdownExtensions {
5259                directives: true,
5260                ..Default::default()
5261            },
5262        )
5263        .unwrap();
5264        build_t(&ed.nodes().unwrap(), src, Some(80))
5265    }
5266
5267    /// [`map`] with soft breaks preserved (`LineFlow::Preserve`).
5268    fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
5269        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5270        build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
5271    }
5272
5273    /// The cache-free reference [`build`], with no per-image height overrides —
5274    /// every block image stays its default one-row placeholder. The tests that
5275    /// need a taller image drive it through [`crate::Doc::set_media_rows`] instead.
5276    fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
5277        build(nodes, src, wrap, false, &HashMap::new(), None)
5278    }
5279
5280    /// An arena and a string that disagree — spans reaching past the source they
5281    /// are built against.
5282    ///
5283    /// [`crate::Doc`] keeps the two in step, so this is a "cannot happen" that
5284    /// nonetheless *did*: `examples/bench` timed a loop of `edit_range` and then
5285    /// went on handing the grown editor's spans to a builder holding the string
5286    /// from before it, and every run ended in a slice panic rather than a
5287    /// number. `push_escaped_text` was already written to survive the mismatch —
5288    /// it clamps the span's end and falls back to an empty slice — and this is
5289    /// the half of that intent it did not carry through.
5290    ///
5291    /// Rendering the wrong thing is the acceptable answer here; panicking in a
5292    /// paint loop is not.
5293    #[test]
5294    fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
5295        // An escape puts the run on `push_escaped_text`'s slow path — the fast
5296        // path is a length comparison that a truncated source fails anyway.
5297        let src = "alpha \\*beta\\* gamma delta epsilon\n";
5298        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5299        let nodes = ed.nodes().unwrap();
5300
5301        // Every truncation of it, so the cut lands before, inside and after the
5302        // escaped run rather than only where one hand-picked index put it.
5303        for cut in 0..=src.len() {
5304            if !src.is_char_boundary(cut) {
5305                continue;
5306            }
5307            let map = build_t(&nodes, &src[..cut], Some(80));
5308            for row in &map.rows {
5309                for g in &row.glyphs {
5310                    assert!(
5311                        g.src <= src.len(),
5312                        "cut {cut}: glyph {:?} points past the source at {}",
5313                        g.ch,
5314                        g.src
5315                    );
5316                }
5317            }
5318        }
5319    }
5320
5321    fn rendered(m: &VisualMap) -> String {
5322        m.rows
5323            .iter()
5324            .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
5325            .collect::<Vec<_>>()
5326            .join("\n")
5327    }
5328
5329    /// Render a source both ways: `build` over the whole marshalled arena (the
5330    /// reference), and `build_cached` driven the way [`crate::Doc`] drives it —
5331    /// top-level blocks from `child_spans`, per-block subtrees on a miss.
5332    fn render_both(
5333        ed: &mut Editor,
5334        src: &str,
5335        wrap: Option<usize>,
5336        cache: &mut BlockCache,
5337    ) -> (VisualMap, VisualMap) {
5338        let all = ed.nodes().unwrap();
5339        let media_rows = HashMap::new();
5340        let plain = build(&all, src, wrap, false, &media_rows, None);
5341        let top = top_blocks(ed);
5342        let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
5343            ed.subtree(NodeId(id)).unwrap_or_default()
5344        });
5345        (plain, cached)
5346    }
5347
5348    /// The whole correctness claim of the block cache: `build_cached` produces a
5349    /// byte-identical map to `build`, on a fresh cache *and* — the case that
5350    /// actually exercises reuse-and-shift plus per-block subtree marshalling — on
5351    /// a warm cache after the source has been edited underneath it.
5352    /// **Every glyph must stand on the character it claims.** A row's source
5353    /// extent is computed from its last glyph's offset, so a glyph carrying an
5354    /// offset that is not its own character's start yields a row end inside a
5355    /// multi-byte character — and every later slice of the source panics on it.
5356    ///
5357    /// Reproduces a real crash from a journal entry: a bracketed elision inside
5358    /// a blockquote (`[…]`) gave the closing bracket a `text` of `"]"` over a
5359    /// source span covering `"…]"`, because the parse folded the ellipsis into a
5360    /// neighbouring node. `push_escaped_text` walked that span assuming a
5361    /// dropped backslash was the only way text and source could diverge, so the
5362    /// `]` landed on the `…`'s first byte:
5363    /// `byte index 1236 is not a char boundary; it is inside '…'`.
5364    #[test]
5365    fn a_glyph_never_lands_inside_the_character_before_it() {
5366        let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
5367        let vmap = map(src);
5368        for (r, row) in vmap.rows.iter().enumerate() {
5369            assert!(
5370                src.is_char_boundary(row.end_src.min(src.len())),
5371                "row {r} ends at {} — inside a character",
5372                row.end_src
5373            );
5374            for g in &row.glyphs {
5375                assert!(
5376                    src.is_char_boundary(g.src.min(src.len())),
5377                    "row {r} has {:?} at {}, which is inside a character",
5378                    g.ch,
5379                    g.src
5380                );
5381            }
5382        }
5383        // The elision survives, and its bracket sits on the real `]`.
5384        let text: String = vmap
5385            .rows
5386            .iter()
5387            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5388            .collect();
5389        assert!(text.contains("[…]"), "the elision should render: {text:?}");
5390        let close = vmap
5391            .rows
5392            .iter()
5393            .flat_map(|r| r.glyphs.iter())
5394            .find(|g| g.ch == ']')
5395            .expect("a closing bracket");
5396        assert_eq!(
5397            src[close.src..].chars().next(),
5398            Some(']'),
5399            "the bracket glyph should stand on the source's own `]`"
5400        );
5401    }
5402
5403    #[test]
5404    fn build_cached_matches_build() {
5405        let docs = [
5406            "# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
5407            "## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
5408            "para one\n\n```\ncode\nlines\n```\n\nafter code\n",
5409            "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
5410            "line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
5411            "> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
5412            "intro\n\n![a cat](img/cat.png)\n\nbetween\n\n![](https://x.dev/logo.svg)\n\nend\n",
5413            "- text item\n- ![alt](pic.png)\n- more text\n",
5414            // Footnotes: twig parses each definition as a root beside `doc`, so
5415            // these are the docs where the reference build and the incremental
5416            // one could disagree about what the top-level blocks even are.
5417            "A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
5418            "note[^a]\n\n[^a]: body **bold**\n    wrapped on\n    three lines\n\nafter\n",
5419            // No trailing newline. twig closes the document's last block on the
5420            // virtual newline it supplies at EOF, so that block's `span.end` is
5421            // `source.len() + 1` — a range that slices no bytes at all. Keying
5422            // the block cache off such a slice made every last block hash alike;
5423            // see [`block_bytes`].
5424            "# Title\n\nThe quick brown fox.\n\nA tail with no newline",
5425            "A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
5426            // Comments draw nothing. The per-block builder the cached path
5427            // renders one with starts at offset 0 and, drawing nothing, never
5428            // moved — so the walk went on from 0 and spelled every line of the
5429            // document as a blank row. One at the start, one between blocks,
5430            // one at the end, so each position is covered.
5431            "<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
5432            // Link reference definitions: roots beside `doc` like footnotes,
5433            // but drawing nothing. Alone between blocks, glued under a
5434            // paragraph, and closing the file under a comment — the README
5435            // shape.
5436            "see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
5437        ];
5438        for wrap in [None, Some(80usize), Some(20)] {
5439            for src in docs {
5440                let ctx = format!("wrap={wrap:?} src={src:?}");
5441                let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5442                let mut cache = BlockCache::default();
5443
5444                // 1) Fresh cache equals the cache-free build.
5445                let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
5446                assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
5447
5448                // 2) Type a char mid-document, reparse, rebuild with the now-warm
5449                //    cache: the edited block is re-marshalled and re-rendered,
5450                //    every block below it is reused shifted, and the result must
5451                //    still match a from-scratch build.
5452                let at = (src.len() / 2..=src.len())
5453                    .find(|&i| src.is_char_boundary(i))
5454                    .unwrap();
5455                ed.edit_range(at, at, "Z").unwrap();
5456                let src2 = ed.source_str().unwrap();
5457                let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
5458                assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
5459
5460                // 3) Delete it again: offsets shift back the other way, and the
5461                //    warm cache must not hand back stale shifted rows.
5462                ed.edit_range(at, at + 1, "").unwrap();
5463                let src3 = ed.source_str().unwrap();
5464                let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
5465                assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
5466            }
5467        }
5468    }
5469
5470    /// A document that does not end in a newline is the one place twig hands
5471    /// leaf a top-level span that addresses no source: the last block is closed
5472    /// on the virtual newline the parser supplies at EOF, so its `span.end` is
5473    /// `source.len() + 1`. The block cache keys on the bytes under that span, and
5474    /// reading the out-of-range slice as *no bytes* broke it two ways at once —
5475    /// [`block_bytes`] has the full account. Both ways are checked here, because
5476    /// they fail independently.
5477    #[test]
5478    fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
5479        // One: two overrunning blocks collide. A footnote definition is a root
5480        // beside `doc` that [`top_blocks`] merges into the top level, while the
5481        // `section` above it spans the definition's bytes too — so when the
5482        // definition ends the file, both blocks end past it. The second was
5483        // served the first's rows, and the definition rendered as a copy of the
5484        // heading.
5485        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.";
5486        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5487        let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
5488        assert_maps_eq(&plain, &cached, "a definition ending the file");
5489        let text = rendered(&cached);
5490        assert!(
5491            text.ends_with("[note] A note with a word for a label."),
5492            "the last definition should render itself: {text:?}"
5493        );
5494        assert_eq!(
5495            text.matches("A heading with a reference").count(),
5496            1,
5497            "the heading should render exactly once: {text:?}"
5498        );
5499
5500        // Two: one overrunning block goes stale. Its bytes are its cache key, so
5501        // a block that keeps hashing the same however it is edited is served the
5502        // rows built before the edit — the whole last line frozen as the user
5503        // types in it.
5504        let mut cache = BlockCache::default();
5505        let first = "first para\n\n# A heading\n\nlast para with no newline";
5506        let mut ed = Editor::new_str(first, Format::Djot).unwrap();
5507        let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
5508        assert!(rendered(&warm).ends_with("last para with no newline"));
5509
5510        let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
5511        let mut ed = Editor::new_str(second, Format::Djot).unwrap();
5512        let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
5513        assert_maps_eq(&plain, &cached, "edited last block, warm cache");
5514        let text = rendered(&cached);
5515        assert!(
5516            text.ends_with("DIFFERENT text without a newline"),
5517            "the warm cache served the pre-edit rows: {text:?}"
5518        );
5519    }
5520
5521    #[test]
5522    fn resolves_markup_to_plain_text() {
5523        let text = rendered(&map("# Title\n\na **bold** word\n"));
5524        assert!(!text.contains('#'), "heading marker shown: {text:?}");
5525        assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
5526        assert!(text.contains("Title") && text.contains("bold word"));
5527    }
5528
5529    #[test]
5530    fn every_glyph_points_at_its_source_byte() {
5531        let src = "a **bold** c\n";
5532        let m = map(src);
5533        for row in &m.rows {
5534            for g in &row.glyphs {
5535                // A real (non-synthetic) glyph's source byte is the glyph's char.
5536                if g.src < src.len()
5537                    && src.is_char_boundary(g.src)
5538                    && let Some(sc) = src[g.src..].chars().next()
5539                    && sc == g.ch
5540                {
5541                    continue;
5542                }
5543                // Synthetic prefixes (none here) would be the only exceptions.
5544                panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
5545            }
5546        }
5547    }
5548
5549    #[test]
5550    fn offset_and_position_round_trip_on_visible_text() {
5551        let m = map("hello world\n");
5552        let (r, c) = m.pos_of_offset(6); // the 'w'
5553        assert_eq!(m.offset_of_pos(r, c), 6);
5554    }
5555
5556    #[test]
5557    fn visible_utf16_indices_count_the_text_the_system_sees() {
5558        // Hidden delimiters, a two-unit emoji, and a block gap — every way the
5559        // visible text's UTF-16 length parts company with a source byte count.
5560        let src = "a **b\u{1F600}** c\n\nd\n";
5561        let m = map(src);
5562        let end = m.snap_to_stop(src.len());
5563        let text = m.visible_text(0, end);
5564        assert_eq!(text, "a b\u{1F600} c\nd");
5565
5566        // Forward: the index of each offset is where that character sits in
5567        // the visible string, in UTF-16 units.
5568        for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
5569            let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
5570            assert_eq!(
5571                m.visible_utf16_len(0, *src_off),
5572                expect,
5573                "utf16 index of source offset {src_off}"
5574            );
5575            // And back: the index resolves to the offset it came from.
5576            assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
5577        }
5578        // Inside the emoji's surrogate pair resolves to the emoji.
5579        let emoji_src = src.find('\u{1F600}').unwrap();
5580        let emoji_idx = m.visible_utf16_len(0, emoji_src);
5581        assert_eq!(
5582            m.offset_at_visible_utf16(end, emoji_idx + 1),
5583            Some(emoji_src)
5584        );
5585        // At or past the end is nobody's character.
5586        let total = m.visible_utf16_len(0, end);
5587        assert_eq!(total, text.encode_utf16().count());
5588        assert_eq!(m.offset_at_visible_utf16(end, total), None);
5589    }
5590
5591    #[test]
5592    fn unwrapped_mode_emits_one_row_per_paragraph() {
5593        // A long paragraph that would wrap under a column budget stays a single
5594        // row when wrap is None (the GUI wraps it at pixel width instead).
5595        let long = "one two three four five six seven eight nine ten eleven twelve\n";
5596        let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
5597        let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
5598        let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
5599        assert!(wrapped.num_rows() > 1, "narrow column should wrap");
5600        assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
5601        // Every glyph's source byte is preserved in the single row.
5602        let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
5603        assert_eq!(text.trim_end(), long.trim_end());
5604    }
5605
5606    fn line_texts(m: &VisualMap) -> Vec<String> {
5607        m.rows
5608            .iter()
5609            .map(|r| {
5610                // Trim the trailing whitespace a row may carry — the zero-width
5611                // '\n' that closes a preserved line, and any space glyph left at
5612                // a wrap boundary (both real caret stops, neither visible text).
5613                r.glyphs
5614                    .iter()
5615                    .map(|g| g.ch)
5616                    .collect::<String>()
5617                    .trim_end()
5618                    .to_string()
5619            })
5620            .collect()
5621    }
5622
5623    #[test]
5624    fn preserve_lays_each_soft_break_on_its_own_row() {
5625        // A soft break (a bare newline inside a paragraph) folds into a space by
5626        // default — the whole paragraph is one reflowed row...
5627        let src = "one two\nthree four\n";
5628        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5629        let folded = build_t(&ed.nodes().unwrap(), src, None);
5630        assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
5631        assert_eq!(
5632            line_texts(&folded),
5633            vec!["one two three four"],
5634            "break folded to a space"
5635        );
5636
5637        // ...and under Preserve it renders where it was written, a row per line.
5638        let kept = map_preserve(src, None);
5639        assert_eq!(
5640            line_texts(&kept),
5641            vec!["one two", "three four"],
5642            "preserve: a row per line"
5643        );
5644    }
5645
5646    #[test]
5647    fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
5648        // The break must leave a caret stop at the newline byte, or the caret
5649        // could not rest at the end of the first line. The '\n' glyph is dropped
5650        // from the row (so nothing stray renders); its offset (7 here) becomes the
5651        // row's end stop instead — the same offset the folded space would carry.
5652        let src = "one two\nthree four\n";
5653        let m = map_preserve(src, None);
5654        assert!(
5655            !m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
5656            "the break glyph is dropped"
5657        );
5658        assert_eq!(
5659            m.rows[0].end_src, 7,
5660            "the first row ends at the newline byte"
5661        );
5662        assert!(m.is_stop(7), "the newline offset is a caret stop");
5663        // Row end offsets stay strictly ascending — no two rows pin one offset.
5664        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
5665        assert!(
5666            offs.windows(2).all(|w| w[0] < w[1]),
5667            "offsets not unique: {offs:?}"
5668        );
5669    }
5670
5671    #[test]
5672    fn preserved_lines_wrap_independently() {
5673        // Each preserved line wraps to the column on its own; the break between
5674        // them is hard, so a word never crosses it — "gamma" and "delta" could
5675        // share a row on width alone but the soft break keeps them apart.
5676        let src = "alpha beta gamma\ndelta epsilon\n";
5677        let m = map_preserve(src, Some(12));
5678        assert_eq!(
5679            line_texts(&m),
5680            vec!["alpha beta", "gamma", "delta", "epsilon"],
5681            "each source line wraps on its own"
5682        );
5683    }
5684
5685    #[test]
5686    fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
5687        // "A", then two blank lines (an empty paragraph opened with Enter), then
5688        // "B": the empty paragraph must be navigable rows, not collapsed onto B.
5689        // Rows: "A", spacer, empty-paragraph, spacer, "B" — each blank row a
5690        // distinct source offset.
5691        let m = map("A\n\n\n\nB\n");
5692        let text: Vec<String> = m
5693            .rows
5694            .iter()
5695            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5696            .collect();
5697        assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
5698        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
5699        // Strictly ascending — no two rows share an offset (else the caret pins).
5700        assert!(
5701            offs.windows(2).all(|w| w[0] < w[1]),
5702            "offsets not unique: {offs:?}"
5703        );
5704    }
5705
5706    #[test]
5707    fn a_tight_block_boundary_still_gets_one_separator() {
5708        // A heading directly above text (no blank line between) keeps the single
5709        // conventional separator row, as before.
5710        let m = map("# H\ntext\n");
5711        let text: Vec<String> = m
5712            .rows
5713            .iter()
5714            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5715            .collect();
5716        assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
5717    }
5718
5719    #[test]
5720    fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
5721        // `a\*b` renders the three visible chars `a * b` — the escape backslash
5722        // is hidden — and every glyph points at its real source byte, so a caret
5723        // past the escape lands right (the `*` at source 2, `b` at source 3, not
5724        // the drifted 1/2 the naive text-offset mapping gave).
5725        let m = map("a\\*b\n");
5726        let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
5727        assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
5728    }
5729
5730    #[test]
5731    fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
5732        // `\# hi` is a paragraph beginning with a literal `#`, not a heading —
5733        // the backslash is hidden, the `#` shown at its true offset.
5734        let m = map("\\# hi\n");
5735        let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
5736        assert_eq!(text, "# hi");
5737        assert_eq!(
5738            m.rows[0].glyphs[0].src, 1,
5739            "the # is at source byte 1, past the \\"
5740        );
5741    }
5742
5743    #[test]
5744    fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
5745        // A list item's own text and the sub-list nested under it are written on
5746        // adjacent source lines, so the rich view butts them together — no
5747        // fabricated blank row. Regression: the synthetic "breathe" separator
5748        // used to open a gap between `• a` and its `  • b`.
5749        assert_eq!(rendered(&map("- a\n  - b\n")), "• a\n  • b");
5750    }
5751
5752    #[test]
5753    fn a_loose_nested_list_keeps_its_real_blank_line() {
5754        // A genuine blank source line (a loose list) still parts the item from
5755        // its sub-list — only the *fabricated* separator is suppressed, never a
5756        // real one the author typed. The gap row wears the item's continuation
5757        // prefix (the two-space indent), so it renders as "  ", not empty.
5758        assert_eq!(rendered(&map("- a\n\n  - b\n")), "• a\n  \n  • b");
5759    }
5760
5761    #[test]
5762    fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
5763        // Leading YAML frontmatter renders nothing — no phantom blank rows for
5764        // its lines, no leading gap — and `content_start` points at the first
5765        // real block so the caret floor can keep out of the hidden metadata.
5766        let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
5767        let src = format!("{fm}# leaf\n\nA line.\n");
5768        let m = map(&src);
5769        let text = rendered(&m);
5770        assert!(
5771            !text.contains("config"),
5772            "frontmatter body leaked: {text:?}"
5773        );
5774        assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
5775        assert_eq!(
5776            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5777            "leaf"
5778        );
5779        assert_eq!(
5780            m.content_start,
5781            fm.len(),
5782            "floor should be the first real block"
5783        );
5784    }
5785
5786    #[test]
5787    fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
5788        // Nothing to render, so the caret floor is the end of the hidden
5789        // frontmatter — not 0, which is *before* the opening `---` and made the
5790        // first keystroke in a fresh metadata-only note land ahead of it. And
5791        // the frontmatter's own newlines are not trailing blank lines: they used
5792        // to open phantom rows at offsets 1..4, inside the metadata.
5793        let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
5794        let m = map(src);
5795        assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
5796        assert!(
5797            m.rows.is_empty(),
5798            "frontmatter must render no rows: {:?}",
5799            rendered(&m)
5800        );
5801        assert!(
5802            m.stops.is_empty(),
5803            "no stop may sit inside the metadata: {:?}",
5804            m.stops
5805        );
5806    }
5807
5808    #[test]
5809    fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
5810        // Two blank lines after the frontmatter are the author's empty paragraph
5811        // and still render, counted from the metadata's end rather than from 0.
5812        let fm = "---\ntitle: n\n---\n";
5813        let m = map(&format!("{fm}\n\n"));
5814        assert_eq!(m.content_start, fm.len());
5815        assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
5816        assert!(
5817            m.rows.iter().all(|r| r.end_src > fm.len()),
5818            "rows must sit past the frontmatter"
5819        );
5820    }
5821
5822    #[test]
5823    fn a_document_without_frontmatter_has_a_zero_floor() {
5824        let m = map("# leaf\n\nbody\n");
5825        assert_eq!(m.content_start, 0);
5826    }
5827
5828    #[test]
5829    fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
5830        // Markdown/Djot drop the trailing space in `hello ` from the `str` node,
5831        // so without help the row would end at `hello` and the caret couldn't be
5832        // drawn past column 5 — typing a space at a line's end wouldn't move it
5833        // on screen until the next visible character reparsed the space into an
5834        // interior node. The builder recovers it from the block's span/content_span
5835        // gap and emits it as a real, caret-stoppable glyph.
5836        let m = map("hello \n");
5837        assert_eq!(
5838            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5839            "hello "
5840        );
5841        assert_eq!(
5842            m.rows[0].end_src, 6,
5843            "the row now ends past the trailing space"
5844        );
5845        // The caret can rest both on and past the space.
5846        assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
5847        assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
5848        // Two trailing spaces, both stops.
5849        let m = map("hello  \n");
5850        assert_eq!(
5851            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5852            "hello  "
5853        );
5854        assert_eq!(m.pos_of_offset(7), (0, 7));
5855    }
5856
5857    #[test]
5858    fn a_headings_trailing_space_is_a_caret_stop_too() {
5859        // The hidden `# ` marker means `# hi ` renders as `hi ` in three columns;
5860        // the caret past the trailing space lands on the third.
5861        let m = map("# hi \n");
5862        assert_eq!(
5863            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5864            "hi "
5865        );
5866        assert_eq!(m.pos_of_offset(5), (0, 3));
5867    }
5868
5869    #[test]
5870    fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
5871        // A cell's own `span` is the whole row, so the trailing-whitespace
5872        // recovery must not run for cells or it would swallow the `│` delimiters
5873        // and neighbours between the cell text and the row's end. The grid stays
5874        // exactly as before.
5875        let text = rendered(&map(TABLE));
5876        assert!(
5877            text.contains("│ Pear │   3 │"),
5878            "cell padding disturbed:\n{text}"
5879        );
5880    }
5881
5882    #[test]
5883    fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
5884        // A drag into the empty space under a short document used to resolve to
5885        // offset 0 — the wrong direction, and not even a caret stop when the
5886        // document opens on hidden frontmatter (its `content_start` floor is not
5887        // a stop), which crashed the caret invariant. It now lands on the last
5888        // stop: the end of the document, where dragging downward should reach.
5889        let fm = "---\ntitle: n\n---\n";
5890        let m = map(&format!("{fm}# Hi\n\nbody\n"));
5891        let below = m.num_rows() + 5;
5892        let off = m.offset_of_pos(below, 0);
5893        assert!(
5894            m.is_stop(off),
5895            "offset {off} from a below-content click is not a stop"
5896        );
5897        assert_eq!(
5898            off,
5899            m.stops.last().copied().unwrap(),
5900            "should be the document's last stop"
5901        );
5902        assert!(
5903            off > fm.len(),
5904            "must not fall onto the hidden frontmatter floor"
5905        );
5906    }
5907
5908    #[test]
5909    fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
5910        // The invariant the caret motion asserts: whatever cell a click names,
5911        // the offset it resolves to is one the caret can actually rest at.
5912        for src in [
5913            "hello \n",
5914            "# A heading here \n\nbody text goes on \n",
5915            "---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
5916        ] {
5917            let m = map(src);
5918            for row in 0..m.num_rows() + 3 {
5919                for col in 0..30 {
5920                    let off = m.offset_of_pos(row, col);
5921                    assert!(
5922                        m.is_stop(off),
5923                        "row {row} col {col} → {off} is not a stop in {src:?}"
5924                    );
5925                }
5926            }
5927        }
5928    }
5929
5930    /// `| Name | Qty |` with Name left-aligned and Qty right-aligned.
5931    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
5932
5933    #[test]
5934    fn a_table_renders_as_an_aligned_grid() {
5935        let text = rendered(&map(TABLE));
5936        assert_eq!(
5937            text,
5938            "┌──────┬─────┐\n\
5939             │ Name │ Qty │\n\
5940             ├──────┼─────┤\n\
5941             │ Pear │   3 │\n\
5942             │ Fig  │  12 │\n\
5943             └──────┴─────┘",
5944            "got:\n{text}"
5945        );
5946    }
5947
5948    #[test]
5949    fn table_columns_honour_their_alignment() {
5950        // Centre and default(left) come straight from twig's cell.alignment —
5951        // the delimiter row it's spelled in is consumed and has no node.
5952        let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
5953        assert!(text.contains("│ x │  y  │"), "centred column: {text:?}");
5954    }
5955
5956    #[test]
5957    fn table_borders_are_decoration_the_caret_never_lands_on() {
5958        let m = map(TABLE);
5959        // The rules are whole decoration rows.
5960        for r in [0, 2, 5] {
5961            assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
5962            assert!(
5963                !m.rows[r].glyphs.iter().any(|g| g.stop),
5964                "row {r} has a stop"
5965            );
5966        }
5967        // A content row's `│` and padding are decoration; only the cell text
5968        // and each cell's one end-stop are stops.
5969        let header = &m.rows[1];
5970        assert!(!header.decoration);
5971        for g in &header.glyphs {
5972            if g.ch == '│' {
5973                assert!(!g.stop, "a border is not a caret stop");
5974            }
5975        }
5976        let stops: String = header
5977            .glyphs
5978            .iter()
5979            .filter(|g| g.stop)
5980            .map(|g| g.ch)
5981            .collect();
5982        assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
5983    }
5984
5985    #[test]
5986    fn a_cell_maps_to_its_own_source_text() {
5987        let m = map(TABLE);
5988        // "Pear" starts at byte 32 in TABLE; the caret there draws on the 'P'.
5989        let pear = TABLE.find("Pear").unwrap();
5990        let (r, c) = m.pos_of_offset(pear);
5991        assert_eq!(m.rows[r].glyphs[c].ch, 'P');
5992        assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
5993    }
5994
5995    #[test]
5996    fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
5997        // Columns wider than the surface used to run off the right edge, where
5998        // nothing could reach them. They're cut to the budget instead, and the
5999        // text wraps down inside the column — the header rule stays put, and
6000        // an alignment holds on every line of a wrapped cell, not just the first.
6001        let src = "| Ingredient | Notes |\n|---|---:|\n\
6002                   | flour milled coarse | sift it twice |\n| salt | a pinch |\n";
6003        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6004        let m = build_t(&ed.nodes().unwrap(), src, Some(30));
6005        let text = rendered(&m);
6006        assert_eq!(
6007            text,
6008            "┌──────────────┬─────────────┐\n\
6009             │ Ingredient   │       Notes │\n\
6010             ├──────────────┼─────────────┤\n\
6011             │ flour milled │     sift it │\n\
6012             │ coarse       │       twice │\n\
6013             │ salt         │     a pinch │\n\
6014             └──────────────┴─────────────┘",
6015            "got:\n{text}"
6016        );
6017        for (r, row) in m.rows.iter().enumerate() {
6018            assert!(
6019                row.glyphs.len() <= 30,
6020                "row {r} overflows: {}",
6021                row.glyphs.len()
6022            );
6023        }
6024    }
6025
6026    #[test]
6027    fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
6028        // A paragraph lets an overlong word trail off the end of the line; a
6029        // table column can't — a glyph past the border lands on the border.
6030        let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
6031        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
6032        let m = build_t(&ed.nodes().unwrap(), src, Some(20));
6033        for (r, row) in m.rows.iter().enumerate() {
6034            assert!(
6035                row.glyphs.len() <= 20,
6036                "row {r} overflows: {}",
6037                row.glyphs.len()
6038            );
6039        }
6040        // Broken across lines, but whole: every letter is still drawn, at its
6041        // own source byte, where the caret can reach it.
6042        let word = "antidisestablishmentarianism";
6043        let at = src.find(word).unwrap();
6044        for (i, ch) in word.char_indices() {
6045            assert!(
6046                m.rows
6047                    .iter()
6048                    .flat_map(|r| r.glyphs.iter())
6049                    .any(|g| g.stop && g.src == at + i && g.ch == ch),
6050                "{ch:?} at {} was lost to the break",
6051                at + i
6052            );
6053        }
6054    }
6055
6056    #[test]
6057    fn a_code_block_maps_each_line_to_its_own_source_text() {
6058        // Every glyph used to point at the block's start, which made the whole
6059        // block one offset — visible, but impossible to put a caret inside.
6060        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
6061        let m = map(src);
6062        for row in &m.rows {
6063            for g in row.glyphs.iter().filter(|g| g.stop) {
6064                assert_eq!(
6065                    src[g.src..].chars().next(),
6066                    Some(g.ch),
6067                    "glyph {:?} at {} isn't the source byte it claims",
6068                    g.ch,
6069                    g.src
6070                );
6071            }
6072        }
6073    }
6074
6075    #[test]
6076    fn an_indented_code_block_maps_past_its_stripped_indent() {
6077        // twig strips the four-space indent, so `text` isn't a source slice and
6078        // the lines have to be re-found. Offsets land on the code, not the indent.
6079        let src = "    indented\n    code\n";
6080        let m = map(src);
6081        let stops: Vec<(char, usize)> = m
6082            .rows
6083            .iter()
6084            .flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
6085            .collect();
6086        assert_eq!(
6087            stops[0],
6088            ('i', 4),
6089            "first line should start past the indent"
6090        );
6091        assert!(
6092            stops.contains(&('c', 17)),
6093            "second line misplaced: {stops:?}"
6094        );
6095    }
6096
6097    #[test]
6098    fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
6099        // The one case that defeats a forward search: the opening fence
6100        // ```` ```rust ```` ends with the same text as the code under it.
6101        let src = "```rust\nrust\n```\n";
6102        let m = map(src);
6103        let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
6104        assert_eq!(first.src, 8, "matched the info string, not the code");
6105    }
6106
6107    #[test]
6108    fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
6109        // The old `▏ ` gutter is gone: a code row is the block prefix (none, at
6110        // the top level) plus the code text, and the whole run is named in
6111        // `code_blocks` so a frontend can box it.
6112        let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
6113        let m = map(src);
6114        assert_eq!(m.code_blocks.len(), 1, "one code block");
6115        let span = m.code_blocks[0].rows_span.clone();
6116        let rows: Vec<String> = m.rows[span.clone()]
6117            .iter()
6118            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6119            .collect();
6120        assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
6121        assert!(!rendered(&m).contains('▏'), "gutter still drawn");
6122        assert!(
6123            m.rows[span].iter().all(|r| r.code),
6124            "every row in the span is flagged code"
6125        );
6126    }
6127
6128    #[test]
6129    fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
6130        // `trim_end_matches('\n')` cut the block's terminator *and* the newline
6131        // that spells a trailing empty line, so the row the Return had just made
6132        // never appeared and the caret on it fell through to the block below.
6133        // Every empty line is a row, wherever in the block it falls.
6134        let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
6135        let m = map(src);
6136        let span = m.code_blocks[0].rows_span.clone();
6137        let rows: Vec<String> = m.rows[span.clone()]
6138            .iter()
6139            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6140            .collect();
6141        assert_eq!(
6142            rows,
6143            vec!["alpha".to_string(), "beta".to_string(), String::new()],
6144            "the empty last line gets a row"
6145        );
6146        assert!(
6147            m.rows[span.clone()].iter().all(|r| r.code),
6148            "the empty row is flagged code like the rest of the block"
6149        );
6150        // And it is the *source's* empty line, not a coarse fallback to the
6151        // block start: the offset the caret resolves to is the one Return made.
6152        let empty = span.end - 1;
6153        assert_eq!(
6154            m.rows[empty].end_src,
6155            src.find("beta\n\n").unwrap() + "beta\n".len(),
6156            "the empty row maps to the line the Return opened"
6157        );
6158
6159        // Nothing is invented where there is no empty line, and a second one is
6160        // a second row.
6161        assert_eq!(
6162            map("```\nalpha\nbeta\n```\n").code_blocks[0]
6163                .rows_span
6164                .len(),
6165            2,
6166            "a block that ends at its last code line keeps two rows"
6167        );
6168        assert_eq!(
6169            map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
6170            3,
6171            "two trailing empty lines are two rows"
6172        );
6173    }
6174
6175    #[test]
6176    fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
6177        // diaryx's `:::vis{.public .family}` visibility block, and any other
6178        // `:::name{.class}` fenced div — core is agnostic of `name`.
6179        let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
6180        let m = map_directives(src);
6181
6182        let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
6183        assert!(!content_rows.is_empty(), "some row is flagged directive");
6184
6185        let after_rows: Vec<usize> = (0..m.rows.len())
6186            .filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
6187            .collect();
6188        assert!(
6189            after_rows.iter().all(|&i| !m.rows[i].directive),
6190            "content outside the fence isn't tinted"
6191        );
6192
6193        let labels: Vec<&str> = content_rows
6194            .iter()
6195            .filter_map(|&i| m.rows[i].directive_label.as_deref())
6196            .collect();
6197        assert_eq!(
6198            labels,
6199            vec!["public family"],
6200            "only the first row carries the label"
6201        );
6202
6203        assert_eq!(
6204            rendered(&m)
6205                .lines()
6206                .filter(|l| !l.is_empty())
6207                .collect::<Vec<_>>(),
6208            vec!["hello", "world", "after"],
6209            "fence markers don't leak into the rendered text"
6210        );
6211    }
6212
6213    #[test]
6214    fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
6215        // diaryx_core::visibility's own `:::vis{public family}` — no leading
6216        // dots — is what apps/web's directive serializer and the native
6217        // publish-time filter both actually write today, distinct from twig's
6218        // `.class` convention. Both must label the same way so every existing
6219        // diaryx `:::vis{...}` block reads, not just newly dot-authored ones.
6220        let src = ":::vis{public family}\nhello\n:::\n";
6221        let m = map_directives(src);
6222        let label = m.rows.iter().find_map(|r| r.directive_label.clone());
6223        assert_eq!(label.as_deref(), Some("public family"));
6224    }
6225
6226    #[test]
6227    fn a_text_directive_keeps_its_paragraph_visible() {
6228        // Regression: an inline `:name[label]{…}` used to make its paragraph
6229        // fail the "all children inline" test, so the whole line was walked as
6230        // a container of blocks and rendered as empty rows with NO caret stops —
6231        // the text vanished from the editor and the caret couldn't enter it.
6232        // diaryx's inline `:vis[…]` is exactly this shape.
6233        let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
6234        let m = map_directives(src);
6235        assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
6236        // Every character of the line is a caret home, markup excluded — the
6237        // label reads as ordinary text, the way a link's does.
6238        let stops: usize = m
6239            .rows
6240            .iter()
6241            .map(|r| r.glyphs.iter().filter(|g| g.stop).count())
6242            .sum();
6243        assert_eq!(stops, "Text with HTML inline.".chars().count());
6244        // It is inline, so it is not the container form's tinted panel.
6245        assert!(m.rows.iter().all(|r| !r.directive));
6246    }
6247
6248    #[test]
6249    fn a_text_directives_label_maps_to_its_true_source_bytes() {
6250        // Regression (needs twig-doc >= 2.5.0): twig parses a `[label]` as a
6251        // detached slice, and until it rebased the enclosing scan's segments
6252        // onto it every node inside the label reported a span of `(0,0)`. Read
6253        // by anything that trusts a span that means "byte 0", so the label's
6254        // glyphs mapped to the START OF THE DOCUMENT — a click on the label put
6255        // the caret at the top of the file, its stops collided with the real
6256        // first line's, and an edit there landed on the wrong bytes entirely.
6257        //
6258        // The sibling test `a_text_directive_keeps_its_paragraph_visible` only
6259        // counts stops, which is exactly why this went unnoticed: the right
6260        // NUMBER of stops at completely wrong offsets.
6261        let src = "x :abbr[HTML]{title=\"y\"} z\n";
6262        let m = map_directives(src);
6263        let stops: Vec<(char, usize)> = m
6264            .rows
6265            .iter()
6266            .flat_map(|r| &r.glyphs)
6267            .filter(|g| g.stop)
6268            .map(|g| (g.ch, g.src))
6269            .collect();
6270        // `HTML` sits at 8..12. The name, brackets and `{…}` are hidden markup
6271        // the caret steps over, so the line's stops run 0, 1, 8..12, then 24.
6272        assert_eq!(
6273            stops,
6274            [
6275                ('x', 0),
6276                (' ', 1),
6277                ('H', 8),
6278                ('T', 9),
6279                ('M', 10),
6280                ('L', 11),
6281                (' ', 24),
6282                ('z', 25)
6283            ]
6284        );
6285    }
6286
6287    #[test]
6288    fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
6289        // The `every_glyph_points_at_its_source_byte` invariant, extended over
6290        // directive labels now that their offsets are real. Nested markup is
6291        // included: its delimiters are hidden, so the visible glyphs must skip
6292        // them and still name their own bytes.
6293        let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
6294        let m = map_directives(src);
6295        for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
6296            let at = src[g.src..].chars().next();
6297            assert_eq!(
6298                at,
6299                Some(g.ch),
6300                "glyph {:?} claims byte {}, which is {at:?}",
6301                g.ch,
6302                g.src
6303            );
6304        }
6305        assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
6306    }
6307
6308    #[test]
6309    fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
6310        let src = "x :abbr[a *b* c] y\n";
6311        let m = map_directives(src);
6312        let b = m
6313            .rows
6314            .iter()
6315            .flat_map(|r| &r.glyphs)
6316            .find(|g| g.ch == 'b')
6317            .expect("the emphasised char");
6318        assert!(b.style.italic, "the label's *b* lost its emphasis");
6319        assert_eq!(b.src, 11, "the label's *b* lost its source byte");
6320    }
6321
6322    #[test]
6323    fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
6324        // Regression: twig matches a colon followed by any letter-led word, so
6325        // ordinary prose is full of "text directives" nobody meant to write.
6326        // With no `[label]` there are no children, and the arm recursed into
6327        // them — rendering *nothing*. The word vanished from the document with
6328        // no caret stop left behind, so it could not even be deleted.
6329        for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
6330            let m = map_directives(src);
6331            assert_eq!(
6332                rendered(&m).trim_end(),
6333                src.trim_end(),
6334                "prose was eaten: {src:?}"
6335            );
6336        }
6337    }
6338
6339    #[test]
6340    fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
6341        let src = "a :word b\n";
6342        let m = map_directives(src);
6343        // Nothing here is markup, so nothing is hidden: each byte maps to
6344        // itself and can be stood on, which is what makes the colon deletable.
6345        let stops: Vec<(char, usize)> = m
6346            .rows
6347            .iter()
6348            .flat_map(|r| &r.glyphs)
6349            .filter(|g| g.stop)
6350            .map(|g| (g.ch, g.src))
6351            .collect();
6352        assert_eq!(
6353            stops,
6354            "a :word b"
6355                .chars()
6356                .enumerate()
6357                .map(|(i, c)| (c, i))
6358                .collect::<Vec<_>>()
6359        );
6360    }
6361
6362    #[test]
6363    fn an_attribute_bearing_text_directive_draws_a_chip() {
6364        // `{…}` is deliberate in a way a bare colon is not — diaryx writes
6365        // `:vis{.family}` inline — so this one reads as an embed, on the same
6366        // `⧉ label` recipe the leaf form's placeholder row uses.
6367        // Both attribute conventions label it: twig's dot-prefixed classes and
6368        // the bare pandoc-style words diaryx also writes.
6369        for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
6370            let m = map_directives(src);
6371            assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
6372        }
6373        // A `key=value` attr is configuration, not a name, so it adds nothing.
6374        let m = map_directives("a :foo{title=\"x\"} b\n");
6375        assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
6376    }
6377
6378    #[test]
6379    fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
6380        let src = "a :vis{.family} b\n";
6381        let m = map_directives(src);
6382        let stops: Vec<usize> = m
6383            .rows
6384            .iter()
6385            .flat_map(|r| &r.glyphs)
6386            .filter(|g| g.stop)
6387            .map(|g| g.src)
6388            .collect();
6389        // The chip contributes exactly one stop, at the directive's start (2),
6390        // so the caret steps over it whole instead of walking hidden markup a
6391        // byte at a time. `{.family}`'s bytes (3..15) are never stood on.
6392        assert_eq!(stops, [0, 1, 2, 15, 16]);
6393    }
6394
6395    #[test]
6396    fn a_paragraph_holding_only_a_chip_is_still_navigable() {
6397        // With no stop of its own the row would be unreachable — the caret
6398        // could never be put on the line to edit or delete the directive.
6399        let m = map_directives(":vis{.family}\n");
6400        assert!(
6401            m.row_is_navigable(0),
6402            "a chip-only paragraph has no caret home"
6403        );
6404        assert_eq!(
6405            m.offset_of_pos(0, 0),
6406            0,
6407            "its caret home isn't the directive's start"
6408        );
6409    }
6410
6411    #[test]
6412    fn a_ratio_or_a_clock_time_is_never_a_directive() {
6413        // twig requires a letter after the colon, so these stay prose — the
6414        // verbatim arm must not be reached for them at all.
6415        let src = "ratio 3:4 and 10:30\n";
6416        assert_eq!(
6417            rendered(&map_directives(src)).trim_end(),
6418            "ratio 3:4 and 10:30"
6419        );
6420    }
6421
6422    #[test]
6423    fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
6424        // `::name{…}` is a standalone block with no body — an embed, a table of
6425        // contents. It used to emit no rows at all: invisible, no caret home,
6426        // vertical motion crossing a void. Now it draws the image recipe's
6427        // placeholder and publishes what the host app needs to paint the real
6428        // thing.
6429        let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
6430        let m = map_directives(src);
6431
6432        let row = m
6433            .rows
6434            .iter()
6435            .position(|r| r.leaf_directive.is_some())
6436            .expect("a placeholder row");
6437        assert_eq!(
6438            m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
6439            "⧉ embed"
6440        );
6441        assert!(
6442            m.rows[row].glyphs.iter().any(|g| g.stop),
6443            "the caret can land on it"
6444        );
6445        assert!(
6446            m.rows[row].directive,
6447            "a frontend frames it like the container form"
6448        );
6449
6450        assert_eq!(m.directives.len(), 1);
6451        let info = &m.directives[0];
6452        assert_eq!(info.name, "embed");
6453        assert_eq!(info.rows_span, row..row + 1);
6454        assert_eq!(info.attr("src"), Some("demo.html"));
6455        assert_eq!(info.attr("height"), Some("400"));
6456        assert_eq!(info.attr("nope"), None);
6457        // The prose around it is untouched.
6458        assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
6459    }
6460
6461    #[test]
6462    fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
6463        // A `[label]` names the placeholder (the way an image's alt does), and a
6464        // quoted directive keeps the quote's gutter — it is a block like any
6465        // other, not a special case that escapes its container.
6466        let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
6467        assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
6468        assert_eq!(m.directives[0].label, "Audience demo");
6469
6470        let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
6471        assert_eq!(rendered(&quoted).trim_end(), "│ ⧉ embed");
6472        assert_eq!(quoted.directives[0].name, "embed");
6473    }
6474
6475    #[test]
6476    fn a_container_directive_is_still_a_panel_not_a_placeholder() {
6477        // The three forms must not bleed into each other: only the leaf form is
6478        // a placeholder, and only the container form tints the blocks it wraps.
6479        let m = map_directives(":::note{.warning}\nBody\n:::\n");
6480        assert!(
6481            m.directives.is_empty(),
6482            "a container publishes no placeholder"
6483        );
6484        assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
6485        assert_eq!(rendered(&m).trim_end(), "Body");
6486        assert!(
6487            m.rows
6488                .iter()
6489                .any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
6490        );
6491    }
6492
6493    /// A production-path build with both extensions on — the only way to put a
6494    /// promoted HTML element and a directive in one document, which is what the
6495    /// `container` kind made necessary to tell apart. Returns the whole `Doc`
6496    /// because [`VisualMap`] is not `Clone`; read `doc.vmap`.
6497    fn doc_built(src: &str) -> crate::Doc {
6498        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
6499        doc.build_visual(80);
6500        doc
6501    }
6502
6503    /// Every `container` node in `src`, parsed the way production does (both
6504    /// extensions on), paired with what [`container_is_directive`] makes of it.
6505    fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
6506        let mut ed = Editor::new_ext(
6507            src.as_bytes(),
6508            Format::Markdown,
6509            twig::MarkdownExtensions {
6510                directives: true,
6511                html_elements: true,
6512                ..Default::default()
6513            },
6514        )
6515        .unwrap();
6516        ed.nodes()
6517            .unwrap()
6518            .iter()
6519            .filter(|n| n.kind == Kind::Container)
6520            .map(|n| {
6521                (
6522                    n.name.clone().unwrap_or_default(),
6523                    container_is_directive(n),
6524                    n.directive_form,
6525                )
6526            })
6527            .collect()
6528    }
6529
6530    #[test]
6531    fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
6532        // twig 2.8 folded `div`/`span`/`directive`/`element` into one `container`
6533        // kind. `directive_form` reads as though it separates them and does not:
6534        // a block-level `<div>` reports `Some(DirectiveForm::Container)` exactly
6535        // as a `:::note` does. Trusting it would draw directive chrome — a tinted
6536        // panel, a `.class` audience label — on every pasted Slack/Docs div.
6537        for (src, name, want) in [
6538            (":::note{.a}\nbody\n:::\n", "note", true),
6539            ("::embed{src=x}\n", "embed", true),
6540            ("a :vis[hi]{.b} b\n", "vis", true),
6541            ("<div class=\"x\">\nhi\n</div>\n", "div", false),
6542            ("<video src=\"v.mp4\" controls></video>\n", "video", false),
6543            ("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
6544            ("<figure>\n\nhi\n\n</figure>\n", "figure", false),
6545            // The `:` in an attribute must not read as a directive opener: the
6546            // `<` of the tag comes first, and first one wins.
6547            (
6548                "<video src=\"http://x.test/v.mp4\" controls></video>\n",
6549                "video",
6550                false,
6551            ),
6552            (
6553                "<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
6554                "source",
6555                false,
6556            ),
6557        ] {
6558            let found = containers(src);
6559            let hit = found.iter().find(|(n, ..)| n == name);
6560            let Some((_, is_directive, form)) = hit else {
6561                panic!("no `{name}` container in {src:?} — found {found:?}");
6562            };
6563            assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
6564        }
6565
6566        // And the reason this can't just read the field: for the one collision
6567        // that matters, the field says the same thing for both.
6568        let div = containers("<div class=\"x\">\nhi\n</div>\n");
6569        let note = containers(":::note{.a}\nbody\n:::\n");
6570        assert_eq!(
6571            div[0].2, note[0].2,
6572            "if these ever differ, `directive_form` became usable and this rule can go"
6573        );
6574    }
6575
6576    #[test]
6577    fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
6578        // A container's span opens with its *block prefix*, not its own markup —
6579        // `> ::embed{…}` starts at the `>`. Reading only the first byte to tell a
6580        // directive from an element (both `container` since 2.8) therefore misses
6581        // every nested one, and the placeholder silently renders as nothing.
6582        for (src, ctx) in [
6583            ("> ::embed{src=\"x\"}\n", "quoted"),
6584            ("- ::embed{src=\"x\"}\n", "listed"),
6585            (">> ::embed{src=\"x\"}\n", "twice quoted"),
6586        ] {
6587            let m = map_directives(src);
6588            assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
6589            assert_eq!(m.directives[0].name, "embed", "{ctx}");
6590        }
6591    }
6592
6593    #[test]
6594    fn a_video_is_still_media_and_not_a_directive() {
6595        // The other side of the same coin: `<video>` is a `container` too, and
6596        // must reach `block_media` rather than the directive arms.
6597        let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
6598        assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
6599        assert!(
6600            doc.vmap.rows.iter().all(|r| !r.directive),
6601            "the video drew directive chrome"
6602        );
6603    }
6604
6605    #[test]
6606    fn a_directive_needs_the_extension_flag() {
6607        // `map` (twig's default extensions) leaves `directives` off — the fence
6608        // renders as literal paragraph text, same as any other unrecognized
6609        // punctuation, never corrupting or panicking.
6610        let src = ":::vis{.public}\nhello\n:::\n";
6611        let m = map(src);
6612        assert!(m.rows.iter().all(|r| !r.directive));
6613        assert!(rendered(&m).contains(":::vis{.public}"));
6614    }
6615
6616    #[test]
6617    fn a_footnote_reference_keeps_its_paragraph_visible() {
6618        // Regression: `footnote_reference` was in neither `is_inline_kind` nor
6619        // the inline walker, so a paragraph carrying one failed the "all children
6620        // inline" test, was walked as a container of blocks, and rendered as
6621        // empty rows with no caret stop anywhere — the whole line vanished.
6622        let src = "A claim[^1] and more.\n";
6623        let m = map(src);
6624        assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
6625        // The `^` is spelling, not text: hidden the way a link's `](dest)` is.
6626        assert!(!rendered(&m).contains('^'));
6627    }
6628
6629    #[test]
6630    fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
6631        // What makes `[1]` read as a reference rather than as bracketed text.
6632        // The brackets ride with the label: the chip is one raised mark.
6633        let m = map("A claim[^1] and more.\n");
6634        assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
6635        assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
6636        assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
6637        assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
6638    }
6639
6640    #[test]
6641    fn a_footnote_reference_keeps_the_link_role_it_had() {
6642        // The raised baseline is added to the role, not swapped for it: every
6643        // frontend already paints `Role::Link`, and a reference is one.
6644        let m = map("A claim[^1].\n");
6645        let label = m
6646            .rows
6647            .iter()
6648            .flat_map(|r| &r.glyphs)
6649            .find(|g| g.ch == '1')
6650            .unwrap();
6651        assert_eq!(label.style.role, Role::Link);
6652        assert_eq!(label.style.baseline, Baseline::Super);
6653    }
6654
6655    /// The [`Role`] of the first glyph spelling `ch` — how a test reads one
6656    /// run's styling off a map without caring which row it landed on.
6657    fn role_of(m: &VisualMap, ch: char) -> Role {
6658        m.rows
6659            .iter()
6660            .flat_map(|r| r.glyphs.iter())
6661            .find(|g| g.ch == ch)
6662            .unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
6663            .style
6664            .role
6665    }
6666
6667    #[test]
6668    fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
6669        // twig 3.3's `highlight`/`highlight_colors`, which `parse_extensions`
6670        // turns on for every leaf document: `==text==` is a `mark` in Markdown
6671        // and not the literal `==` it used to be, and `==🔴 text==` is one
6672        // carrying a colour.
6673        //
6674        // `doc_built` rather than `map`, deliberately — the extensions are
6675        // leaf's choice, not twig's default, so a test that parsed bare
6676        // Markdown here would be testing a document leaf never builds.
6677        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
6678        assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
6679        assert_eq!(
6680            role_of(&doc.vmap, 'r'),
6681            Role::Mark(Some(MarkColor::Red)),
6682            "the `data-color` twig stripped the emoji into"
6683        );
6684        assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
6685    }
6686
6687    #[test]
6688    fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
6689        // The colour is *spelling*: twig strips the emoji out of the mark's
6690        // content, so the reader sees the words and the wash, never the circle.
6691        // Drawing it would put a character in the rendered text that the author
6692        // wrote as syntax — the same mistake as drawing an emphasis's `*`.
6693        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
6694        let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6695        assert_eq!(drawn, "Plain yes and red ok");
6696    }
6697
6698    #[test]
6699    fn a_superscript_and_a_subscript_sit_off_the_baseline() {
6700        // Regression: both rendered flat, so the toolbar's superscript button
6701        // produced markup that looked exactly like the text around it.
6702        let m = map_djot("H~2~O and x^2^\n");
6703        assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
6704        assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
6705        assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
6706    }
6707
6708    #[test]
6709    fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
6710        // Why this is a `Baseline` and not a `Role`: raising a glyph says where
6711        // it sits, and must not cost it what it already was.
6712        let m = map_djot("# Heading x^2^\n");
6713        let two = m
6714            .rows
6715            .iter()
6716            .flat_map(|r| &r.glyphs)
6717            .find(|g| g.ch == '2')
6718            .unwrap();
6719        assert_eq!(two.style.baseline, Baseline::Super);
6720        assert_eq!(two.style.role, Role::Heading(1), "still heading text");
6721    }
6722
6723    #[test]
6724    fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
6725        let src = "see[^note] here\n";
6726        let m = map(src);
6727        // `[^note]` spans 3..10, its label `note` 5..9. The caret walks the
6728        // label; the brackets are drawn but never stood on, as a table's are,
6729        // and the `[^`/`]` bytes are stepped over like any hidden delimiter.
6730        let stops: Vec<usize> = m
6731            .rows
6732            .iter()
6733            .flat_map(|r| &r.glyphs)
6734            .filter(|g| g.stop)
6735            .map(|g| g.src)
6736            .collect();
6737        for off in 5..9 {
6738            assert!(
6739                stops.contains(&off),
6740                "label byte {off} isn't a caret stop: {stops:?}"
6741            );
6742        }
6743        for off in [3usize, 4, 9] {
6744            assert!(
6745                !stops.contains(&off),
6746                "delimiter byte {off} is a caret stop: {stops:?}"
6747            );
6748        }
6749    }
6750
6751    #[test]
6752    fn a_task_item_draws_its_box_where_the_bullet_would_be() {
6753        // Regression: the `[ ] ` is markup twig consumes — the item's paragraph
6754        // content starts past it — so a task item used to render as `• todo`,
6755        // identical to a plain bullet and with no way to see it was ticked.
6756        let m = map("- [ ] todo\n- [x] done\n- plain\n");
6757        assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
6758
6759        // The tick rides the item's first row, for a GUI that paints its own box.
6760        let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
6761        assert_eq!(ticks, [Some(false), Some(true), None]);
6762    }
6763
6764    #[test]
6765    fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
6766        let m = map_at(
6767            "- [x] a much longer task that has to wrap somewhere\n",
6768            Some(20),
6769        );
6770        assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
6771        assert_eq!(m.rows[0].task, Some(true));
6772        assert!(
6773            m.rows[1..].iter().all(|r| r.task.is_none()),
6774            "only the first row"
6775        );
6776        // The continuation lines hang under the box, not under column zero.
6777        assert!(
6778            rendered(&m)
6779                .lines()
6780                .nth(1)
6781                .is_some_and(|l| l.starts_with("  "))
6782        );
6783    }
6784
6785    #[test]
6786    fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
6787        // `task_checked` finds the box past the list marker; a plain item whose
6788        // text merely contains a bracket has none, and must keep its bullet.
6789        let m = map("- see [1] below\n");
6790        assert_eq!(rendered(&m), "• see [1] below");
6791        assert_eq!(m.rows[0].task, None);
6792    }
6793
6794    #[test]
6795    fn a_footnote_definition_renders_where_it_was_written() {
6796        // Regression: twig parses `[^1]: …` as a root *beside* `doc` — not a
6797        // child of it — so the walk from `doc` never reached one and every byte
6798        // of the note's body rendered as nothing at all.
6799        let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
6800        let m = map(src);
6801        let text = rendered(&m);
6802        assert!(
6803            text.contains("The note body."),
6804            "the note body is invisible: {text:?}"
6805        );
6806        // In source order — between the paragraph that cites it and the one
6807        // after — not hoisted to the end, and marked to match its reference.
6808        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
6809        assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
6810    }
6811
6812    #[test]
6813    fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
6814        let src = "x[^a].\n\n[^a]: body\n";
6815        let m = map(src);
6816        // `body` sits at 14..18. Its glyphs must map there — a marker that ate
6817        // the offsets would put the caret in the wrong place on every click.
6818        let body: Vec<(char, usize)> = m
6819            .rows
6820            .iter()
6821            .flat_map(|r| &r.glyphs)
6822            .filter(|g| g.stop && g.src >= 14)
6823            .map(|g| (g.ch, g.src))
6824            .collect();
6825        assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
6826    }
6827
6828    #[test]
6829    fn an_empty_footnote_definition_still_shows_its_marker() {
6830        // The instant `[^1]: ` has been typed and nothing after it. `blocks`
6831        // renders no child, so without the explicit marker row the definition
6832        // wouldn't appear at all until something was typed into it.
6833        let src = "x[^1]\n\n[^1]:\n";
6834        let m = map(src);
6835        assert!(
6836            rendered(&m).contains("[1] "),
6837            "no marker row: {:?}",
6838            rendered(&m)
6839        );
6840    }
6841
6842    #[test]
6843    fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
6844        let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
6845        let m = map_at(src, Some(24));
6846        let text = rendered(&m);
6847        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
6848        // Continuation lines hang under the marker, as a list item's do — the
6849        // indent is the marker's own width, not a fixed one.
6850        assert_eq!(lines[1].trim_end(), "[src] one two three four");
6851        assert!(
6852            lines[2].starts_with("      "),
6853            "body doesn't hang: {:?}",
6854            lines[2]
6855        );
6856        assert_eq!(lines[2].trim(), "five six seven");
6857    }
6858
6859    #[test]
6860    fn a_code_block_leaves_exactly_one_blank_row_below_it() {
6861        // The closing fence line used to be miscounted as a blank separator,
6862        // opening a phantom second gap under the block. One block boundary is
6863        // one blank row, code block or not.
6864        let src = "para\n\n```\ncode\n```\n\nafter\n";
6865        let m = map(src);
6866        let code_end = m.code_blocks[0].rows_span.end;
6867        let after = m
6868            .rows
6869            .iter()
6870            .position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
6871            .unwrap();
6872        assert_eq!(
6873            after - code_end,
6874            1,
6875            "exactly one row between code and 'after'"
6876        );
6877    }
6878
6879    #[test]
6880    fn a_fenced_block_publishes_its_language_on_its_code_block() {
6881        // The info string becomes the block's label; a bare fence and an indented
6882        // block carry none.
6883        assert_eq!(
6884            map("```rust\nlet x = 1;\n```\n").code_blocks[0]
6885                .lang
6886                .as_deref(),
6887            Some("rust")
6888        );
6889        assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
6890        assert_eq!(map("    indented\n").code_blocks[0].lang, None);
6891    }
6892
6893    /// The token every glyph spelling `ch` carries, in row order — how a test
6894    /// reads a block's highlighting off the map.
6895    fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
6896        m.rows
6897            .iter()
6898            .flat_map(|r| r.glyphs.iter())
6899            .filter(|g| g.ch == ch)
6900            .map(|g| g.style.token)
6901            .collect()
6902    }
6903
6904    #[cfg(feature = "syntax")]
6905    #[test]
6906    fn a_fenced_block_in_a_known_language_carries_tokens() {
6907        // `let` is a keyword, the string literal a string, and the plain
6908        // identifier `x` nothing at all — it draws in the code colour. Every
6909        // glyph is still `Role::Code`: a token is beside the role, not instead.
6910        let m = map("```rust\nlet x = \"s\";\n```\n");
6911        assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
6912        assert_eq!(tokens_of(&m, 'x'), vec![None]);
6913        assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
6914        assert!(
6915            m.rows
6916                .iter()
6917                .filter(|r| r.code)
6918                .flat_map(|r| r.glyphs.iter())
6919                .all(|g| g.style.role == Role::Code),
6920            "a token replaced the code role"
6921        );
6922    }
6923
6924    #[cfg(feature = "syntax")]
6925    #[test]
6926    fn a_token_changes_nothing_about_where_a_glyph_is() {
6927        // The same block with and without a language it can be highlighted in
6928        // lays out identically: same rows, same offsets, same stops. Only the
6929        // token differs, so the caret walks a highlighted block as it walked an
6930        // unhighlighted one.
6931        let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
6932        let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
6933        assert_eq!(hl.rows.len(), plain.rows.len());
6934        for (a, b) in hl.rows.iter().zip(&plain.rows) {
6935            assert_eq!(a.end_src, b.end_src);
6936            assert_eq!(a.glyphs.len(), b.glyphs.len());
6937            for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
6938                assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
6939                assert_eq!(ga.style.token(None), gb.style);
6940            }
6941        }
6942        assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
6943        assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
6944    }
6945
6946    #[test]
6947    fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
6948        // A bare fence, an indented block, a fence in a language no grammar
6949        // covers, and inline code all draw as plain code — and so does a
6950        // `rust` fence when the `syntax` feature is off.
6951        for src in [
6952            "```\nlet x = 1;\n```\n",
6953            "    let x = 1;\n",
6954            "```no-such-language\nlet x = 1;\n```\n",
6955            "a `let x` b\n",
6956        ] {
6957            assert!(
6958                tokens_of(&map(src), 'l').iter().all(Option::is_none),
6959                "{src:?} was highlighted"
6960            );
6961        }
6962        #[cfg(not(feature = "syntax"))]
6963        assert!(
6964            tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
6965                .iter()
6966                .all(Option::is_none)
6967        );
6968    }
6969
6970    #[test]
6971    fn inline_code_is_not_a_code_block() {
6972        // A `code` span inside prose is styled by role, not boxed: it's part of a
6973        // normal paragraph row, so it names no `code_blocks` entry.
6974        let m = map("a `snippet` b\n");
6975        assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
6976        assert!(
6977            m.rows.iter().all(|r| !r.code),
6978            "inline code flagged a code row"
6979        );
6980    }
6981
6982    #[test]
6983    fn caret_steps_over_hidden_delimiters() {
6984        // "a **bold** c": bytes 8,9 are the closing ** — no glyph. Moving right
6985        // from 'd' (src 7) lands on the space before 'c' (src 10), not inside **.
6986        let m = map("a **bold** c\n");
6987        let (r, c) = m.pos_of_offset(7);
6988        assert_eq!(m.offset_of_pos(r, c + 1), 10);
6989    }
6990
6991    // ── the structural view of a table ───────────────────────────────────────
6992
6993    #[test]
6994    fn a_table_is_published_structurally_beside_its_picture() {
6995        let m = map(TABLE);
6996        let t = &m.tables[0];
6997        let cell = |r: usize, c: usize| -> String {
6998            t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
6999        };
7000        assert_eq!(t.grid.len(), 3, "head + two body rows");
7001        assert_eq!(
7002            (cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
7003            ("Name".into(), "Qty".into(), "Pear".into(), "12".into())
7004        );
7005        assert_eq!(
7006            t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
7007            [true, false, false]
7008        );
7009        // The alignment the delimiter row spelled, carried per cell — the only
7010        // place it survives, since the parser consumes that row.
7011        assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
7012        assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
7013    }
7014
7015    #[test]
7016    fn a_block_media_is_published_structurally_beside_its_placeholder() {
7017        let m = map("intro\n\n![a cat](img/cat.png)\n\nend\n");
7018        assert_eq!(m.media.len(), 1, "one block image");
7019        let img = &m.media[0];
7020        assert_eq!(img.destination, "img/cat.png");
7021        assert_eq!(img.alt, "a cat");
7022        // The placeholder row named by `rows_span` carries the label a plain
7023        // surface paints and a capable frontend replaces.
7024        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7025        assert_eq!(
7026            img.rows_span.end - img.rows_span.start,
7027            1,
7028            "one placeholder row"
7029        );
7030        assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
7031        // The row carries the mark `media_spans` derives the side-table from.
7032        assert!(m.rows[img.rows_span.start].media.is_some());
7033    }
7034
7035    #[test]
7036    fn an_image_without_alt_labels_itself_with_its_filename() {
7037        let m = map("![](photos/beach.jpg)\n");
7038        let row = &m.rows[m.media[0].rows_span.start];
7039        assert_eq!(
7040            row.glyphs.iter().map(|g| g.ch).collect::<String>(),
7041            "🖼 beach.jpg"
7042        );
7043        assert_eq!(m.media[0].alt, "");
7044    }
7045
7046    #[test]
7047    fn an_empty_cells_home_is_read_from_either_shape_of_span() {
7048        // A whole-row span: the cell's pipes are the `col`-th and next.
7049        let row = "|  |  |";
7050        assert_eq!(empty_cell_offset(row, 10, 0), 12);
7051        assert_eq!(empty_cell_offset(row, 10, 1), 15);
7052        // A cell's own span, opening pipe to closing pipe exclusive: the same
7053        // homes, each read from its own span.
7054        assert_eq!(empty_cell_offset("|  ", 10, 0), 12);
7055        assert_eq!(empty_cell_offset("|  ", 13, 1), 15);
7056        // Nothing to stand in: just inside the pipe, never past the span.
7057        assert_eq!(empty_cell_offset("|", 10, 0), 11);
7058        assert_eq!(empty_cell_offset("", 10, 1), 10);
7059    }
7060
7061    #[test]
7062    fn a_hidden_marks_content_end_is_a_caret_home_but_not_a_glyph_stop() {
7063        // `a **bold** b`: the `d` is at 7, the content ends at 8, the closing
7064        // `**` draws nothing, and the space after it is at 10. Two homes at one
7065        // spot on screen: 8 (inside the bold) and 10 (past it).
7066        let m = map("a **bold** b\n");
7067        assert!(
7068            !m.stops.contains(&8),
7069            "8 has no glyph, so it is no glyph stop"
7070        );
7071        assert_eq!(m.mark_ends, vec![8]);
7072        assert!(m.is_stop(8), "but the caret may rest there");
7073        assert_eq!(m.snap_to_stop(8), 8, "and is left there when placed there");
7074        // Left/Right take both homes; the character-pairing walk takes one.
7075        assert_eq!(m.caret_stop_after(7), Some(8));
7076        assert_eq!(m.caret_stop_after(8), Some(10));
7077        assert_eq!(m.caret_stop_before(10), Some(8));
7078        assert_eq!(m.caret_stop_before(8), Some(7));
7079        assert_eq!(m.stop_after(7), Some(10));
7080        assert_eq!(m.stop_before(10), Some(7));
7081        // Drawn where the next glyph is: after the `d`, not on it.
7082        assert_eq!(m.pos_of_offset(8), m.pos_of_offset(10));
7083    }
7084
7085    #[test]
7086    fn every_hidden_inline_mark_gives_its_content_end_a_home() {
7087        // One end per mark, whatever it is spelled with; nested marks closing
7088        // together share the outer's end and the inner's alike.
7089        assert_eq!(
7090            map("*em* `code` [link](u) ~~del~~\n").mark_ends,
7091            vec![3, 10, 17, 27]
7092        );
7093        assert_eq!(map("***both***\n").mark_ends, vec![7]);
7094        // A mark that closes at its row's end coincides with the row's own end
7095        // stop — one offset, in both tables.
7096        let m = map("**bold**\n");
7097        assert_eq!(m.mark_ends, vec![6]);
7098        assert!(m.stops.contains(&6));
7099        // Revealed, the delimiter is glyphs of its own and the end is an
7100        // ordinary glyph stop: nothing to add.
7101        let mut ed = Editor::new_str("a **bold** b\n", Format::Markdown).unwrap();
7102        let src = "a **bold** b\n";
7103        let revealed = build(
7104            &ed.nodes().unwrap(),
7105            src,
7106            Some(80),
7107            false,
7108            &HashMap::new(),
7109            Some(0..src.len()),
7110        );
7111        assert!(revealed.mark_ends.is_empty());
7112        assert!(revealed.stops.contains(&8));
7113    }
7114
7115    #[test]
7116    fn a_marks_content_end_is_a_home_inside_a_table_cell() {
7117        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
7118        let m = map(src);
7119        let end = src.find("bold").unwrap() + 4; // 32, before the closing `**`
7120        assert_eq!(m.mark_ends, vec![end]);
7121        assert_eq!(m.snap_to_stop(end), end);
7122        // Drawn after the `d`, in this cell — where the cell's own end stop is.
7123        assert_eq!(m.pos_of_offset(end), m.pos_of_offset(end + 2));
7124    }
7125
7126    #[test]
7127    fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
7128        // `![x](y)` on its own line: the caret can rest in front of the image
7129        // (its start) and just past it (the row end), and nowhere inside the
7130        // markup — the same coarse mapping a thematic break uses.
7131        let src = "![x](y.png)\n";
7132        let m = map(src);
7133        let img = &m.rows[m.media[0].rows_span.start];
7134        let start = 0; // the image opens the document
7135        let end = "![x](y.png)".len();
7136        // Every placeholder glyph maps to the image start and is a stop there.
7137        assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
7138        assert_eq!(img.end_src, end, "the row ends past the image");
7139        assert_eq!(m.stops.first(), Some(&start));
7140        assert!(m.stops.contains(&end), "a stop sits after the image");
7141        // Nothing inside the markup is a stop.
7142        assert!(!m.stops.iter().any(|&s| s > start && s < end));
7143    }
7144
7145    #[test]
7146    fn an_inline_image_amid_text_is_not_a_block_media() {
7147        // An image sharing its line with prose isn't block-level: it stays in the
7148        // inline path (rendered as its alt text), and publishes no MediaInfo.
7149        let m = map("see ![a cat](cat.png) here\n");
7150        assert!(m.media.is_empty(), "not a block image");
7151        assert!(
7152            rendered(&m).contains("a cat"),
7153            "alt text still renders inline"
7154        );
7155    }
7156
7157    /// The block images `Doc` publishes for `src`, driven through the real
7158    /// production build (`build_visual` → `build_cached`) with `html_elements`
7159    /// on — the path a `<picture>` actually travels. Not the raw `build` the
7160    /// other tests use: the editor's flat whole-arena snapshot tangles the links
7161    /// of inline-promoted HTML (phantom roots, dangling `parent`s), which only
7162    /// the per-block subtree walk `build_cached` does untangles.
7163    fn doc_media(src: &str) -> Vec<MediaInfo> {
7164        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7165        doc.build_visual(80);
7166        doc.vmap.media.clone()
7167    }
7168
7169    #[test]
7170    fn a_video_block_is_media_with_its_src_poster_and_kind() {
7171        // The load-bearing assumption of video support: twig has no `video` node
7172        // kind, so `html_elements` promotion must land a `<video>` as a generic
7173        // `element` whose tag name and attributes survive onto `FlatNode` — the
7174        // same treatment `<picture>` gets. If that ever stops holding, this is
7175        // the test that says so.
7176        let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
7177        assert_eq!(m.len(), 1, "the video is one block media");
7178        assert_eq!(m[0].kind, MediaKind::Video);
7179        assert_eq!(m[0].destination, "clip.mp4");
7180        assert_eq!(m[0].poster, "still.png");
7181    }
7182
7183    #[test]
7184    fn a_single_line_video_is_a_block_too() {
7185        // The spelling everyone actually writes. It used to parse as a paragraph
7186        // of raw inline HTML — CommonMark opens a block on a complete tag only
7187        // when the line ends there, and its fixed tag list predates `<video>` —
7188        // so the tags never reached core as an element at all. twig 2.5.1 widened
7189        // that list under `html_elements`; this is the test that would catch the
7190        // pin sliding back.
7191        let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
7192        assert_eq!(m.len(), 1, "single-line <video> is a block");
7193        assert_eq!(m[0].kind, MediaKind::Video);
7194        assert_eq!(m[0].destination, "clip.mp4");
7195    }
7196
7197    #[test]
7198    fn a_single_line_picture_is_a_block_with_its_alternatives() {
7199        // `<picture>` had the identical gap and it went unnoticed because the
7200        // conventional spelling breaks the lines. Same twig fix covers it.
7201        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
7202                   <img src=\"l.svg\" alt=\"banner\"></picture>\n";
7203        let m = doc_media(src);
7204        assert_eq!(m.len(), 1);
7205        assert_eq!(m[0].kind, MediaKind::Image);
7206        assert_eq!(m[0].destination, "l.svg");
7207        assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
7208    }
7209
7210    #[test]
7211    fn an_audio_block_is_media_with_no_poster() {
7212        let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
7213        assert_eq!(m.len(), 1);
7214        assert_eq!(m[0].kind, MediaKind::Audio);
7215        assert_eq!(m[0].destination, "take.mp3");
7216        assert!(m[0].poster.is_empty(), "audio has no poster frame");
7217    }
7218
7219    #[test]
7220    fn a_videos_source_children_are_its_candidates_typed_by_mime() {
7221        // A `<video>` with no `src` of its own — the common shape, since it's how
7222        // you offer more than one codec. The candidates come from `<source src>`
7223        // (not `srcset`, which is `<picture>`'s spelling) and carry their MIME.
7224        let src = "<video controls>\n\
7225                   <source src=\"a.webm\" type=\"video/webm\">\n\
7226                   <source src=\"a.mp4\" type=\"video/mp4\">\n\
7227                   fallback\n\
7228                   </video>\n";
7229        let m = doc_media(src);
7230        assert_eq!(m.len(), 1);
7231        assert!(
7232            m[0].destination.is_empty(),
7233            "no src attribute on the element"
7234        );
7235        assert_eq!(m[0].sources.len(), 2);
7236        assert_eq!(m[0].sources[0].srcset, "a.webm");
7237        assert_eq!(m[0].sources[0].mime, "video/webm");
7238        assert_eq!(m[0].sources[1].srcset, "a.mp4");
7239        // With an empty destination, `resolve` falls through to the first
7240        // candidate rather than handing the frontend nothing to load.
7241        assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
7242    }
7243
7244    #[test]
7245    fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
7246        // The placeholder contract images already hold, now for a video: the row
7247        // renders as a labelled stand-in a plain surface can paint as-is, and
7248        // carries the mark a capable frontend replaces it from.
7249        let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
7250        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
7251        doc.build_visual(80);
7252        let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
7253        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7254        assert!(
7255            text.starts_with('🎬'),
7256            "video sigil, not the image one: {text:?}"
7257        );
7258        assert!(row.media.is_some(), "the mark rides the placeholder row");
7259    }
7260
7261    #[test]
7262    fn a_picture_block_carries_its_source_alternatives() {
7263        // A `<picture>` with a dark-mode `<source>`: one block image, whose
7264        // fallback destination is the `<img>` and whose `sources` carry the
7265        // `<source>`'s media + srcset for a theme-aware frontend to pick.
7266        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
7267        let images = doc_media(src);
7268        assert_eq!(images.len(), 1, "the picture is one block image");
7269        let img = &images[0];
7270        assert_eq!(img.destination, "light.svg", "fallback is the <img>");
7271        assert_eq!(img.alt, "banner");
7272        assert_eq!(
7273            img.sources,
7274            vec![MediaSource {
7275                media: "(prefers-color-scheme: dark)".into(),
7276                srcset: "dark.svg".into(),
7277                mime: String::new(),
7278            }],
7279        );
7280    }
7281
7282    #[test]
7283    fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
7284        // fig.md's shape: the banner is an `<h1>` wrapping the `<picture>`.
7285        let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
7286        let images = doc_media(src);
7287        assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
7288        assert_eq!(images[0].destination, "l.svg");
7289        assert_eq!(images[0].sources.len(), 1);
7290        assert_eq!(images[0].sources[0].srcset, "d.svg");
7291    }
7292
7293    #[test]
7294    fn a_plain_image_has_no_media_sources() {
7295        // A bare Markdown image carries an empty `sources` — nothing to pick from.
7296        let images = doc_media("![alt](p.png)\n");
7297        assert_eq!(images.len(), 1);
7298        assert!(
7299            images[0].sources.is_empty(),
7300            "no <picture>, no alternatives"
7301        );
7302    }
7303
7304    #[test]
7305    fn resolve_picks_the_source_matching_the_scheme() {
7306        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
7307        let images = doc_media(src);
7308        let img = &images[0];
7309        // Dark theme takes the dark source; light falls through to the <img>.
7310        assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
7311        assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
7312    }
7313
7314    #[test]
7315    fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
7316        // A plain image ignores the scheme.
7317        let plain = doc_media("![a](p.png)\n");
7318        assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
7319
7320        // A <source> with an unrecognized media query is skipped; a light source
7321        // is taken under a light theme.
7322        let m = doc_media(
7323            "<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
7324        );
7325        assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
7326        assert_eq!(
7327            m[0].resolve(ColorScheme::Dark),
7328            "f.svg",
7329            "no dark source → <img>"
7330        );
7331    }
7332
7333    #[test]
7334    fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
7335        // A comma/descriptor srcset resolves to its first URL.
7336        assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
7337        assert_eq!(first_srcset_url("  solo.svg  "), Some("solo.svg"));
7338        assert_eq!(first_srcset_url(""), None);
7339        // An empty (unconditional) media always matches.
7340        assert!(media_matches("", ColorScheme::Light));
7341        assert!(media_matches(
7342            "(prefers-color-scheme:dark)",
7343            ColorScheme::Dark
7344        ));
7345        assert!(!media_matches(
7346            "(prefers-color-scheme: dark)",
7347            ColorScheme::Light
7348        ));
7349    }
7350
7351    #[test]
7352    fn a_block_media_carries_its_list_prefix() {
7353        // An image that is a list item's body opens past the bullet, like every
7354        // other block does.
7355        let m = map("- ![alt](p.png)\n");
7356        let row = &m.rows[m.media[0].rows_span.start];
7357        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7358        assert!(
7359            text.starts_with("• "),
7360            "the list marker prefixes the image row: {text:?}"
7361        );
7362        assert!(text.contains("🖼 alt"));
7363    }
7364
7365    #[test]
7366    fn the_structural_table_spans_exactly_its_drawn_rows() {
7367        // A frontend drawing its own grid skips `rows_span` and renders from
7368        // `grid`. If the span were short the leftover border rows would be
7369        // painted as text under the real table; if long it would eat a
7370        // neighbouring paragraph. Both are silent, so pin it to the picture.
7371        let m = map(&format!("before\n\n{TABLE}\nafter\n"));
7372        let t = &m.tables[0];
7373        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7374        assert!(
7375            row_text(t.rows_span.start).starts_with('┌'),
7376            "opens on the top border"
7377        );
7378        assert!(
7379            row_text(t.rows_span.end - 1).starts_with('└'),
7380            "closes on the bottom border"
7381        );
7382        assert!(
7383            !row_text(t.rows_span.start - 1).contains('┌'),
7384            "the row before the span is not the table's"
7385        );
7386        assert_eq!(
7387            row_text(t.rows_span.end),
7388            "",
7389            "the span ends before the gap row"
7390        );
7391    }
7392
7393    #[test]
7394    fn a_nested_tables_structure_carries_the_block_prefix() {
7395        // The picture puts the quote's gutter on every row of the grid. A
7396        // frontend drawing its own table has to draw that too and start past it,
7397        // so the prefix has to travel with the structure — without it a quoted
7398        // table renders flush at the margin and leaves the quote it's in.
7399        let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
7400        let t = &m.tables[0];
7401        let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
7402        assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
7403        // And it matches what the picture actually drew.
7404        let drawn: String = m.rows[t.rows_span.start]
7405            .glyphs
7406            .iter()
7407            .map(|g| g.ch)
7408            .collect();
7409        assert!(
7410            drawn.starts_with(&prefix),
7411            "picture and structure disagree: {drawn:?}"
7412        );
7413    }
7414
7415    #[test]
7416    fn a_top_level_table_carries_no_prefix() {
7417        assert!(map(TABLE).tables[0].prefix.is_empty());
7418    }
7419
7420    #[test]
7421    fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
7422        // The picture wraps a cell to its column; a frontend laying the grid out
7423        // in pixels needs the text as the document spells it, before that
7424        // decision. Narrow enough that the drawn cell must break.
7425        let src = "| Name |\n|------|\n| alpha beta gamma |\n";
7426        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7427        let m = build_t(&ed.nodes().unwrap(), src, Some(12));
7428        let drawn = rendered(&m);
7429        let cell: String = m.tables[0].grid[1].cells[0]
7430            .glyphs
7431            .iter()
7432            .map(|g| g.ch)
7433            .collect();
7434        assert_eq!(
7435            cell, "alpha beta gamma",
7436            "structure must not carry the wrap"
7437        );
7438        assert!(
7439            drawn.lines().count() > 5,
7440            "the picture should have wrapped, else this proves nothing:\n{drawn}"
7441        );
7442    }
7443
7444    // ── display columns ──────────────────────────────────────────────────────
7445
7446    #[test]
7447    fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
7448        // A column sized by counting characters is drawn narrower than the text
7449        // it has to hold — `你好` is two characters in four cells — and the cell
7450        // spills over the border it is supposed to sit inside, taking the whole
7451        // grid out of square with it. Squareness is the property: every row of a
7452        // grid is drawn to the same column, whatever its cells are spelled with.
7453        for src in [
7454            "| A | B |\n|---|---|\n| 你好 | y |\n",
7455            "| A | B |\n|---|---|\n| a👨‍👩‍👧b | y |\n",
7456            "| A | 漢字 |\n|---|---|\n| x | y |\n",
7457        ] {
7458            let m = map(src);
7459            let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
7460            assert!(
7461                widths.windows(2).all(|w| w[0] == w[1]),
7462                "ragged grid {widths:?} for {src:?}:\n{}",
7463                rendered(&m)
7464            );
7465        }
7466    }
7467
7468    #[test]
7469    fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
7470        // A column too narrow for its cell hard-breaks the text, and every line
7471        // of it is given an end stop just past its last glyph. Broken into runs
7472        // of four glyphs, the first line of this cell ends between `👨‍👩` and the
7473        // joiner holding `👧` on — so its end stop lands inside a character,
7474        // where a click or Down can reach it and the next Backspace takes the
7475        // cluster apart from the middle.
7476        let src = "| A |\n|---|\n| 👨‍👩‍👧👨‍👩‍👧 |\n";
7477        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7478        let m = build_t(&ed.nodes().unwrap(), src, Some(8));
7479        let boundaries: Vec<usize> = src
7480            .grapheme_indices(true)
7481            .map(|(i, _)| i)
7482            .chain(std::iter::once(src.len()))
7483            .collect();
7484        for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
7485            assert!(
7486                boundaries.contains(&off),
7487                "stop at {off} is inside a character:\n{}",
7488                rendered(&m)
7489            );
7490        }
7491    }
7492
7493    #[test]
7494    fn a_wrapped_cell_keeps_every_line_inside_its_column() {
7495        // The width is a promise in a table, where a glyph past the column lands
7496        // on the border or in the next cell — and it is a promise about cells,
7497        // which is not what a count of glyphs measures.
7498        let src = "| A |\n|---|\n| 你好世界漢字 |\n";
7499        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7500        let m = build_t(&ed.nodes().unwrap(), src, Some(14));
7501        for r in &m.rows {
7502            assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
7503        }
7504    }
7505
7506    #[test]
7507    fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
7508        let glyphs = |s: &str| {
7509            let mut out = Vec::new();
7510            push_text(&mut out, s, 0, Style::default());
7511            out
7512        };
7513        let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
7514
7515        // Six cells of CJK broken at four: two characters, then one — never
7516        // between the two cells of `好`.
7517        let w = glyphs("你好世");
7518        let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
7519        assert_eq!(pieces, ["你好", "世"]);
7520
7521        // A character wider than the column has nowhere legal to break, so it
7522        // keeps its cells rather than being cut in half.
7523        let w = glyphs("你好");
7524        let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
7525        assert_eq!(pieces, ["你", "好"]);
7526
7527        // An empty word yields no pieces at all — a double space stays a space.
7528        assert!(hard_break(&[], 4).is_empty());
7529    }
7530
7531    #[test]
7532    fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
7533        // Pressing Enter at the end of a list item opens a new, empty item —
7534        // a childless `list_item`. Without a row of its own the new bullet
7535        // wouldn't appear until something was typed into it (the caret would be
7536        // stranded on an offset no row draws). It now renders as one prefixed
7537        // row whose end is a caret stop, so the bullet shows and the caret lands
7538        // just past the marker.
7539        let m = map("- item\n- \n");
7540        assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
7541        assert_eq!(
7542            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7543            "• ",
7544            "the empty item draws just its bullet",
7545        );
7546        // Its end is the caret home (past the `- ` marker), and it's a real stop.
7547        assert!(
7548            m.is_stop(m.rows[1].end_src),
7549            "the empty item's caret home is not a stop"
7550        );
7551        assert_eq!(
7552            m.pos_of_offset(m.rows[1].end_src),
7553            (1, 2),
7554            "caret sits after '• '"
7555        );
7556    }
7557
7558    #[test]
7559    fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
7560        // The peek bug: a note whose body ends in a link has its last byte
7561        // inside the hidden destination, so mapping `end - 1` through
7562        // `pos_of_offset` snapped *forward* — past its own row, past the drawn
7563        // gap, and onto the next note's row. The popover then drew both notes.
7564        let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
7565        let m = map(src);
7566        let body = src.find("[title]").unwrap();
7567        let end = src.find("\n\n[^3]").unwrap();
7568
7569        let (first, last) = m.row_range_for(body..end);
7570        assert_eq!(
7571            first, last,
7572            "a one-block note is one row, not a span onto the next"
7573        );
7574
7575        // The old arithmetic, kept here as the thing that must stay wrong: it
7576        // is what this method exists instead of.
7577        assert_ne!(
7578            m.pos_of_offset(end - 1).0,
7579            last,
7580            "the forward snap still leaves the note's row — that is the whole point",
7581        );
7582
7583        // A note ending in *visible* text was never broken, and still isn't:
7584        // both readings agree there, which is why the original test missed it.
7585        let plain = src.find("bare text").unwrap();
7586        let plain_end = src.find("\n\n[^2]").unwrap();
7587        let (pf, pl) = m.row_range_for(plain..plain_end);
7588        assert_eq!(pf, pl);
7589        assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
7590    }
7591
7592    #[test]
7593    fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
7594        // The range is a span, not a point: a quote of two paragraphs covers its
7595        // gap row and both of its text rows, so a peek draws the whole thing.
7596        let src = "> one\n>\n> two\n\nafter\n";
7597        let m = map(src);
7598        let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
7599        assert_eq!((first, last), (0, 2));
7600
7601        // And a range with no visible byte at all still covers the row it opened
7602        // on, rather than collapsing to nothing.
7603        let (f, l) = m.row_range_for(0..1);
7604        assert_eq!((f, l), (0, 0));
7605    }
7606
7607    #[test]
7608    fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
7609        // The peer of the empty list item, and the case that made an empty line
7610        // in a quote draw as plain body text: a childless `block_quote` — a bare
7611        // `> `, which is what the toolbar's Quote button leaves on a blank line —
7612        // has no inner block to carry the gutter, so the whole quote used to
7613        // render as *nothing*. It didn't merely lose its bar; the row went away
7614        // and the caret had no home on it.
7615        let m = map("a\n\n> \n\nb\n");
7616        assert_eq!(
7617            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
7618            "│ ",
7619            "the empty quote draws just its gutter",
7620        );
7621        assert!(
7622            m.rows[2]
7623                .glyphs
7624                .iter()
7625                .all(|g| g.style.role == Role::QuoteGutter)
7626        );
7627        assert!(
7628            !m.rows[2].decoration,
7629            "it is a line text can go on, not a drawn gap"
7630        );
7631        assert!(
7632            m.is_stop(m.rows[2].end_src),
7633            "the empty quote's caret home is not a stop"
7634        );
7635        assert_eq!(
7636            m.pos_of_offset(m.rows[2].end_src),
7637            (2, 2),
7638            "caret sits after '│ '"
7639        );
7640
7641        // And a document that is *only* an empty quote still renders a row — it
7642        // used to render none at all, leaving the caret nowhere to stand.
7643        let m = map("> \n");
7644        assert_eq!(m.num_rows(), 1);
7645        assert_eq!(
7646            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7647            "│ "
7648        );
7649    }
7650
7651    #[test]
7652    fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
7653        // Enter at the end of `> a` writes `> a\n>\n> \n`. Those last two lines
7654        // hold no block — a quote's `content_span` stops at its last child — so
7655        // the children walk never reaches them, and they used to fall through to
7656        // the document-level trailing pass, which knows no prefix: the gutter
7657        // stopped and the writer's new line drew as plain prose. Fixable only
7658        // since twig 3.2.0, where the quote's *span* covers its own marker lines
7659        // (`0..3` before, `0..8` now) and there is finally a node saying they
7660        // are the quote's.
7661        let m = map("> a\n>\n> \n");
7662        assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
7663        for (i, row) in m.rows.iter().enumerate() {
7664            let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
7665            assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
7666            assert!(
7667                !row.decoration,
7668                "row {i} is a line to type on, not a drawn gap"
7669            );
7670            assert!(m.is_stop(row.end_src), "row {i} has no caret home");
7671        }
7672        assert_eq!(
7673            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7674            "│ a"
7675        );
7676        // Distinct offsets, so ↑/↓ between them moves the caret rather than
7677        // landing twice on the same byte.
7678        assert!(m.rows[0].end_src < m.rows[1].end_src);
7679        assert!(m.rows[1].end_src < m.rows[2].end_src);
7680
7681        // A blank line *after* the quote is not the quote's: it is spelled with
7682        // no marker, so it stays an ordinary boundary and the gutter ends.
7683        let m = map("> a\n\nb\n");
7684        assert_eq!(m.num_rows(), 3);
7685        assert_eq!(
7686            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
7687            "b"
7688        );
7689        assert!(
7690            !m.rows[1]
7691                .glyphs
7692                .iter()
7693                .any(|g| g.style.role == Role::QuoteGutter)
7694        );
7695
7696        // Nesting is the case this could get wrong, and the depth has to come
7697        // from which quote's span the line falls in rather than from the row
7698        // above it. A trailing `>` under `> > a` matches only the OUTER quote,
7699        // so it wears one gutter; spell it `> >` and it wears two.
7700        let m = map("> > a\n>\n");
7701        assert_eq!(
7702            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7703            "│ │ a"
7704        );
7705        assert_eq!(
7706            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7707            "│ "
7708        );
7709        let m = map("> > a\n> >\n");
7710        assert_eq!(
7711            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7712            "│ │ "
7713        );
7714
7715        // And a marker line BETWEEN two quoted paragraphs is untouched: that is
7716        // the boundary `emit_separators_before` spells, and it stays a drawn gap
7717        // rather than becoming a line to type on.
7718        let m = map("> a\n>\n> b\n");
7719        assert_eq!(m.num_rows(), 3);
7720        assert!(
7721            m.rows[1].decoration,
7722            "the gap between two quoted blocks is still a gap"
7723        );
7724    }
7725
7726    #[test]
7727    fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
7728        let m = map("1. item\n2. \n");
7729        assert_eq!(m.num_rows(), 2);
7730        assert_eq!(
7731            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7732            "2. "
7733        );
7734        assert!(m.is_stop(m.rows[1].end_src));
7735        assert_eq!(
7736            m.pos_of_offset(m.rows[1].end_src),
7737            (1, 3),
7738            "caret sits after '2. '"
7739        );
7740    }
7741
7742    #[test]
7743    fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
7744        // The toolbar's H1 on a blank line writes `# ` and nothing else. The row
7745        // it renders is empty (the marker is hidden), so its end *is* its only
7746        // caret stop — and it has to be the offset past the `# `, where typing
7747        // continues the heading. Anchored at the block's start instead, the caret
7748        // drew in front of the hashes and the first character typed there landed
7749        // before them (`x# `), which isn't a heading at all.
7750        let m = map("# \n");
7751        assert_eq!(m.num_rows(), 1);
7752        assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
7753        assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
7754        assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
7755    }
7756
7757    #[test]
7758    fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
7759        // The row-level fact a proportional frontend sizes a whole line by. An
7760        // empty heading has no glyph to read a `Role::Heading` off, so a renderer
7761        // scanning glyphs drew `# ` (and its caret) at body height until the
7762        // first character landed.
7763        let m = map("# \n");
7764        assert_eq!(
7765            m.rows[0].heading,
7766            Some(1),
7767            "the empty heading knows its level"
7768        );
7769
7770        // Every row of one that wraps, not just the first — and nothing else.
7771        let m = map_at(
7772            "## a heading long enough to wrap over two rows\n\nbody\n",
7773            Some(20),
7774        );
7775        let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
7776        assert!(
7777            heads.iter().filter(|h| **h == Some(2)).count() >= 2,
7778            "got {heads:?}"
7779        );
7780        assert_eq!(
7781            m.rows.last().and_then(|r| r.heading),
7782            None,
7783            "the paragraph under it is not a heading",
7784        );
7785    }
7786
7787    #[test]
7788    fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
7789        // The row's end is also what the *next* row's separator is measured from,
7790        // so an empty heading that under-reported it shifted every offset below —
7791        // and the blank line under the heading then claimed the same offset as the
7792        // heading's own end. `pos_of_offset` resolves such a tie downstream (a
7793        // soft wrap belongs to the row below), so the caret at the end of the
7794        // heading was drawn two rows lower, on the blank line.
7795        // `text\n\n# \n\n`: the heading's content opens at 8, and the two rows
7796        // under it end at 9 and 10 — the blank line and the document's end.
7797        let m = map("text\n\n# \n\n");
7798        let end = m.rows.last().expect("a trailing blank row").end_src;
7799        assert_eq!(end, 10, "the trailing rows must end at their real offsets");
7800        // The heading's caret home is its own row's, not one shared with a row
7801        // below — the tie that drew the caret two rows down.
7802        assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
7803        assert!(
7804            m.rows[3..].iter().all(|r| r.end_src > 8),
7805            "rows below own later offsets"
7806        );
7807    }
7808
7809    // ── block boundaries ─────────────────────────────────────────────────────
7810
7811    /// Every drawn boundary in `src`, in order, as `(above, below)`.
7812    fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
7813        m.rows
7814            .iter()
7815            .filter_map(|r| r.boundary)
7816            .map(|b| (b.above, b.below))
7817            .collect()
7818    }
7819
7820    #[test]
7821    fn a_boundary_says_which_blocks_it_divides() {
7822        use BlockClass::*;
7823        let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
7824        assert_eq!(
7825            boundaries(&m),
7826            vec![
7827                (Paragraph, Paragraph),
7828                (Paragraph, Heading),
7829                (Heading, Paragraph),
7830                (Paragraph, Quote),
7831                (Quote, Code),
7832                // The blank the document trails off with is a boundary too — it
7833                // closes the last block above the empty paragraph the caret rests
7834                // on. See `emit_trailing_blank_lines`.
7835                (Code, Paragraph),
7836            ],
7837            "each gap names the pair it falls between, in document order"
7838        );
7839    }
7840
7841    // ── hidden blocks ────────────────────────────────────────────────────────
7842
7843    /// The row texts of `m`, one string per row.
7844    fn row_texts(m: &VisualMap) -> Vec<String> {
7845        m.rows
7846            .iter()
7847            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7848            .collect()
7849    }
7850
7851    #[test]
7852    fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
7853        // `<!-- exec -->` is a top-level block that draws no rows. The blocks
7854        // either side of it meet across the one boundary a paragraph and a code
7855        // block always meet across — not that boundary *plus* one blank row per
7856        // line of the comment, which is what counting the separator from the
7857        // paragraph's end used to spell.
7858        let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
7859        assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
7860        assert_eq!(
7861            boundaries(&m),
7862            vec![
7863                (BlockClass::Paragraph, BlockClass::Code),
7864                (BlockClass::Code, BlockClass::Paragraph),
7865            ],
7866            "the boundary names the drawn blocks either side, not the comment"
7867        );
7868        // The gap stands past the comment, so the caret's row lookup never
7869        // resolves inside it.
7870        assert_eq!(
7871            m.rows[1].end_src, 23,
7872            "the gap row ends at the comment's end"
7873        );
7874    }
7875
7876    #[test]
7877    fn a_comment_opening_the_document_draws_no_leading_gap() {
7878        let m = map("<!-- lead -->\n\npara\n");
7879        assert_eq!(row_texts(&m), ["para"]);
7880        assert_eq!(m.content_start, 0, "the comment is still the first block");
7881    }
7882
7883    #[test]
7884    fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
7885        // Its lines are not blank lines the author opened with Enter, so no
7886        // gap-plus-empty-paragraph is fabricated under the last drawn block.
7887        let m = map("para\n\n<!-- trail -->\n");
7888        assert_eq!(row_texts(&m), ["para"]);
7889        // Enter at the end of the document still opens the empty paragraph the
7890        // caret rests on: the newlines *after* the comment count as they would
7891        // after any block.
7892        let m = map("para\n\n<!-- trail -->\n\n");
7893        assert_eq!(row_texts(&m), ["para", "", ""]);
7894    }
7895
7896    #[test]
7897    fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
7898        // The first *drawn* child wears the item's marker; a hidden first child
7899        // would otherwise take it and leave the text without one.
7900        let m = map("- <!-- note -->\n\n  text\n- two\n");
7901        let texts = row_texts(&m);
7902        assert!(
7903            texts.iter().any(|t| t == "• text"),
7904            "the text wears the bullet: {texts:?}"
7905        );
7906        assert!(
7907            !texts.iter().any(|t| t == "• "),
7908            "no empty bullet row for the comment: {texts:?}"
7909        );
7910    }
7911
7912    #[test]
7913    fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
7914        // The bug as seen: a 200-line document with one comment in it rendered
7915        // ~200 blank rows after the comment, one per source line, because the
7916        // comment's per-block builder handed back a `last_off` of 0. Parity with
7917        // `build` alone would not catch a *shared* wrong answer, so the count is
7918        // pinned outright.
7919        let body = (0..200)
7920            .map(|i| format!("line {i}"))
7921            .collect::<Vec<_>>()
7922            .join("\n\n");
7923        let src = format!("intro\n\n<!-- exec -->\n{body}\n");
7924        let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
7925        let mut cache = BlockCache::default();
7926        let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
7927        assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
7928        // intro, then 200 × (gap, paragraph): 401 rows and not a row more.
7929        assert_eq!(cached.rows.len(), 401);
7930    }
7931
7932    #[test]
7933    fn a_link_reference_definition_is_stepped_over_like_a_comment() {
7934        // `[a]: /a` is a root beside `doc` with no rows of its own. Merged into
7935        // the walk it is a hidden block: the blocks either side meet across one
7936        // boundary, and its line is not a blank row.
7937        let m = map("see [a]\n\n[a]: /a\n\nafter\n");
7938        assert_eq!(row_texts(&m), ["see a", "", "after"]);
7939        assert_eq!(
7940            boundaries(&m),
7941            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
7942        );
7943    }
7944
7945    #[test]
7946    fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
7947        // The README shape: prose, then a `[links]` block nobody reads. Its
7948        // lines used to be counted as blank ones, an empty paragraph per
7949        // definition under the last real block.
7950        let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
7951        assert_eq!(row_texts(&m), ["see a and b"]);
7952    }
7953
7954    #[test]
7955    fn a_definition_glued_under_a_paragraph_stays_inside_it() {
7956        // `[a]: /a` at the front of a paragraph's lines is stripped from the
7957        // paragraph's text, but the paragraph's span still starts on its line.
7958        // Both blocks start at the same offset; the definition, sorted first,
7959        // is stepped over, and the paragraph draws as it always did — one gap
7960        // above it, none inside.
7961        let m = map("intro\n\n[a]: /a\ntext [a]\n");
7962        assert_eq!(row_texts(&m), ["intro", "", "text a"]);
7963    }
7964
7965    #[test]
7966    fn a_definition_with_no_span_is_left_out_of_the_walk() {
7967        // twig before 3.3.3 reported `0..0` for every link reference
7968        // definition. One of those has nowhere to be merged: sorted first by
7969        // its zero start it would open the document with a phantom block, and
7970        // the walk would step back to offset 0. It is simply not a block. A
7971        // footnote definition is always placed; it has a body to draw.
7972        assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
7973        assert!(is_placed_definition(&Kind::Reference, &(7..14)));
7974        assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
7975        assert!(!is_placed_definition(&Kind::Str, &(7..14)));
7976    }
7977
7978    #[test]
7979    fn the_trailing_gap_closes_the_last_block() {
7980        // Two Enters at the end of a document: a drawn gap, then the navigable
7981        // empty paragraph. Only the gap is labelled, so a frontend that shrinks
7982        // boundaries shrinks the spacer and leaves the row being typed on alone.
7983        let m = map("# Head\n\n\n");
7984        assert_eq!(
7985            boundaries(&m),
7986            vec![(BlockClass::Heading, BlockClass::Paragraph)]
7987        );
7988    }
7989
7990    #[test]
7991    fn only_the_drawn_gap_rows_carry_a_boundary() {
7992        let m = map("one\n\ntwo\n");
7993        for row in &m.rows {
7994            assert_eq!(
7995                row.boundary.is_some(),
7996                row.decoration,
7997                "a boundary is exactly a drawn gap row: {:?}",
7998                row.glyphs.iter().map(|g| g.ch).collect::<String>()
7999            );
8000        }
8001    }
8002
8003    #[test]
8004    fn preserve_flow_labels_no_boundary() {
8005        // Every blank line is a caret home there — somewhere text can go, not a
8006        // gap between blocks — so nothing is drawn-only and nothing is labelled.
8007        // A frontend keying its spacing off `boundary` can't shrink a row the
8008        // author is about to type on.
8009        let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
8010        assert!(boundaries(&m).is_empty());
8011    }
8012
8013    #[test]
8014    fn a_list_draws_no_boundary_between_its_items() {
8015        // Tight or loose, core puts no gap row between two items of one list —
8016        // so an item↔item boundary is a shape no frontend will ever be handed,
8017        // and spacing one is spacing something that isn't there.
8018        for src in ["- one\n- two\n", "- one\n\n- two\n"] {
8019            let m = map(src);
8020            assert!(
8021                boundaries(&m).is_empty(),
8022                "no gap row inside the list of {src:?}"
8023            );
8024        }
8025        // Leaving the list is an ordinary boundary, and the list is named as
8026        // what sits above it.
8027        let m = map("- one\n- two\n\npara\n");
8028        assert_eq!(
8029            boundaries(&m),
8030            vec![(BlockClass::List, BlockClass::Paragraph)]
8031        );
8032    }
8033
8034    #[test]
8035    fn a_nested_boundary_names_the_blocks_inside_the_container() {
8036        // Two paragraphs inside a blockquote are divided by a Paragraph↔Paragraph
8037        // boundary — the quote is the container they're both in, not what the gap
8038        // separates.
8039        let m = map("> one\n>\n> two\n");
8040        assert_eq!(
8041            boundaries(&m),
8042            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
8043        );
8044    }
8045
8046    #[test]
8047    fn a_directive_container_draws_one_boundary_like_every_other_block() {
8048        // A container's rows stop at its last *child*, so without anchoring
8049        // `last_off` past the closing `:::` the separator logic counted the fence
8050        // line as a blank row of its own and drew the gap twice — one authored
8051        // blank line, two boundaries, and a frontend spacing each of them put
8052        // double margin under every fenced div. The code-block arm anchors past
8053        // its ``` for exactly this reason; compare the two here.
8054        let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
8055        assert_eq!(
8056            boundaries(&fenced),
8057            vec![(BlockClass::Directive, BlockClass::Paragraph)],
8058            "one authored gap, one boundary row"
8059        );
8060        let code = map("```\nc\n```\n\ntwo\n");
8061        assert_eq!(
8062            boundaries(&code).len(),
8063            boundaries(&fenced).len(),
8064            "a fenced div spaces like a fenced code block"
8065        );
8066        // Nesting closes several fences at once; still one gap.
8067        let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
8068        assert_eq!(
8069            boundaries(&nested),
8070            vec![(BlockClass::Directive, BlockClass::Paragraph)]
8071        );
8072    }
8073
8074    #[test]
8075    fn a_block_media_names_itself_in_the_boundaries_either_side() {
8076        use BlockClass::*;
8077        // A block image is never a node of its own — `media_only` promotes the
8078        // *paragraph* wrapping it — so classifying the node the walk stands on
8079        // called the picture `Paragraph` and left `BlockClass::Media` unreachable:
8080        // a frontend could not give a photo more air than a line of prose.
8081        // `label_media_boundaries` reads it back off the finished rows instead.
8082        let m = map("one\n\n![alt](p.png)\n\ntwo\n");
8083        assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
8084        // At the edges of the document too: the leading gap has no boundary of
8085        // its own, and the trailing one is `emit_trailing_blank_lines`'.
8086        let edges = map("![a](p.png)\n\nmid\n\n![b](q.png)\n");
8087        assert_eq!(
8088            boundaries(&edges),
8089            vec![(Media, Paragraph), (Paragraph, Media)]
8090        );
8091        // One gap spelled with several rows — the row closing the block above and
8092        // the row opening the one below, with the author's spare blank line
8093        // navigable between them — carries the same pair on every drawn row.
8094        let roomy = map("one\n\n\n\n![alt](p.png)\n");
8095        assert_eq!(
8096            boundaries(&roomy),
8097            vec![(Paragraph, Media), (Paragraph, Media)]
8098        );
8099    }
8100
8101    #[test]
8102    fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
8103        // Worse than the image case before `label_media_boundaries`: a `<video>`
8104        // arrives as twig's generic `container`, which classifies `Directive` —
8105        // the one class a frontend reads as "draw a tinted panel here". A movie
8106        // got the chrome of a fenced div.
8107        let mut doc = crate::Doc::from_source(
8108            "one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
8109            Format::Markdown,
8110        )
8111        .unwrap();
8112        doc.build_visual(80);
8113        assert_eq!(
8114            boundaries(&doc.vmap),
8115            vec![
8116                (BlockClass::Paragraph, BlockClass::Media),
8117                (BlockClass::Media, BlockClass::Paragraph),
8118            ]
8119        );
8120    }
8121
8122    #[test]
8123    fn the_incremental_walk_labels_boundaries_like_the_full_one() {
8124        // `assert_maps_eq` compares boundaries too, so this pins the two doors
8125        // into `BlockClass::from_node_kind` — a `FlatNode`'s kind on the full
8126        // build, a query match's on the cached one — against a document with one
8127        // of every boundary in it.
8128        let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
8129        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
8130        let mut cache = BlockCache::default();
8131        let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
8132        assert_maps_eq(&full, &cached, "boundary labelling");
8133        assert!(
8134            !boundaries(&full).is_empty(),
8135            "the fixture has boundaries to compare"
8136        );
8137    }
8138
8139    #[test]
8140    fn every_caret_stop_opens_a_cluster_of_its_row() {
8141        // The two ways of finding a cluster have to agree. `push_text` marks the
8142        // stops by segmenting one run of text; the column mapping segments the
8143        // whole row, decoration and all. A stop that came out as the *middle* of
8144        // some row-level cluster would be a caret with no column of its own —
8145        // drawn at the column of whatever swallowed it.
8146        let src = "# 標題\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` 你好\n\n\
8147                   - 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
8148                   | A | 值 |\n|---|---|\n| 你好 | 👩‍🚀 |\n";
8149        let m = map(src);
8150        for (r, row) in m.rows.iter().enumerate() {
8151            let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
8152            for (i, g) in row.glyphs.iter().enumerate() {
8153                assert!(
8154                    !g.stop || openers.contains(&i),
8155                    "row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
8156                     so it is drawn at another glyph's column",
8157                    g.ch
8158                );
8159            }
8160        }
8161    }
8162}