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;
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}
170
171/// What a drawn block boundary separates: the kinds of the blocks it falls
172/// between — the pair a frontend spaces by.
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub struct Boundary {
175    pub above: BlockClass,
176    pub below: BlockClass,
177}
178
179/// The block kinds core tells apart when it walks a document — the vocabulary
180/// [`Boundary`] is spelled in. A statement about *structure*, not about how any
181/// of it should look: what a frontend does with "this gap sits above a heading"
182/// is entirely the frontend's.
183///
184/// `Class` rather than `Kind` because [`twig::BlockKind`] already means
185/// something else in this crate's public surface — the *command* vocabulary
186/// (`Paragraph | Heading(n)`) a toolbar passes to [`Doc::set_block`](crate::Doc::set_block).
187/// This is the reverse direction: what a block already *is*, read back off a
188/// rendered row.
189///
190/// [`BlockClass::Other`] is the honest answer for a node kind core doesn't
191/// separate out, so adding one here is additive for every frontend: nothing has
192/// to change until it wants to space that kind differently.
193#[derive(Clone, Copy, Debug, PartialEq, Eq)]
194pub enum BlockClass {
195    Paragraph,
196    Heading,
197    /// A whole list. Its *items* are [`BlockClass::ListItem`]; note that core
198    /// draws no boundary row between two items of one list, tight or loose, so
199    /// an item↔item pair never reaches a frontend.
200    List,
201    ListItem,
202    Quote,
203    Code,
204    Table,
205    /// A block-level image, video, or audio.
206    ///
207    /// Never reached through [`from_node_kind`](BlockClass::from_node_kind): a
208    /// block picture is not a node of its own — [`Builder::media_only`] promotes
209    /// the paragraph (or `<picture>`/`<video>` container) wrapping it — so the
210    /// walk only ever sees the wrapper's kind. [`label_media_boundaries`] reads
211    /// it back off the finished rows instead, after the fact.
212    Media,
213    /// A `:::name{.class}` directive container.
214    Directive,
215    Rule,
216    Footnote,
217    Other,
218}
219
220impl BlockClass {
221    /// Classify a twig node kind — the same vocabulary [`Builder::block`]
222    /// matches on, so the two can't drift about what a block is. Both the
223    /// whole-arena walk (which has [`FlatNode`]s) and the incremental top-level
224    /// walk (which has only a query match's kind) reach it by this one door.
225    pub fn from_node_kind(kind: &Kind) -> BlockClass {
226        match kind {
227            Kind::Para => BlockClass::Paragraph,
228            Kind::Heading => BlockClass::Heading,
229            Kind::BulletList | Kind::OrderedList | Kind::TaskList => BlockClass::List,
230            Kind::ListItem | Kind::TaskListItem => BlockClass::ListItem,
231            Kind::BlockQuote => BlockClass::Quote,
232            Kind::CodeBlock => BlockClass::Code,
233            Kind::Table => BlockClass::Table,
234            Kind::Image => BlockClass::Media,
235            // twig 2.8 folded `div`/`span`/`directive`/`element` into one
236            // `container` kind, so a `:::note` panel and a promoted `<video>`
237            // arrive here indistinguishable — telling them apart needs the
238            // node's `origin`, and the incremental walk has only this kind.
239            // `Directive` is the right answer for the case that motivates the
240            // class (nothing else draws a tinted panel) and a harmless one for
241            // the rest: `BlockClass` is descriptive and core never branches on
242            // it. The one case where it was actively wrong — a promoted
243            // `<video>`, which would have been handed to a frontend as something
244            // to draw a fenced-div panel around — is corrected by
245            // [`label_media_boundaries`] once the rows are final, along the same
246            // door as a block image. Anything else that must be exact reads
247            // [`container_is_directive`] off a real node.
248            Kind::Container => BlockClass::Directive,
249            Kind::ThematicBreak => BlockClass::Rule,
250            Kind::Footnote => BlockClass::Footnote,
251            _ => BlockClass::Other,
252        }
253    }
254}
255
256/// The name and attributes a leaf directive's placeholder row carries, so a
257/// frontend that knows the host app's vocabulary can paint the real thing —
258/// an embedded page for diaryx's `::embed{src=…}`, a generated table of
259/// contents for a `::toc`, and the plain `⧉ name` label for one it doesn't
260/// know. The peer of [`MediaMark`], and plain strings for the same reason: they
261/// survive the row shuffling of [`BlockCache`] reuse and [`build_spliced`].
262#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct DirectiveMark {
264    /// The directive's type — `embed`, `toc`, `vis` — with no leading colons.
265    /// Core is agnostic of what it means: the vocabulary is the host app's.
266    pub name: String,
267    /// Its `{…}` attributes as `(key, value)` pairs in source order. A bare
268    /// attribute (`{public}`) has a `None` value, the way twig reports it.
269    pub attrs: Vec<(String, Option<String>)>,
270    /// The directive's `[label]` text, flattened from its inline children, or
271    /// empty when it has none. Also what the placeholder label shows.
272    pub label: String,
273    /// How many visual rows this directive reserves — the label row plus blank
274    /// filler rows below it, so a frontend painting something real has the
275    /// vertical room. `1` is the bare placeholder, and the only value core
276    /// produces today: unlike an image (whose height a terminal frontend
277    /// measures and reports back), nothing has told core how tall an embed is.
278    /// A pixel-laid-out GUI sets its own height regardless.
279    pub rows: usize,
280}
281
282/// What a block-level media placeholder actually is, so a frontend knows which
283/// widget to build over the reserved rows: a raster, a movie player, or a
284/// transport with no picture at all. Core classifies and stops there — it opens
285/// nothing, so this is a statement about the *markup*, not about a file it has
286/// verified exists or can decode.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum MediaKind {
289    /// A `![](…)` / `<img>` / `<picture>` — a still picture.
290    Image,
291    /// An HTML `<video>`. Markdown and Djot spell no video of their own, so this
292    /// only ever arrives through `html_elements` promotion (or a `::video{…}`
293    /// directive a host app maps itself, which core reports as a directive).
294    Video,
295    /// An HTML `<audio>` — a transport with no picture, so a frontend gives it a
296    /// fixed control height rather than measuring an aspect ratio.
297    Audio,
298}
299
300/// Which of the two caret homes a block media has — see
301/// [`VisualMap::block_media_stop`].
302#[derive(Clone, Copy, Debug, PartialEq, Eq)]
303pub enum MediaStop {
304    /// The stop in front of the picture. What is typed here belongs above it.
305    Before,
306    /// The stop just past it. What is typed here belongs below it.
307    After,
308}
309
310impl MediaKind {
311    /// The emoji a plain surface prefixes the placeholder label with — the
312    /// `🖼`/`🎬`/`🔊` that makes the row read as *a thing* rather than as text.
313    fn sigil(self) -> char {
314        match self {
315            MediaKind::Image => '🖼',
316            MediaKind::Video => '🎬',
317            MediaKind::Audio => '🔊',
318        }
319    }
320}
321
322/// The destination and label a block-level media placeholder row carries, so a
323/// capable frontend can resolve and paint the real thing. Plain strings (no
324/// source offsets), so they survive the row shuffling of [`BlockCache`] reuse
325/// and [`build_spliced`] untouched — see [`VRow::media`].
326#[derive(Clone, Debug, PartialEq, Eq)]
327pub struct MediaMark {
328    /// Whether this is a picture, a movie, or a sound — which widget the
329    /// frontend builds over the reserved rows.
330    pub kind: MediaKind,
331    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
332    /// the AST. A frontend resolves a relative path against the document's
333    /// directory itself; core holds no I/O.
334    ///
335    /// Empty is possible and legal for a `<video>`/`<audio>`, which may carry no
336    /// `src` of its own and name its candidates in child `<source>`s instead —
337    /// unlike an `<img>`, whose `src` *is* the picture. A frontend with an empty
338    /// destination takes its URL from [`sources`](MediaMark::sources).
339    pub destination: String,
340    /// A `<picture>`'s theme/media alternatives, in document order, when this
341    /// block image came from one; empty for a plain `![](…)` / bare `<img>`. Each
342    /// is a `<source>`'s media query + candidate URL(s); a frontend that knows its
343    /// theme picks the first whose media matches and falls back to [`destination`]
344    /// (the `<img>`). Core keeps them verbatim and picks nothing — it has no theme.
345    ///
346    /// [`destination`]: MediaMark::destination
347    pub sources: Vec<MediaSource>,
348    /// The media's alt text (its rendered inline children, flattened), or empty
349    /// when it has none. Also what the placeholder label shows. For a `<video>`/
350    /// `<audio>` this is the element's own text content — the "your browser does
351    /// not support…" fallback, which doubles as its accessible name.
352    pub alt: String,
353    /// A `<video poster="…">`'s still frame, verbatim, or empty when there is
354    /// none (and always empty for an image or audio). It is an *image*
355    /// destination, so a frontend already able to draw a picture can show it
356    /// before the movie loads — or in place of one it can't play at all.
357    pub poster: String,
358    /// How many visual rows this media reserves — the placeholder label row plus
359    /// the blank filler rows below it, so a frontend that paints a real raster has
360    /// the vertical room to draw it. `1` is the bare placeholder (a frontend that
361    /// can't draw pictures, or an image it couldn't resolve). A terminal frontend
362    /// asks for as many rows as the fitted picture is tall; the pixel-laid-out GUI
363    /// ignores this and sets its own row height, so it always leaves it `1`. The
364    /// count comes from the frontend (via [`crate::Doc::set_media_rows`]) because
365    /// core does no I/O and can't measure the image itself. See [`VRow::media`].
366    pub rows: usize,
367}
368
369/// One `<source>` under a `<picture>`, `<video>`, or `<audio>`: a candidate URL
370/// plus whichever of the two things HTML lets a `<source>` be chosen by — a
371/// media query (`<picture>`) or a MIME type (`<video>`/`<audio>`). Verbatim from
372/// the AST: core carries the alternatives and resolves none of them, having
373/// neither a theme nor a codec list to judge them by.
374///
375/// The two spellings are normalised onto one field. `<picture>` writes
376/// `srcset`, `<video>`/`<audio>` write `src`; both land in
377/// [`srcset`](MediaSource::srcset), since a frontend wants the URL either way
378/// and only `<picture>` ever uses the descriptor syntax.
379#[derive(Clone, Debug, PartialEq, Eq)]
380pub struct MediaSource {
381    /// The `<source media="…">` query, verbatim (`"(prefers-color-scheme: dark)"`),
382    /// or empty for a `<source>` with no `media` (an unconditional override, and
383    /// the norm for `<video>`/`<audio>`, which pick by codec rather than theme).
384    pub media: String,
385    /// The candidate URL(s): a `<picture>`'s `srcset` verbatim — one URL, or a
386    /// comma-separated candidate list with `1x`/`2x`/width descriptors — or a
387    /// `<video>`/`<audio>` `<source>`'s plain `src`. A frontend takes the first
388    /// URL token; the theme and codec cases both only ever need that.
389    pub srcset: String,
390    /// The `<source type="…">` MIME type (`"video/webm"`), verbatim, or empty
391    /// when the `<source>` declares none. How a `<video>`/`<audio>` frontend
392    /// picks a candidate it can actually decode; a `<picture>`'s sources
393    /// normally leave it empty and are chosen by [`media`](MediaSource::media).
394    pub mime: String,
395}
396
397/// The rendered document plus the offset⇄position mapping the caret rides on.
398#[derive(Clone, Default)]
399pub struct VisualMap {
400    /// The document's **default monospace rendering** — one [`VRow`] of glyphs
401    /// per visual line, tables spelled with box-drawing borders (`│ ─ ┌┬┐…`) and
402    /// cells padded to whole character-cell columns. Any monospace surface can
403    /// draw these verbatim, so a consumer gets a working view for free: the TUI
404    /// paints them as-is, and a five-line plain-text dump would too.
405    ///
406    /// It's a *default*, not the only truth. A frontend with its own geometry —
407    /// a proportional GUI — lays text out in its own units, and for a table
408    /// skips the box-drawn rows named by [`TableInfo::rows_span`] and draws from
409    /// the structural [`TableInfo`] instead. The box glyphs live here rather than
410    /// in a frontend precisely because they *are* a renderable default: unlike a
411    /// colour (a role each surface must map to its own palette — see
412    /// [`crate::style`]), `┌─┐` is finished text that needs no interpretation.
413    pub rows: Vec<VRow>,
414    /// The first source offset that is actually rendered — the caret floor for
415    /// the WYSIWYG view. Non-zero when a leading `metadata` block (YAML/TOML
416    /// frontmatter) is skipped: the frontmatter is preserved in the source and
417    /// editable in the source view, but hidden and unreachable here, so the
418    /// caret and selection can't wander into it (and copy won't grab it).
419    pub content_start: usize,
420    /// Every offset the caret may rest at, ascending and deduplicated: each
421    /// row's stop glyphs plus the row's own end (the "after the last character"
422    /// spot every line needs). Decoration contributes nothing.
423    ///
424    /// Left/Right read this instead of walking the grid, because the grid isn't
425    /// laid out in offset order: a table with wrapped cells puts column 1's
426    /// second line *below* column 2's first, so "the next stop rightward" and
427    /// "the next stop in the document" part ways. Following the document is what
428    /// a caret means — and on every row that *is* in order the two agree anyway,
429    /// so nothing else has to change.
430    stops: Vec<usize>,
431    /// Every table in the document, in order, described structurally rather than
432    /// drawn — see [`TableInfo`] for why both exist.
433    pub tables: Vec<TableInfo>,
434    /// Every fenced/indented code block, in order, as the range of [`rows`] it
435    /// occupies — a frontend draws one bordered, tinted box around each and
436    /// scrolls it horizontally rather than wrapping. Derived from the per-row
437    /// [`VRow::code`] flag once the rows are final (so it survives incremental
438    /// row reuse), the same way [`collect_stops`] derives the stop table.
439    ///
440    /// [`rows`]: VisualMap::rows
441    pub code_blocks: Vec<CodeBlockInfo>,
442    /// Every block-level image in the document, in order — one per placeholder
443    /// row a frontend replaces with a real picture. Derived from the per-row
444    /// [`VRow::media`] mark once the rows are final (so it survives incremental
445    /// row reuse), the same way [`code_blocks`](VisualMap::code_blocks) is
446    /// derived from [`VRow::code`].
447    pub media: Vec<MediaInfo>,
448    /// Every **leaf** directive in the document, in order — one per placeholder
449    /// row a frontend may replace with whatever the host app's vocabulary makes
450    /// of it. Derived from the per-row [`VRow::leaf_directive`] mark once the
451    /// rows are final, exactly as [`media`](VisualMap::media) is.
452    pub directives: Vec<DirectiveInfo>,
453}
454
455impl VisualMap {
456    pub fn num_rows(&self) -> usize {
457        self.rows.len()
458    }
459
460    /// The width of `row` in display columns — the rightmost column its caret
461    /// can occupy, and so what a goal column is clamped to on the way in.
462    pub fn row_width(&self, row: usize) -> usize {
463        self.rows.get(row).map_or(0, |r| r.width())
464    }
465
466    /// The screen `(row, col)` for a source offset — where to draw the caret:
467    /// the *nearest* stop at or past `off`. Snaps a hidden offset (inside a
468    /// delimiter) to the next visible glyph, and never resolves onto decoration
469    /// (a table border, a cell's padding), which is drawn but holds no caret.
470    ///
471    /// "Nearest" rather than "the first one found" because a table's wrapped
472    /// cells put rows slightly out of offset order: scanning top to bottom, the
473    /// second line of column 1 comes *after* the first line of column 2 but
474    /// holds smaller offsets. Where rows are in order the two rules agree.
475    ///
476    /// A soft wrap is the one place two rows want the same offset: the row above
477    /// ends where the row below opens, the space the wrap ate being drawn on the
478    /// row above and the offset past it being the row below's first character.
479    /// It resolves *downstream*, to the row that character is on — the row
480    /// above's last column is a phantom, a place the caret can be drawn but
481    /// never sent, and resolving upstream into it is what pinned Down at the
482    /// first wrap of a paragraph: it aimed at the row below's column 0, landed
483    /// on the offset it already had, and read that back as the row above's end.
484    pub fn pos_of_offset(&self, off: usize) -> (usize, usize) {
485        let mut best: Option<(usize, usize, usize)> = None; // (src, row, col)
486        for (r, row) in self.rows.iter().enumerate() {
487            if row.decoration {
488                continue;
489            }
490            // Offsets ascend *within* a row, so its first stop at or past `off`
491            // is the best this row has to offer.
492            let cand = row
493                .glyphs
494                .iter()
495                .enumerate()
496                .find(|(_, g)| g.stop && g.src >= off)
497                .map(|(i, g)| (g.src, r, row.col_of_glyph(i)))
498                .or_else(|| (row.end_src >= off).then_some((row.end_src, r, row.width())));
499            if let Some(c) = cand {
500                // `<=`, so a tie goes to the later row: the only offset two rows
501                // both hold is a wrap boundary, and it belongs to the row below.
502                if best.is_none_or(|b| c.0 <= b.0) {
503                    best = Some(c);
504                }
505            }
506            // A row's *first* stop never decreases from one row to the next —
507            // true even across a table's wrapped cells, since a cell's lines run
508            // downward. So once a row opens past the best found so far, no later
509            // row can beat it and the scan stays proportional to `off`.
510            if let (Some(b), Some(first)) = (best, row.glyphs.iter().find(|g| g.stop))
511                && first.src > b.0
512            {
513                break;
514            }
515        }
516        match best {
517            Some((_, r, c)) => (r, c),
518            None => {
519                let r = self.last_stop_row();
520                (r, self.row_width(r))
521            }
522        }
523    }
524
525    /// The rows a source range occupies, inclusive: `(first, last)`.
526    ///
527    /// A *different question* from [`pos_of_offset`](Self::pos_of_offset), which
528    /// is why it can't be spelled with two calls to it. That one answers "where
529    /// does the caret go", and for a caret its forward snap is right — an offset
530    /// inside a hidden delimiter has no column of its own, so the caret belongs
531    /// at the next visible glyph, wherever that turns out to be. This one asks
532    /// "which rows does this block cover", and there the snap is a trap: a
533    /// footnote whose body *ends* in a link (`[^2]: [title](url)`) has a last
534    /// byte inside the hidden destination, so `pos_of_offset(end - 1)` walked
535    /// clean off the note's row and landed on the next note's — and a peek
536    /// slicing `first..=last` out of the frame drew two notes where the reader
537    /// asked for one. Every block ending in a link, an image, or any trailing
538    /// hidden markup had the same fault; only a block ending in visible text
539    /// (which is what the tests happened to use) did not.
540    ///
541    /// `row.end_src` is no help either: it is where the *rendered* text of a row
542    /// ends, not how far into the source the block reaches, and redefining it
543    /// would move every end-of-line caret.
544    ///
545    /// So the last row is found by asking which rows *open* before the range
546    /// does, rather than by mapping its last byte: a row belongs to the range
547    /// when its first caret stop lies before `range.end`. Decoration is skipped
548    /// (a drawn gap between blocks is not part of either), and the answer is
549    /// never shorter than one row — a range whose every byte is hidden still
550    /// covers the row it started on.
551    pub fn row_range_for(&self, range: Range<usize>) -> (usize, usize) {
552        if self.rows.is_empty() {
553            return (0, 0);
554        }
555        let first = self.pos_of_offset(range.start).0;
556        let mut last = first;
557        for (r, row) in self.rows.iter().enumerate().skip(first) {
558            if row.decoration {
559                continue;
560            }
561            let open = row
562                .glyphs
563                .iter()
564                .find(|g| g.stop)
565                .map_or(row.end_src, |g| g.src);
566            if open >= range.end {
567                // A row's first stop never decreases from one row to the next —
568                // the invariant `pos_of_offset` breaks on, true even across a
569                // table's wrapped cells — so nothing below can be in range.
570                break;
571            }
572            last = r;
573        }
574        (first, last)
575    }
576
577    /// The source offset of the task checkbox drawn at `(row, col)`, or `None`
578    /// when that cell holds no box — the hit-test a frontend runs on a click
579    /// before treating it as a tick rather than a caret placement.
580    ///
581    /// Only the box's own cells answer. Clicking an item's *text* places the
582    /// caret like any other click, so the box is a target aimed at rather than
583    /// something tripped over while editing — which is also why this is a
584    /// separate question from [`offset_of_pos`](Self::offset_of_pos) instead of
585    /// a flag on the offset it returns.
586    pub fn task_box_at(&self, row: usize, col: usize) -> Option<usize> {
587        let r = self.rows.get(row)?;
588        self.task_box_at_glyph(row, r.glyph_at_col(col)?)
589    }
590
591    /// [`task_box_at`](Self::task_box_at) keyed by glyph index rather than
592    /// display column — for a frontend that shapes its own rows (the GUI) and so
593    /// resolves a click to a glyph before it ever has a column.
594    pub fn task_box_at_glyph(&self, row: usize, glyph: usize) -> Option<usize> {
595        let r = self.rows.get(row)?;
596        r.task?;
597        let g = r.glyphs.get(glyph)?;
598        (g.style.role == Role::ListMarker).then_some(g.src)
599    }
600
601    /// The source offset for a screen `(row, col)` — where a click or a
602    /// visual-space move lands the caret. Clicking decoration maps through its
603    /// `src`, which points at the text it decorates, so a click on a border or
604    /// on a cell's padding lands in that cell.
605    ///
606    /// The inverse of [`pos_of_offset`](Self::pos_of_offset), which it has to
607    /// agree with: `col` is a display column, and the one it names may be the
608    /// far cell of a wide glyph — [`VRow::glyph_at_col`] is where that lands.
609    pub fn offset_of_pos(&self, row: usize, col: usize) -> usize {
610        let Some(r) = self.rows.get(row) else {
611            // A click or drag below the last row — a short document with empty
612            // space under it, dragged into to extend a selection. Land on the
613            // document's last caret stop (its end), not offset 0: jumping the
614            // caret to the top is the wrong direction, and 0 isn't even a stop
615            // when the document opens on hidden frontmatter or a `# ` marker, so
616            // returning it would leave the caret where it draws in one place and
617            // types in another (`move_to` would then clamp it onto the unhomeable
618            // frontmatter floor). `None` only for a document with no stops at all
619            // (empty), where the caret has nowhere to be but 0.
620            return self.stops.last().copied().unwrap_or(0);
621        };
622        match r.glyph_at_col(col).and_then(|i| r.glyphs.get(i)) {
623            // A glyph that holds no caret is clickable, but where it points
624            // isn't always somewhere the caret can be: the blank gap between two
625            // paragraphs stands at an offset that belongs to neither of them,
626            // and the tail of a grapheme cluster stands inside a character.
627            // Land on the nearest real stop instead of handing back an offset
628            // that looks like the gap but types into the paragraph above.
629            Some(g) if !g.stop => self.nearest_stop(g.src),
630            Some(g) => g.src,
631            // A row's end is a stop by construction — unless the row is
632            // decoration, which contributes none.
633            None if r.decoration => self.nearest_stop(r.end_src),
634            None => r.end_src,
635        }
636    }
637
638    /// Which of a block media's two caret homes `off` is, or `None` for every
639    /// other offset in the document.
640    ///
641    /// [`block_media`](Builder::block_media) gives a block-level image, video, or
642    /// audio exactly two stops — one in front of it and one just past it — and
643    /// nothing inside the markup. Both are ordinary offsets to everything else in
644    /// core, but they are the two places where inserting text would *dissolve the
645    /// picture*: `![](p.png)` with anything typed against it is no longer a block
646    /// image but a paragraph with an inline one, and the frontend that was
647    /// painting a photo there paints a text run instead. A caller that is about to
648    /// insert asks this so it can open a paragraph first — see
649    /// [`Doc::insert`](crate::Doc::insert).
650    ///
651    /// An *inline* image reports `None`: it has no placeholder row and no stops of
652    /// its own, and typing beside one is ordinary editing.
653    ///
654    /// Answers with the media's own source span as well, since a caller that has
655    /// to keep the picture whole usually has to address it — [`Doc::backspace`]
656    /// takes the picture out in one piece rather than nibbling a byte off its
657    /// markup, which is the same dissolution from the other side.
658    ///
659    /// [`Doc::backspace`]: crate::Doc::backspace
660    pub fn block_media_stop(&self, off: usize) -> Option<(MediaStop, Range<usize>)> {
661        for m in &self.media {
662            let Some(row) = self.rows.get(m.rows_span.start) else {
663                continue;
664            };
665            // Every glyph of the `🖼 alt` label maps to the media's start offset;
666            // the row's end is past its markup. Read the start off the label
667            // rather than the first glyph, which on a quoted or listed picture is
668            // the block prefix and points at the gutter.
669            let Some(start) = row
670                .glyphs
671                .iter()
672                .find(|g| g.style.role == Role::Image)
673                .map(|g| g.src)
674            else {
675                continue;
676            };
677            if off == start {
678                return Some((MediaStop::Before, start..row.end_src));
679            }
680            if off == row.end_src {
681                return Some((MediaStop::After, start..row.end_src));
682            }
683        }
684        None
685    }
686
687    /// Snap `off` to the nearest caret stop — the funnel a frontend that
688    /// hit-tests pixels straight to a source offset must run its result through.
689    /// A click or drag can land in the blank gap a paragraph break is drawn with,
690    /// or inside a hidden delimiter; both are offsets the caret can't rest at, so
691    /// resting there would draw the caret in one place and type in another. This
692    /// settles it on a real caret home instead. Idempotent on an offset that is
693    /// already a stop — the `(row, col)` click path already snaps this way inside
694    /// [`offset_of_pos`](Self::offset_of_pos), and this gives the pixel path the
695    /// same guarantee. Returns `off` unchanged only for an empty document (no
696    /// stops at all).
697    pub fn snap_to_stop(&self, off: usize) -> usize {
698        self.nearest_stop(off)
699    }
700
701    /// The caret stop nearest `off`, preferring the one before it when `off`
702    /// falls exactly between two. Returns `off` unchanged if there are no stops
703    /// at all (an empty document).
704    fn nearest_stop(&self, off: usize) -> usize {
705        let i = self.stops.partition_point(|&s| s < off);
706        let after = self.stops.get(i).copied();
707        let before = i.checked_sub(1).map(|j| self.stops[j]);
708        match (before, after) {
709            (Some(b), Some(a)) if off - b <= a - off => b,
710            (_, Some(a)) => a,
711            (Some(b), None) => b,
712            (None, None) => off,
713        }
714    }
715
716    /// Whether the caret can occupy `row` at all: decoration rows (a table's
717    /// border rules) are stepped over by vertical motion.
718    pub fn row_is_navigable(&self, row: usize) -> bool {
719        self.rows.get(row).is_some_and(|r| !r.decoration)
720    }
721
722    /// The first offset the caret can rest at on `row` — its first stop, or the
723    /// row's own end when it holds no text (an empty paragraph). `None` for a
724    /// decoration row, which holds no caret at all.
725    ///
726    /// Not `offset_of_pos(row, 0)`: column 0 of a quoted or listed row is the
727    /// gutter, and a gutter's `src` points at the *block* it opens, so the stop
728    /// nearest it is the one on the block's first row rather than on this one.
729    /// Which is right for a click — the gutter decorates the whole block — and
730    /// wrong for Home, whose whole question is where *this* row starts.
731    pub fn row_start(&self, row: usize) -> Option<usize> {
732        let r = self.rows.get(row).filter(|r| !r.decoration)?;
733        Some(
734            r.glyphs
735                .iter()
736                .find(|g| g.stop)
737                .map_or(r.end_src, |g| g.src),
738        )
739    }
740
741    /// The last row the caret can rest on — the fallback when an offset is past
742    /// everything rendered (a table's bottom border must not swallow the caret).
743    fn last_stop_row(&self) -> usize {
744        (0..self.rows.len())
745            .rev()
746            .find(|&r| self.row_is_navigable(r))
747            .unwrap_or(0)
748    }
749
750    /// The nearest row above `row` the caret can occupy, skipping decoration.
751    pub fn navigable_above(&self, row: usize) -> Option<usize> {
752        (0..row.min(self.rows.len()))
753            .rev()
754            .find(|&r| self.row_is_navigable(r))
755    }
756
757    /// The nearest row below `row` the caret can occupy, skipping decoration.
758    pub fn navigable_below(&self, row: usize) -> Option<usize> {
759        ((row + 1)..self.rows.len()).find(|&r| self.row_is_navigable(r))
760    }
761
762    /// The caret stop just before `off` — one press of Left. `None` at the
763    /// first stop in the document.
764    ///
765    /// Runs of decoration (a table border, a cell's alignment padding) are
766    /// stepped over in a single press: they hold no stop, so they aren't in the
767    /// table to land on.
768    pub fn stop_before(&self, off: usize) -> Option<usize> {
769        let i = self.stops.partition_point(|&s| s < off);
770        i.checked_sub(1).map(|i| self.stops[i])
771    }
772
773    /// The caret stop just after `off` — one press of Right. `None` at the last
774    /// stop in the document.
775    pub fn stop_after(&self, off: usize) -> Option<usize> {
776        let i = self.stops.partition_point(|&s| s <= off);
777        self.stops.get(i).copied()
778    }
779
780    /// The first caret stop at or past `off` — where the caret at a hidden
781    /// offset is *drawn*, and so where a rightward walk over the rendered text
782    /// starts from.
783    pub fn stop_at_or_after(&self, off: usize) -> Option<usize> {
784        let i = self.stops.partition_point(|&s| s < off);
785        self.stops.get(i).copied()
786    }
787
788    /// The last caret stop at or before `off` — where a leftward walk starts
789    /// from. Snapping the way the walk is headed, rather than always forward,
790    /// is what keeps a leftward motion from ever moving the caret right.
791    pub fn stop_at_or_before(&self, off: usize) -> Option<usize> {
792        let i = self.stops.partition_point(|&s| s <= off);
793        i.checked_sub(1).map(|i| self.stops[i])
794    }
795
796    /// Whether the caret may rest at `off` — the invariant every motion in this
797    /// view has to leave standing.
798    pub fn is_stop(&self, off: usize) -> bool {
799        self.stops.binary_search(&off).is_ok()
800    }
801
802    /// The visible text a caret crosses walking rightward from `from` up to
803    /// (but not including) `to` — `UITextInput.text(in:)`'s `[from, to)` in
804    /// *this* view. A hidden inline-mark delimiter (`**`, `` ` ``, `_`, an
805    /// escape backslash) never got a glyph in the first place — see
806    /// [`push_text`]/[`synth`] — so it contributes nothing; what's left is
807    /// exactly what's drawn on screen for that span.
808    ///
809    /// Built from the same stop glyphs [`stop_after`](Self::stop_after) steps
810    /// across (every glyph with [`Glyph::stop`] set, i.e. one per grapheme
811    /// cluster, decoration excluded) — **plus one inserted `'\n'` for every
812    /// genuine block boundary strictly inside `[from, to)`**: a run of whole
813    /// [`decoration`] rows sitting between two content rows — a paragraph
814    /// gap, a table rule, an image's reserved filler rows — never an ordinary
815    /// soft wrap, which puts no decoration *row* between the two halves of
816    /// its one paragraph (only inline decoration glyphs, e.g. a table's `│`,
817    /// live inside a single content row, and never split one).
818    ///
819    /// [`decoration`]: VRow::decoration
820    ///
821    /// Without that inserted break, two blocks abutting in this string were
822    /// indistinguishable from one run of text: [`collect_stops`] gives a
823    /// block boundary *zero* stops of its own (crossing one is a single,
824    /// free hop — see `the_caret_skips_the_gap_between_two_paragraphs` in
825    /// `doc.rs`'s tests, which pins that as intentional caret behaviour, a
826    /// paragraph gap costing no extra Right presses, not a bug to fix here).
827    /// So the last word of one paragraph and the first word of the next used
828    /// to land directly adjacent with *nothing* between them in this string
829    /// (`"...edb\n\nhello\n"` read back as `"edbhello"`), and `UITextInput`'s
830    /// default word tokenizer then saw one unbroken run of letters and
831    /// selected across the boundary — reported as double-tapping the last
832    /// word on a line expanding the selection into the following
833    /// paragraph(s).
834    ///
835    /// This means the once-strict equality with `distance_offset`/
836    /// `step_offset` (`leaf-ffi`) no longer always holds: those intentionally
837    /// keep costing a block boundary *zero* stops, while this text now
838    /// spends one *character* on it that is never itself a stop. So the
839    /// relationship is `visible_text(a, b).chars().count() >=
840    /// distance_offset(a, b)`, equality holding whenever `(a, b)` spans no
841    /// block boundary (the common case, and the only case the previous
842    /// equality was ever tested against). It can only ever be *greater*,
843    /// never less: every character this function omits relative to a plain
844    /// stop count is a stop with no glyph of its own (a hidden delimiter, or
845    /// a block's own trailing "end of row" stop), and every such omission at
846    /// a block's end is exactly paired with the one inserted separator that
847    /// follows it, so nothing this function returns is ever short of what a
848    /// consumer walking stops one at a time would need. That inequality is
849    /// still exactly what `UITextInput`'s tokenizer needs: it only ever reads
850    /// this string to find a boundary and converts the character index it
851    /// finds back to a position with `position(from:offset:)`, which walks
852    /// stops — an inserted separator is never handed back as one, it only
853    /// keeps two paragraphs' words apart for the tokenizer's letter-run scan.
854    ///
855    /// `from` is snapped to its nearest stop first, exactly as a caret asked
856    /// to stand at a hidden offset is drawn at the next stop instead; `to` is
857    /// left as given, so a stop landing exactly on it is still the walk's
858    /// last step — the same asymmetry `distance_offset`'s own loop has.
859    pub fn visible_text(&self, from: usize, to: usize) -> String {
860        self.visible_items(from, to)
861            .into_iter()
862            .map(|(_, ch)| ch.unwrap_or('\n'))
863            .collect()
864    }
865
866    /// The UTF-16 length of `visible_text(from, to)` — what an `NSRange`
867    /// location into that text is, without building the string.
868    ///
869    /// AppKit's `NSTextInputClient` and `NSAccessibility` speak in UTF-16
870    /// units of *the text as the system sees it*, which for leaf is the visible
871    /// text — delimiters hidden. A frontend reporting its selection to the
872    /// system converts each end with this and gets back an index into the
873    /// string `visible_text(0, end)` returns, which is exactly what the system
874    /// will index into.
875    pub fn visible_utf16_len(&self, from: usize, to: usize) -> usize {
876        self.visible_items(from, to)
877            .into_iter()
878            .map(|(_, ch)| ch.map_or(1, char::len_utf16))
879            .sum()
880    }
881
882    /// The inverse of `visible_utf16_len(0, ·)`: the source offset of the
883    /// visible character a UTF-16 index into `visible_text(0, to)` lands on.
884    ///
885    /// An index inside a surrogate pair resolves to the character that owns
886    /// it; one at or past the end of the text returns `None`, so a caller can
887    /// substitute the document's end stop. A synthetic block separator (the
888    /// `\n` the text spells a boundary with) resolves to the gap offset, which
889    /// is not a stop, so a caller placing a caret there gets it snapped like
890    /// any other hidden offset.
891    pub fn offset_at_visible_utf16(&self, to: usize, index: usize) -> Option<usize> {
892        let mut seen = 0usize;
893        for (src, ch) in self.visible_items(0, to) {
894            let len = ch.map_or(1, char::len_utf16);
895            if index < seen + len {
896                return Some(src);
897            }
898            seen += len;
899        }
900        None
901    }
902
903    /// The items `visible_text` spells, in order: every stop glyph in range
904    /// keyed by its own source offset (`Some(ch)`), and every block boundary
905    /// in range keyed by its gap offset (`None`, drawn as `\n`).
906    fn visible_items(&self, from: usize, to: usize) -> Vec<(usize, Option<char>)> {
907        let from = self.nearest_stop(from);
908
909        // Real content: every stop glyph in range, keyed by its own source
910        // offset (`None` tags it as a genuine character, versus the
911        // synthetic separators below).
912        let mut items: Vec<(usize, Option<char>)> = self
913            .rows
914            .iter()
915            .filter(|r| !r.decoration)
916            .flat_map(|r| r.glyphs.iter())
917            .filter(|g| g.stop && g.src >= from && g.src < to)
918            .map(|g| (g.src, Some(g.ch)))
919            .collect();
920
921        // Every structural boundary row contributes a separator; its `end_src`
922        // is the gap offset itself (never a stop — see
923        // `place_caret_snaps_out_of_the_blank_gap_between_paragraphs` in
924        // `doc.rs`) — a source offset like any glyph's, so it merges into the
925        // same ordering. `None` marks it a synthetic separator rather than a
926        // real character, tagged distinctly so a query landing exactly on the
927        // gap offset still opens with its break even with no glyph on either
928        // side to anchor it to (a range spanning nothing but a bare gap).
929        let mut boundaries: Vec<usize> = self
930            .rows
931            .iter()
932            // A table rule is also a decoration row, but it is chrome *inside*
933            // one block. Treating it as a block boundary inserts newlines into
934            // table cells (for example "Feature" became "F\neature").
935            .filter(|r| r.boundary.is_some())
936            .map(|r| r.end_src)
937            .filter(|&src| src >= from && src < to)
938            .collect();
939        boundaries.sort_unstable();
940        boundaries.dedup();
941        items.extend(boundaries.into_iter().map(|src| (src, None)));
942
943        // Row order matches source order except across a table's wrapped
944        // cells (see `pos_of_offset`), so sort rather than trust it here too.
945        // A boundary can't share an offset with a glyph (it's the undrawn gap
946        // between two blocks' real content), so tie-breaking never arises.
947        items.sort_by_key(|&(src, _)| src);
948        items
949    }
950}
951
952/// Collect the caret stops of a laid-out grid: every stop glyph's offset plus
953/// every row's end, ascending and deduplicated. Duplicates are the norm rather
954/// than the exception — a wrapped line's end is the same offset as the next
955/// line's first glyph — and collapsing them is what makes one press of Left or
956/// Right cross exactly one stop.
957fn collect_stops(rows: &[VRow]) -> Vec<usize> {
958    let mut stops: Vec<usize> = rows
959        .iter()
960        .filter(|r| !r.decoration)
961        .flat_map(|r| {
962            r.glyphs
963                .iter()
964                .filter(|g| g.stop)
965                .map(|g| g.src)
966                .chain(std::iter::once(r.end_src))
967        })
968        .collect();
969    stops.sort_unstable();
970    stops.dedup();
971    stops
972}
973
974/// Group the rows tagged [`VRow::code`] into one [`CodeBlockInfo`] per maximal
975/// run — the block-level view a frontend needs to box and scroll each code
976/// block. Two code blocks are always parted by the blank separator row a block
977/// boundary is spelled with (never itself a code row), so a contiguous run is
978/// exactly one block. Derived from the final rows rather than tracked through
979/// the builder so it comes out right no matter how [`build_cached`] and
980/// [`build_spliced`] shuffle rows around.
981fn code_block_spans(rows: &[VRow]) -> Vec<CodeBlockInfo> {
982    let mut blocks = Vec::new();
983    let mut start: Option<usize> = None;
984    for (i, row) in rows.iter().enumerate() {
985        match (row.code, start) {
986            (true, None) => start = Some(i),
987            (false, Some(s)) => {
988                blocks.push(CodeBlockInfo {
989                    rows_span: s..i,
990                    lang: rows[s].code_lang.clone(),
991                });
992                start = None;
993            }
994            _ => {}
995        }
996    }
997    if let Some(s) = start {
998        blocks.push(CodeBlockInfo {
999            rows_span: s..rows.len(),
1000            lang: rows[s].code_lang.clone(),
1001        });
1002    }
1003    blocks
1004}
1005
1006/// The value of `node`'s `key` attribute, if it carries one *with* a value. A
1007/// bare attribute (`controls`, `muted`) has a `None` value and so reads as
1008/// absent here — a caller wanting presence-not-value tests the list directly.
1009/// Shared by the media element and `<source>` readers.
1010fn attr_of(node: &FlatNode, key: &str) -> Option<String> {
1011    node.attrs
1012        .iter()
1013        .find(|(k, _)| k == key)
1014        .and_then(|(_, v)| v.clone())
1015}
1016
1017/// Collect one [`MediaInfo`] per row carrying a [`VRow::media`] mark — the
1018/// block-level view a frontend needs to replace each placeholder row with a real
1019/// picture. The mark rides the block's *first* row and names how many rows the
1020/// media reserves ([`MediaMark::rows`]); the rows below it are blank
1021/// [`decoration`](VRow::decoration) fillers that hold the vertical space and no
1022/// caret. So the span runs from the marked row across those fillers. Derived from
1023/// the final rows rather than tracked through the builder so it survives however
1024/// [`build_cached`] and [`build_spliced`] shuffle rows around.
1025fn media_spans(rows: &[VRow]) -> Vec<MediaInfo> {
1026    rows.iter()
1027        .enumerate()
1028        .filter_map(|(i, row)| {
1029            row.media.as_ref().map(|m| MediaInfo {
1030                rows_span: i..i + m.rows.max(1),
1031                kind: m.kind,
1032                destination: m.destination.clone(),
1033                sources: m.sources.clone(),
1034                alt: m.alt.clone(),
1035                poster: m.poster.clone(),
1036            })
1037        })
1038        .collect()
1039}
1040
1041/// Re-label the drawn block boundaries either side of a block-level media
1042/// placeholder, so the pair a frontend spaces by names the picture.
1043///
1044/// [`BlockClass::from_node_kind`] classifies the node the walk is standing on,
1045/// and a block image is never a node of its own: [`Builder::media_only`] promotes
1046/// its *wrapper* — a `paragraph`, or the `container` a `<video>`/`<audio>`
1047/// arrives as — so the gap above a picture reported [`BlockClass::Paragraph`] and
1048/// the gap above a movie reported [`BlockClass::Directive`], the class a frontend
1049/// paints a tinted panel for. [`BlockClass::Media`] was unreachable in
1050/// consequence: the vocabulary named a kind no frontend could ever be told about.
1051///
1052/// Done as a pass over the finished rows rather than inside the walk because
1053/// only the rows know. The incremental top-level walk carries no node arena at
1054/// all (`nodes: &[]`) and can classify by kind alone, so teaching the wrapper
1055/// promotion to the whole-arena walk would label the full and incremental builds
1056/// differently — the exact drift that walk's own comment forbids. Both builds
1057/// emit the same [`VRow::media`] marks, so both reach the same answer here. The
1058/// [`media_spans`] / [`code_block_spans`] pattern.
1059///
1060/// One gap can be spelled with several rows — [`Builder::emit_separators_before`]
1061/// draws the row that closes the block above and the row that opens the block
1062/// below, with any extra blank source lines navigable between them — and gives
1063/// every one of them the same [`Boundary`]. So the walk crosses those navigable
1064/// blanks and relabels the whole run, stopping at the first row that is neither.
1065fn label_media_boundaries(rows: &mut [VRow]) {
1066    let spans: Vec<Range<usize>> = rows
1067        .iter()
1068        .enumerate()
1069        .filter_map(|(i, row)| row.media.as_ref().map(|m| i..i + m.rows.max(1)))
1070        .collect();
1071    // A row inside one gap: a drawn boundary to relabel, or one of the navigable
1072    // blank lines sitting between two drawn ones. Anything else ends the run.
1073    fn in_gap(row: &VRow) -> bool {
1074        row.boundary.is_some() || (!row.decoration && row.glyphs.is_empty())
1075    }
1076    for span in spans {
1077        for i in (0..span.start).rev() {
1078            if !in_gap(&rows[i]) {
1079                break;
1080            }
1081            if let Some(b) = rows[i].boundary.as_mut() {
1082                b.below = BlockClass::Media;
1083            }
1084        }
1085        for row in rows.iter_mut().skip(span.end) {
1086            if !in_gap(row) {
1087                break;
1088            }
1089            if let Some(b) = row.boundary.as_mut() {
1090                b.above = BlockClass::Media;
1091            }
1092        }
1093    }
1094}
1095
1096/// Collect one [`DirectiveInfo`] per row carrying a [`VRow::leaf_directive`]
1097/// mark — the block-level view a frontend needs to replace each placeholder row
1098/// with whatever the directive means to it. The peer of [`media_spans`], derived
1099/// from the final rows for the same reason: it survives however [`build_cached`]
1100/// and [`build_spliced`] shuffle rows around.
1101fn directive_spans(rows: &[VRow]) -> Vec<DirectiveInfo> {
1102    rows.iter()
1103        .enumerate()
1104        .filter_map(|(i, row)| {
1105            row.leaf_directive.as_ref().map(|m| DirectiveInfo {
1106                rows_span: i..i + m.rows.max(1),
1107                name: m.name.clone(),
1108                attrs: m.attrs.clone(),
1109                label: m.label.clone(),
1110            })
1111        })
1112        .collect()
1113}
1114
1115/// The source range of a fenced code block's info string — everything on the
1116/// opening line past the fence (`` ```rust `` → the `rust`). `block_start` is the
1117/// code block node's `span.start`. `None` for an indented code block, which
1118/// opens with no fence to carry one. The range is empty for a fence written
1119/// bare (`` ``` `` alone), which is exactly where a language would be inserted.
1120///
1121/// Shared by the WYSIWYG builder (to label the box) and [`crate::Doc`] (to edit
1122/// the label through a prompt), so the two agree on where the language lives.
1123pub fn code_info_span(source: &str, block_start: usize) -> Option<Range<usize>> {
1124    let rest = source.get(block_start..)?;
1125    let line_len = rest.find('\n').unwrap_or(rest.len());
1126    let line = &rest[..line_len];
1127    // A fence may be indented up to three spaces; past that it opens with a run
1128    // of the same fence character.
1129    let indent = line.len() - line.trim_start().len();
1130    if indent > 3 {
1131        return None;
1132    }
1133    let fence = line[indent..].chars().next()?;
1134    if fence != '`' && fence != '~' {
1135        return None; // an indented block, not a fenced one
1136    }
1137    let fence_len = line[indent..].chars().take_while(|&c| c == fence).count();
1138    let info_start = block_start + indent + fence_len;
1139    Some(info_start..block_start + line_len)
1140}
1141
1142/// A fenced code block's language for display: its info string, trimmed, or
1143/// `None` when there's no fence or the fence carries no language. The trimmed
1144/// text is what a frontend labels the box with; [`code_info_span`] is what an
1145/// edit replaces.
1146pub fn code_language(source: &str, block_start: usize) -> Option<String> {
1147    let span = code_info_span(source, block_start)?;
1148    let text = source.get(span)?.trim();
1149    (!text.is_empty()).then(|| text.to_string())
1150}
1151
1152/// A horizontal rule's dash count when the map isn't wrapping to a column grid
1153/// (the GUI, which wraps at pixel width): a fixed, sane width the frontend can
1154/// paint or re-wrap, instead of a runaway count from an unbounded wrap width.
1155const UNWRAPPED_RULE_WIDTH: usize = 40;
1156
1157/// Render the document to a [`VisualMap`]. `wrap` is the column budget for
1158/// word-wrapping (`Some` for the monospace TUI), or `None` to emit one row per
1159/// block — the GUI does its own proportional pixel wrapping over these rows.
1160/// Text and offsets come from the AST (`str` nodes carry the verbatim source
1161/// slice and an exact span), so the original source string isn't needed here.
1162pub fn build(
1163    nodes: &[FlatNode],
1164    source: &str,
1165    wrap: Option<usize>,
1166    preserve_soft: bool,
1167    media_rows: &HashMap<String, usize>,
1168    reveal: Option<Range<usize>>,
1169) -> VisualMap {
1170    let Some(doc) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
1171        return VisualMap::default();
1172    };
1173    let top = top_level(nodes, doc);
1174    let mut b = Builder {
1175        nodes,
1176        source,
1177        wrap: wrap.map(|w| w.max(8)),
1178        rows: Vec::new(),
1179        tables: Vec::new(),
1180        last_off: 0,
1181        stepped_over: 0,
1182        media_rows,
1183        break_glyph: Cell::new(' '),
1184        preserve_soft,
1185        reveal: reveal.clone(),
1186    };
1187    let last_drawn = b.top_blocks(&top);
1188    // The hidden frontmatter's end is the baseline for both the trailing blank
1189    // rows and the caret floor — see [`hidden_prefix_end`]. `top_level` has
1190    // already dropped every `metadata` child, so read it off the arena.
1191    let hidden_end = hidden_prefix_end(source, metadata_end_of(nodes, doc));
1192    b.emit_trailing_blank_lines(last_drawn.unwrap_or(BlockClass::Paragraph), hidden_end);
1193    let content_start = top.first().map_or(hidden_end, |&i| nodes[i].span.start);
1194    let stops = collect_stops(&b.rows);
1195    label_media_boundaries(&mut b.rows);
1196    let code_blocks = code_block_spans(&b.rows);
1197    let media = media_spans(&b.rows);
1198    let directives = directive_spans(&b.rows);
1199    VisualMap {
1200        rows: b.rows,
1201        content_start,
1202        stops,
1203        tables: b.tables,
1204        code_blocks,
1205        media,
1206        directives,
1207    }
1208}
1209
1210/// Like [`build`], but reuses a persistent [`BlockCache`] so an edit re-renders
1211/// only the top-level blocks whose source bytes changed *and* marshals only
1212/// those blocks from twig instead of the whole arena.
1213///
1214/// `top` is the document's top-level blocks — twig's `child_spans` of the doc
1215/// root: `(node_id, kind, span)` for each, in order. `fetch_subtree(node_id)`
1216/// marshals one block's subtree (local-indexed, root at 0) and is called *only*
1217/// for a block that missed the cache, i.e. one that actually changed. So a
1218/// keystroke marshals one small subtree, not ~20k nodes. The result is
1219/// byte-for-byte identical to [`build`] on the same document (the
1220/// `build_cached_matches_build` test pins this); [`build`] stays the cache-free,
1221/// whole-arena reference. This is the entry point [`crate::Doc`] uses.
1222// One builder, and every one of these is a distinct input to the same layout
1223// pass — a struct of them would be built at the one call site and unpacked
1224// here, which is the same arguments with an extra name in the way.
1225#[allow(clippy::too_many_arguments)]
1226pub fn build_cached(
1227    top: &[QueryMatch],
1228    source: &str,
1229    wrap: Option<usize>,
1230    preserve_soft: bool,
1231    media_rows: &HashMap<String, usize>,
1232    reveal: Option<Range<usize>>,
1233    cache: &mut BlockCache,
1234    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1235) -> VisualMap {
1236    let wrap = wrap.map(|w| w.max(8));
1237
1238    // Wrapping is a function of the width, so a width change makes every cached
1239    // row's wrap wrong: start the cache over.
1240    if cache.wrap != Some(wrap) {
1241        cache.entries.clear();
1242        cache.wrap = Some(wrap);
1243    }
1244    cache.generation = cache.generation.wrapping_add(1);
1245
1246    // Frontmatter (a leading `metadata` block) is document metadata, not prose:
1247    // hidden in the rich view exactly as [`Builder::blocks`] skips it.
1248    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1249
1250    // The outer builder only accumulates rows/tables and spells block boundaries
1251    // — both a function of the source and `last_off`, never of a node array — so
1252    // it carries an empty `nodes`. Each changed block is rendered by a *fresh*
1253    // builder over that block's subtree.
1254    let mut b = Builder {
1255        nodes: &[],
1256        source,
1257        wrap,
1258        rows: Vec::new(),
1259        tables: Vec::new(),
1260        last_off: 0,
1261        stepped_over: 0,
1262        media_rows,
1263        break_glyph: Cell::new(' '),
1264        preserve_soft,
1265        reveal: reveal.clone(),
1266    };
1267
1268    // Record the per-block row decomposition as we go, so a later
1269    // [`build_spliced`] can patch one block without rebuilding the map.
1270    let mut layout_blocks: Vec<BlockLayout> = Vec::with_capacity(blocks.len());
1271    let mut all_shift_safe = true;
1272    // The class of the last block that drew anything: what the next separator
1273    // closes, and what the trailing blank lines close at the end. A hidden block
1274    // (a comment) never becomes it — see [`Builder::block_or_hidden`], whose
1275    // step-over this loop repeats for the incremental walk.
1276    let mut above: Option<BlockClass> = None;
1277    for block in &blocks {
1278        let start = block.span.start;
1279        let before_sep = b.rows.len();
1280        if let Some(above) = above {
1281            // This walker has no node arena at all (see the `nodes: &[]` above),
1282            // but a top-level query match carries its kind — the same string
1283            // `BlockClass::from_node_kind` classifies for the whole-arena walk, so
1284            // the incremental and full builds label a boundary identically.
1285            b.emit_separators_before(
1286                start,
1287                &[],
1288                true,
1289                Boundary {
1290                    above,
1291                    below: BlockClass::from_node_kind(&block.kind),
1292                },
1293            );
1294        }
1295        let after_sep = b.rows.len();
1296        let bytes = block_bytes(source, &block.span);
1297        let hash = block_hash(bytes);
1298        // How this block meets the reveal line, if at all — part of its cache
1299        // key, since the same bytes render differently on the caret's line.
1300        let rkey = reveal_key(&reveal, &block.span);
1301
1302        // Hit: clone the block's rows shifted to its current offset and restore
1303        // the (shifted) `last_off` so the next separator lands right — no marshal.
1304        // Only shift-safe blocks are ever cached, so a hit is safe by construction.
1305        if let Some(hit) = cache.reuse(hash, bytes, &rkey) {
1306            let delta = start as isize - hit.built_start as isize;
1307            for row in &hit.rows {
1308                b.rows.push(shift_row(row, delta));
1309            }
1310            b.last_off = (hit.last_off as isize + delta) as usize;
1311        } else {
1312            // Miss: marshal just this block's subtree and render it. A subtree is
1313            // self-contained with local ids (root at 0) and absolute spans, so a
1314            // fresh builder over it produces the same rows the whole-arena path
1315            // would. An empty subtree (twig couldn't hand it back) renders nothing.
1316            let subtree = fetch_subtree(block.node_id);
1317            if !subtree.is_empty() {
1318                let mut sub = Builder {
1319                    nodes: &subtree,
1320                    source,
1321                    wrap,
1322                    rows: Vec::new(),
1323                    tables: Vec::new(),
1324                    last_off: 0,
1325                    stepped_over: 0,
1326                    media_rows,
1327                    break_glyph: Cell::new(' '),
1328                    preserve_soft,
1329                    reveal: reveal.clone(),
1330                };
1331                sub.block(0, &[], &[]);
1332                // A block that drew nothing is stepped over, not stood on: its
1333                // `last_off` is its own end, so the separator after it counts
1334                // from there. The sub-builder started at 0 and never moved, and
1335                // 0 is where the next separator would otherwise count from —
1336                // every line of the document, as a blank row each.
1337                let last_off = if sub.rows.is_empty() {
1338                    block.span.end
1339                } else {
1340                    sub.last_off
1341                };
1342                // Cache only a block that is table-free AND renders inside its own
1343                // span: those two are the conditions for reuse-by-shift to be
1344                // correct. A block failing either is re-rendered every build (a
1345                // fresh render always matches a fresh whole-document build).
1346                if sub.tables.is_empty() {
1347                    if rows_within(&sub.rows, &block.span) {
1348                        cache.store(hash, bytes, start, sub.rows.clone(), last_off, rkey);
1349                    }
1350                    b.rows.extend(sub.rows);
1351                } else {
1352                    // A table block is never cached; rebase its row-index
1353                    // bookkeeping onto the combined row vector and append.
1354                    let base = b.rows.len();
1355                    for t in &mut sub.tables {
1356                        t.rows_span = (t.rows_span.start + base)..(t.rows_span.end + base);
1357                    }
1358                    b.rows.extend(sub.rows);
1359                    b.tables.extend(sub.tables);
1360                }
1361                b.last_off = last_off;
1362            }
1363        }
1364        let content_rows = b.rows.len() - after_sep;
1365        let sep_rows = if content_rows == 0 {
1366            // Hidden: take back the separator drawn for it, so what stands
1367            // either side meets across one boundary. Its layout entry stays, at
1368            // no rows, so the splice arithmetic still counts one entry per block.
1369            b.rows.truncate(before_sep);
1370            // A cache hit restored the stored `last_off` above; an empty subtree
1371            // (twig couldn't hand it back) restored nothing. Either way the walk
1372            // stands past the block.
1373            b.last_off = b.last_off.max(block.span.end);
1374            b.stepped_over = b.stepped_over.max(block.span.end);
1375            0
1376        } else {
1377            above = Some(BlockClass::from_node_kind(&block.kind));
1378            all_shift_safe &= rows_within(&b.rows[after_sep..], &block.span);
1379            after_sep - before_sep
1380        };
1381        layout_blocks.push(BlockLayout {
1382            span: block.span.clone(),
1383            kind: block.kind.clone(),
1384            sep_rows,
1385            content_rows,
1386        });
1387    }
1388
1389    let before_trailing = b.rows.len();
1390    let hidden_end = hidden_prefix_end(
1391        source,
1392        top.iter()
1393            .filter(|m| m.kind == Kind::Metadata)
1394            .map(|m| m.span.end)
1395            .next_back(),
1396    );
1397    b.emit_trailing_blank_lines(above.unwrap_or(BlockClass::Paragraph), hidden_end);
1398    let trailing_rows = b.rows.len() - before_trailing;
1399
1400    // Evict every entry no block reused this build, so the cache tracks the
1401    // current document instead of growing without bound over a session.
1402    let g = cache.generation;
1403    cache.entries.retain(|_, bucket| {
1404        bucket.retain(|e| e.generation == g);
1405        !bucket.is_empty()
1406    });
1407
1408    cache.layout = Layout {
1409        blocks: layout_blocks,
1410        trailing_rows,
1411        built_len: source.len(),
1412        has_tables: !b.tables.is_empty(),
1413        all_shift_safe,
1414        reveal: reveal.clone(),
1415    };
1416
1417    // The first rendered offset is the first non-metadata block's start — the
1418    // analogue of [`first_content_offset`] for the top-level list. With nothing
1419    // but frontmatter it's the end of that frontmatter, and 0 for an empty
1420    // document ([`hidden_prefix_end`]).
1421    let content_start = blocks.first().map_or(hidden_end, |m| m.span.start);
1422    let stops = collect_stops(&b.rows);
1423    label_media_boundaries(&mut b.rows);
1424    let code_blocks = code_block_spans(&b.rows);
1425    let media = media_spans(&b.rows);
1426    let directives = directive_spans(&b.rows);
1427    VisualMap {
1428        rows: b.rows,
1429        content_start,
1430        stops,
1431        tables: b.tables,
1432        code_blocks,
1433        media,
1434        directives,
1435    }
1436}
1437
1438/// The fast path for a single-block edit: patch the previous [`VisualMap`] in
1439/// place rather than reassembling it. Returns `Some(new_map)` when it applies,
1440/// or `None` to tell the caller to fall back to [`build_cached`] (always
1441/// correct). Consumes `prev` either way — on `None` the caller rebuilds from
1442/// scratch and doesn't need it.
1443///
1444/// It applies only when `dirty` (twig's dirty byte range) falls inside exactly
1445/// one top-level block AND the block structure around it is unchanged — verified
1446/// by matching the new `top` list against the previous [`Layout`] block for
1447/// block: kinds unchanged, spans before the edit identical, spans after it
1448/// shifted by the byte delta, count unchanged. Any deviation — a block split or
1449/// merged, a fence opened to swallow later blocks, a table anywhere, a
1450/// multi-block edit — fails the match and returns `None`. That check is what
1451/// makes the byte-range trustworthy: twig's dirty range is exact about *bytes*
1452/// but silent about *reparse*, and the structural match catches the reparse
1453/// effects it can't see.
1454///
1455/// When it applies, the unchanged prefix rows move verbatim, the suffix rows
1456/// shift by the delta *in place* (integer adds, no glyph copy), and only the one
1457/// dirty block is re-marshalled and re-rendered; stops splice the same way by
1458/// offset. So the cost is O(rows after the edit), and nothing before the edit is
1459/// touched. The hash-keyed entry cache is left alone — a later [`build_cached`]
1460/// will miss on the changed block, re-render it, and evict the stale entry, so
1461/// chained splices neither corrupt nor grow it.
1462// One builder, and every one of these is a distinct input to the same layout
1463// pass — a struct of them would be built at the one call site and unpacked
1464// here, which is the same arguments with an extra name in the way.
1465#[allow(clippy::too_many_arguments)]
1466pub fn build_spliced(
1467    prev: VisualMap,
1468    source: &str,
1469    wrap: Option<usize>,
1470    preserve_soft: bool,
1471    top: &[QueryMatch],
1472    dirty: Range<usize>,
1473    media_rows: &HashMap<String, usize>,
1474    reveal: Option<Range<usize>>,
1475    cache: &mut BlockCache,
1476    mut fetch_subtree: impl FnMut(u32) -> Vec<FlatNode>,
1477) -> Option<VisualMap> {
1478    let wrap = wrap.map(|w| w.max(8));
1479    // A width change invalidates every cached row — a full rebuild's job.
1480    if cache.wrap != Some(wrap) {
1481        return None;
1482    }
1483    // So does a moved reveal line, and for the same reason: this path reuses
1484    // every row outside the dirty block, and those rows encode which line was
1485    // showing its raw markup when they were built. Typing almost always moves
1486    // the caret, so under `MarkupMode::Full` this bails to `build_cached` on
1487    // most keystrokes — still block-cached, so only the edited block and the
1488    // revealed one actually re-render.
1489    if cache.layout.reveal != reveal {
1490        return None;
1491    }
1492    // Take the previous layout; on any bail below the caller rebuilds it (and the
1493    // map) via `build_cached`, so leaving it empty is fine. A table or a block
1494    // that renders outside its span (a degenerate inline span) makes shifting
1495    // unsound, so those force the full-rebuild path.
1496    let prev_layout = std::mem::take(&mut cache.layout);
1497    if prev_layout.built_len == 0 || prev_layout.has_tables || !prev_layout.all_shift_safe {
1498        return None;
1499    }
1500    // The layout addresses `prev` by row index, so it is only usable against the
1501    // map it was built from. A frontend is free to hold the map it was handed and
1502    // present it differently — leaf-ratatui splices blank filler rows under an
1503    // oversized heading so the raster has somewhere to stand — and if one of those
1504    // comes back here the row arithmetic below lands on the wrong rows: the
1505    // re-rendered block is laid over a filler and the rows it really occupied
1506    // survive into the suffix, stranding a stale copy of the edited line and
1507    // pushing everything after it one row down, once per keystroke. A row count
1508    // that doesn't match what this layout describes is the tell, and the honest
1509    // answer is the full rebuild.
1510    let described_rows = prev_layout
1511        .blocks
1512        .iter()
1513        .map(|pl| pl.sep_rows + pl.content_rows)
1514        .sum::<usize>()
1515        + prev_layout.trailing_rows;
1516    if described_rows != prev.rows.len() {
1517        return None;
1518    }
1519
1520    let blocks: Vec<&QueryMatch> = top.iter().filter(|m| m.kind != Kind::Metadata).collect();
1521    if blocks.is_empty() || blocks.len() != prev_layout.blocks.len() {
1522        return None;
1523    }
1524    let delta = source.len() as isize - prev_layout.built_len as isize;
1525
1526    // The single block whose NEW span contains the whole dirty range. A dirty
1527    // range straddling a block boundary (or a separator) finds none → bail.
1528    let k = blocks
1529        .iter()
1530        .position(|m| m.span.start <= dirty.start && dirty.end <= m.span.end)?;
1531
1532    // Structural match: every OTHER block is unchanged — same kind throughout,
1533    // span identical before the edit and shifted by `delta` after it. A mismatch
1534    // means the reparse reshaped the block structure, which only a full rebuild
1535    // renders correctly.
1536    for (i, (m, pl)) in blocks.iter().zip(&prev_layout.blocks).enumerate() {
1537        if m.kind != pl.kind {
1538            return None;
1539        }
1540        if i == k {
1541            continue;
1542        }
1543        let want = if i < k {
1544            pl.span.clone()
1545        } else {
1546            (pl.span.start as isize + delta) as usize..(pl.span.end as isize + delta) as usize
1547        };
1548        if m.span != want {
1549            return None;
1550        }
1551    }
1552    // The dirty block itself: start unchanged (the edit is inside it, past its
1553    // start), end moved by exactly the delta.
1554    let pk_start = prev_layout.blocks[k].span.start;
1555    let pk_end = prev_layout.blocks[k].span.end;
1556    let pk_sep = prev_layout.blocks[k].sep_rows;
1557    let pk_content = prev_layout.blocks[k].content_rows;
1558    if blocks[k].span.start != pk_start || blocks[k].span.end != (pk_end as isize + delta) as usize
1559    {
1560        return None;
1561    }
1562
1563    // Re-render the dirty block from its subtree. A table makes the splice
1564    // bookkeeping unsafe, so bail if one appears.
1565    let subtree = fetch_subtree(blocks[k].node_id);
1566    if subtree.is_empty() {
1567        return None;
1568    }
1569    let mut sub = Builder {
1570        nodes: &subtree,
1571        source,
1572        wrap,
1573        rows: Vec::new(),
1574        tables: Vec::new(),
1575        last_off: 0,
1576        stepped_over: 0,
1577        media_rows,
1578        break_glyph: Cell::new(' '),
1579        preserve_soft,
1580        reveal: reveal.clone(),
1581    };
1582    sub.block(0, &[], &[]);
1583    // A table, or content that renders outside the block's span (a degenerate
1584    // inline span), makes the shift bookkeeping unsound — fall back.
1585    if !sub.tables.is_empty() || !rows_within(&sub.rows, &blocks[k].span) {
1586        return None;
1587    }
1588    let new_content = sub.rows;
1589    let new_content_len = new_content.len();
1590    let new_stops = collect_stops(&new_content);
1591
1592    // Row span of the dirty block's CONTENT. Its leading separator stays in the
1593    // prefix: the gap before block k is unchanged, since k's start didn't move.
1594    let content_start_row: usize = prev_layout.blocks[..k]
1595        .iter()
1596        .map(|pl| pl.sep_rows + pl.content_rows)
1597        .sum::<usize>()
1598        + pk_sep;
1599    let content_end_row = content_start_row + pk_content;
1600
1601    // Splice rows: [prefix | new content | suffix + delta]. The prefix moves
1602    // untouched; the suffix shifts in place — integer adds, no glyph copy.
1603    let mut rows = prev.rows;
1604    let mut suffix = rows.split_off(content_end_row);
1605    rows.truncate(content_start_row);
1606    for row in &mut suffix {
1607        shift_row_in_place(row, delta);
1608    }
1609    rows.reserve(new_content_len + suffix.len());
1610    rows.extend(new_content);
1611    rows.extend(suffix);
1612
1613    // Splice stops by offset. The old dirty block covered `[pk_start, pk_end]`:
1614    // prefix stops fall below it, suffix stops above it (shift by delta), the new
1615    // content supplies the middle. The three ranges stay disjoint and ascending,
1616    // so the result needs no re-sort.
1617    let p1 = prev.stops.partition_point(|&s| s < pk_start);
1618    let p2 = prev.stops.partition_point(|&s| s <= pk_end);
1619    let mut stops = Vec::with_capacity(p1 + new_stops.len() + (prev.stops.len() - p2));
1620    stops.extend_from_slice(&prev.stops[..p1]);
1621    stops.extend(new_stops);
1622    for &s in &prev.stops[p2..] {
1623        stops.push((s as isize + delta) as usize);
1624    }
1625
1626    // Record the patched layout for the next splice: spans move to the new
1627    // coordinates, and the dirty block takes its new content-row count.
1628    let mut new_blocks = prev_layout.blocks;
1629    for (pl, m) in new_blocks.iter_mut().zip(&blocks) {
1630        pl.span = m.span.clone();
1631    }
1632    new_blocks[k].content_rows = new_content_len;
1633    cache.layout = Layout {
1634        blocks: new_blocks,
1635        trailing_rows: prev_layout.trailing_rows,
1636        built_len: source.len(),
1637        has_tables: false,
1638        // Every prefix/suffix block was shift-safe last build (we bailed
1639        // otherwise) and the re-rendered block was just checked, so the patched
1640        // document is still entirely shift-safe.
1641        all_shift_safe: true,
1642        reveal,
1643    };
1644
1645    label_media_boundaries(&mut rows);
1646    let code_blocks = code_block_spans(&rows);
1647    let media = media_spans(&rows);
1648    let directives = directive_spans(&rows);
1649    Some(VisualMap {
1650        rows,
1651        content_start: blocks[0].span.start,
1652        stops,
1653        tables: Vec::new(),
1654        code_blocks,
1655        media,
1656        directives,
1657    })
1658}
1659
1660/// A persistent, content-keyed cache of the rows each top-level block renders
1661/// to — the [`VisualMap`] analogue of the GUI's ShapedLine cache, one level
1662/// down. Held by a [`crate::Doc`] and threaded into [`build_cached`], it is what
1663/// makes a rebuild after a keystroke cost "re-render the edited block + shift
1664/// the rest" instead of re-rendering the whole document.
1665///
1666/// A top-level block's rows are a pure function of its source bytes and the wrap
1667/// width, so an unchanged block's rows are cloned and their source offsets
1668/// shifted by the edit's byte delta rather than rebuilt glyph by glyph. Two
1669/// things make that purity hold: at the top level the render prefix is always
1670/// empty (nesting prefixes — a quote gutter, a list indent — exist only *inside*
1671/// a top-level block, within its cached unit), and a block's output never reads
1672/// the incoming `last_off` (it writes `last_off` from its own content before any
1673/// nested separator reads it). So the only thing that differs between two
1674/// positions of an unchanged block is a uniform offset shift. Keyed by a fast
1675/// hash of the block's bytes with the bytes kept for a verify-on-hit — exactly
1676/// the shape cache's weak-hash-then-compare, so a collision costs a re-render,
1677/// never a wrong row.
1678///
1679/// Tables are never cached (a block that emits any table row is always rebuilt):
1680/// their rows are cross-referenced from the map's `tables` side-table by row
1681/// index, which a blind offset-shift wouldn't fix up, and they are rare enough
1682/// that the simplicity beats the reuse.
1683#[derive(Default)]
1684pub struct BlockCache {
1685    /// The wrap width every entry was built at; a change invalidates all of
1686    /// them. `None` before the first build (distinct from `Some(None)`, the
1687    /// unwrapped GUI width).
1688    wrap: Option<Option<usize>>,
1689    /// Bumped once per [`build_cached`]. An entry reused or inserted this build
1690    /// carries the current value; stale entries are dropped at the end of it.
1691    generation: u64,
1692    /// `hash(bytes)` → the block(s) sharing that hash — a bucket because
1693    /// distinct blocks can collide, while two *identical* blocks share one entry
1694    /// (free dedup).
1695    entries: HashMap<u64, Vec<CachedBlock>>,
1696    /// The row/stop decomposition of the last build, which [`build_spliced`]
1697    /// patches in place for a single-block edit. Kept in step with whatever
1698    /// [`VisualMap`] was last produced; empty before the first build.
1699    layout: Layout,
1700}
1701
1702/// How the last build's [`VisualMap`] decomposes into top-level blocks — the
1703/// bookkeeping [`build_spliced`] needs to splice one block's rows and stops
1704/// without rebuilding the whole map. Every field describes the *previous* build,
1705/// in that build's coordinates.
1706#[derive(Default)]
1707struct Layout {
1708    /// One entry per rendered (metadata-filtered) top-level block, in order.
1709    blocks: Vec<BlockLayout>,
1710    /// Trailing blank rows past the last block (from `emit_trailing_blank_lines`).
1711    trailing_rows: usize,
1712    /// The source length this layout was built at — the reference for the edit's
1713    /// byte delta.
1714    built_len: usize,
1715    /// Whether the last build drew any table. A table's cross-referenced row
1716    /// indices don't survive a blind splice, so their presence makes
1717    /// [`build_spliced`] bail to a full rebuild.
1718    has_tables: bool,
1719    /// Whether every block rendered strictly inside its own span (see
1720    /// [`rows_within`]). A block that doesn't — a malformed Markdown inline node
1721    /// that twig leaves with a degenerate `0..0` span renders at a fixed offset
1722    /// outside its block — can't be shifted correctly, so its presence makes
1723    /// [`build_spliced`] bail to a full rebuild.
1724    all_shift_safe: bool,
1725    /// The reveal line this layout was built under (see [`Builder::reveal`]).
1726    /// A splice reuses every row it isn't re-rendering, so a reveal line that
1727    /// has moved would leave the old line still showing its delimiters and the
1728    /// new one still hiding them — [`build_spliced`] bails when this changes.
1729    reveal: Option<Range<usize>>,
1730}
1731
1732/// One top-level block's contribution to the last build: its span and kind (for
1733/// the structural match that proves only one block changed) and how many
1734/// separator and content rows it emitted (to locate its slice of the row
1735/// vector).
1736struct BlockLayout {
1737    span: Range<usize>,
1738    kind: Kind,
1739    sep_rows: usize,
1740    content_rows: usize,
1741}
1742
1743/// One cached block: the rows it rendered to, plus what a reuse at a new
1744/// position needs to shift them. Offsets are stored absolute (as built) and
1745/// shifted by `new_start - built_start` on reuse.
1746struct CachedBlock {
1747    /// The block's exact source bytes, compared on a hash hit so a collision
1748    /// can never hand back another block's rows.
1749    bytes: Box<[u8]>,
1750    /// The offset the rows were built at (the block's `span.start`).
1751    built_start: usize,
1752    /// The block's rows, offsets absolute as built.
1753    rows: Vec<VRow>,
1754    /// `last_off` after this block was emitted, absolute as built — restored
1755    /// (shifted) on reuse so the following separator lands correctly.
1756    last_off: usize,
1757    /// Where the reveal line fell *within this block* when the rows were built,
1758    /// as a block-relative byte range — see [`reveal_key`]. Compared alongside
1759    /// `bytes` on a hit, because identical source renders to different rows
1760    /// depending on whether the caret's line is inside it: the same `*em*`
1761    /// shows its asterisks on the revealed line and hides them everywhere else.
1762    ///
1763    /// Block-relative rather than absolute so an unaffected block still hits
1764    /// after an edit shifts it, and `None` for the overwhelmingly common
1765    /// no-reveal case — which is why an entry stored under `MarkupMode::None`
1766    /// keeps hitting for every block that isn't the caret's.
1767    reveal: Option<Range<usize>>,
1768    /// The build that last reused or inserted this entry (see `generation`).
1769    generation: u64,
1770}
1771
1772/// Where `reveal` falls inside a block, in block-relative bytes — the extra key
1773/// a cached block is stored and matched under.
1774///
1775/// `None` when the block doesn't meet the reveal line at all, which is every
1776/// block on every build in the two hidden modes, and all but one of them under
1777/// [`crate::MarkupMode::Full`]. So the cache keeps its hit rate as the caret
1778/// moves: only the line the caret leaves and the line it arrives at re-render.
1779fn reveal_key(reveal: &Option<Range<usize>>, span: &Range<usize>) -> Option<Range<usize>> {
1780    let r = reveal.as_ref()?;
1781    // The same generous intersection test `Builder::revealed` uses, so a block
1782    // is keyed as revealed exactly when its glyphs will be built that way.
1783    (span.start <= r.end && r.start <= span.end).then(|| {
1784        let start = r.start.max(span.start) - span.start;
1785        let end = r.end.min(span.end) - span.start;
1786        start..end
1787    })
1788}
1789
1790impl BlockCache {
1791    /// Look up a block by hash, verify its bytes and reveal key, and on a hit
1792    /// stamp it used this build and hand back a borrow to shift-and-clone from.
1793    /// `None` on a miss (unknown hash, a collision whose bytes differ, or the
1794    /// same bytes built under a different reveal).
1795    fn reuse(
1796        &mut self,
1797        hash: u64,
1798        bytes: &[u8],
1799        reveal: &Option<Range<usize>>,
1800    ) -> Option<&CachedBlock> {
1801        let g = self.generation;
1802        let bucket = self.entries.get_mut(&hash)?;
1803        let e = bucket
1804            .iter_mut()
1805            .find(|e| &*e.bytes == bytes && &e.reveal == reveal)?;
1806        e.generation = g;
1807        Some(&*e)
1808    }
1809
1810    /// Cache the rows a freshly-rendered block produced (or refresh an existing
1811    /// entry for the same bytes and reveal — an identical block elsewhere, or a
1812    /// re-render).
1813    fn store(
1814        &mut self,
1815        hash: u64,
1816        bytes: &[u8],
1817        built_start: usize,
1818        rows: Vec<VRow>,
1819        last_off: usize,
1820        reveal: Option<Range<usize>>,
1821    ) {
1822        let g = self.generation;
1823        let bucket = self.entries.entry(hash).or_default();
1824        if let Some(e) = bucket
1825            .iter_mut()
1826            .find(|e| &*e.bytes == bytes && e.reveal == reveal)
1827        {
1828            e.built_start = built_start;
1829            e.rows = rows;
1830            e.last_off = last_off;
1831            e.generation = g;
1832        } else {
1833            bucket.push(CachedBlock {
1834                bytes: bytes.into(),
1835                built_start,
1836                rows,
1837                last_off,
1838                reveal,
1839                generation: g,
1840            });
1841        }
1842    }
1843}
1844
1845/// The source bytes a top-level block covers — the block cache's key material.
1846///
1847/// Clamped to the source rather than sliced by the span as twig gives it,
1848/// because that span can end *past* the last byte: the final block of a document
1849/// with no trailing newline is closed on the virtual newline the parser supplies
1850/// at EOF, so its `span.end` is `source.len() + 1`. Slicing by such a range
1851/// yields `None`, and the obvious `unwrap_or(&[])` reads that as *this block has
1852/// no bytes* — the wrong answer twice over.
1853///
1854/// Two blocks whose spans both overrun then key alike, and the second is served
1855/// the first one's rows. That is not hypothetical: a footnote definition is a
1856/// root beside `doc` merged back into the top level by [`top_blocks`], while the
1857/// `section` above it spans the definition's bytes too, so both end at EOF —
1858/// and a document ending in `[^note]: …` renders that definition as a second
1859/// copy of the heading. Even alone, a block that keeps hashing empty as the user
1860/// types in it is served the stale rows built before the edit.
1861///
1862/// Clamping hands back the bytes the block really covers, which tells both cases
1863/// apart, and costs nothing for a span that was in range to begin with.
1864fn block_bytes<'a>(source: &'a str, span: &Range<usize>) -> &'a [u8] {
1865    let bytes = source.as_bytes();
1866    let start = span.start.min(bytes.len());
1867    &bytes[start..span.end.clamp(start, bytes.len())]
1868}
1869
1870/// A fast, allocation-free content hash (FNV-1a) for a block's bytes. Weak by
1871/// design — the bytes are compared on a hit — so its only job is to spread
1872/// blocks across buckets cheaply. SipHash over every block's bytes on every
1873/// keystroke would cost more than it saves, the same lesson the shape cache
1874/// learned when it stopped hashing through the standard hasher.
1875fn block_hash(bytes: &[u8]) -> u64 {
1876    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1877    for &x in bytes {
1878        h ^= x as u64;
1879        h = h.wrapping_mul(0x0000_0100_0000_01b3);
1880    }
1881    h
1882}
1883
1884/// Clone a cached row with every source offset advanced by `delta` — the whole
1885/// cost of reusing an unchanged block: integer adds where a rebuild would
1886/// re-shape every glyph.
1887fn shift_row(row: &VRow, delta: isize) -> VRow {
1888    let shift = |off: usize| (off as isize + delta) as usize;
1889    VRow {
1890        glyphs: row
1891            .glyphs
1892            .iter()
1893            .map(|g| Glyph {
1894                ch: g.ch,
1895                style: g.style,
1896                src: shift(g.src),
1897                stop: g.stop,
1898            })
1899            .collect(),
1900        end_src: shift(row.end_src),
1901        decoration: row.decoration,
1902        code: row.code,
1903        code_lang: row.code_lang.clone(),
1904        directive: row.directive,
1905        directive_label: row.directive_label.clone(),
1906        media: row.media.clone(),
1907        // A tick, not an offset — reuse carries it as-is, like `code_lang`.
1908        task: row.task,
1909        leaf_directive: row.leaf_directive.clone(),
1910        heading: row.heading,
1911        // Structure, not offsets: a reused block's rows divide the same blocks
1912        // wherever the edit above moved them to.
1913        boundary: row.boundary,
1914    }
1915}
1916
1917/// Advance a row's source offsets by `delta` in place — the suffix half of
1918/// [`build_spliced`], where the rows are already owned and only need shifting,
1919/// not copying.
1920fn shift_row_in_place(row: &mut VRow, delta: isize) {
1921    for g in &mut row.glyphs {
1922        g.src = (g.src as isize + delta) as usize;
1923    }
1924    row.end_src = (row.end_src as isize + delta) as usize;
1925}
1926
1927/// Whether every source offset a block's rows carry falls inside the block's own
1928/// span — the precondition for reusing the block by a uniform offset shift. It
1929/// holds for well-formed blocks (their glyphs and row ends address bytes within
1930/// the block, synthetic glyphs point at the block start). It fails when a node
1931/// renders *outside* its block, which today means a malformed Markdown inline
1932/// node twig leaves with a degenerate `0..0` span: that content lands at a fixed
1933/// offset that doesn't move with the block. Such a block is re-rendered every
1934/// build instead of shifted, so the incremental map still matches a fresh one —
1935/// see [`build_cached`] and [`build_spliced`].
1936fn rows_within(rows: &[VRow], span: &Range<usize>) -> bool {
1937    rows.iter().all(|r| {
1938        r.end_src >= span.start
1939            && r.end_src <= span.end
1940            && r.glyphs
1941                .iter()
1942                .all(|g| g.src >= span.start && g.src <= span.end)
1943    })
1944}
1945
1946/// Where the rendered document begins when a leading `metadata` block is all
1947/// there is — the end of that hidden frontmatter, past the newline that closes
1948/// its last line so the floor sits at the start of the (empty) body rather than
1949/// on the closing `---`.
1950///
1951/// With a real block after it the frontmatter's end is never needed: the floor
1952/// is that block's start, and the rows begin there. With nothing after it, both
1953/// the caret floor and the trailing-blank-line count would otherwise fall back
1954/// to offset 0 — inside the hidden frontmatter — which put the caret *before*
1955/// the metadata and made typing land ahead of the opening `---`.
1956fn hidden_prefix_end(source: &str, meta_end: Option<usize>) -> usize {
1957    let Some(end) = meta_end else { return 0 };
1958    let end = end.min(source.len());
1959    let rest = &source[end..];
1960    if rest.starts_with("\r\n") {
1961        end + 2
1962    } else if rest.starts_with('\n') {
1963        end + 1
1964    } else {
1965        end
1966    }
1967}
1968
1969/// The end of the document's hidden frontmatter: the last `metadata` child of
1970/// `doc`, which is what [`top_level`] and [`Builder::blocks`] drop. `None` when
1971/// there is none.
1972fn metadata_end_of(nodes: &[FlatNode], doc: usize) -> Option<usize> {
1973    let mut end = None;
1974    let mut child = nodes[doc].first_child;
1975    while let Some(cid) = child {
1976        let n = &nodes[cid.0 as usize];
1977        if n.kind == Kind::Metadata {
1978            end = Some(n.span.end);
1979        }
1980        child = n.next_sibling;
1981    }
1982    end
1983}
1984
1985/// The document's rendered top-level blocks, as node indices in source order.
1986///
1987/// Not simply `doc`'s children, for two reasons. Frontmatter (a leading
1988/// `metadata` block) is document metadata rather than prose and is dropped, the
1989/// way [`Builder::blocks`] drops it. And a **footnote definition** (`[^1]: …`)
1990/// is not a child of `doc` at all: twig parses it as a root of its own, a
1991/// *sibling* of the document node with `parent == None`. A walk that starts at
1992/// `doc` therefore never reaches one, which is why a definition — and every
1993/// byte of its body — used to render as nothing at all. Merging the roots back
1994/// in by `span.start` puts each definition on screen exactly where it was
1995/// written, which is what keeps rows, stops, and offsets monotonic.
1996///
1997/// A **link reference definition** (`[foo]: /url`) is a root of the same kind,
1998/// and is merged for the opposite reason: it draws *nothing*, and the walk has
1999/// to know where it stands to step over it. A definition closing a README —
2000/// the `[links]: …` block under the prose — left no block over its lines, so
2001/// the separator logic read them as blank lines and drew an empty paragraph
2002/// per definition. Merged in, it is a hidden block like a comment, and
2003/// [`Builder::block_or_hidden`] moves the walk past it. One with no span
2004/// (`0..0`, what twig before 3.3.3 reported for every one) has nowhere to be
2005/// merged, and is left out as before.
2006///
2007/// Only those roots are merged. twig also leaves stray orphan `str` nodes
2008/// parented to nothing (the `*` of an emphasis run, for one); those are already
2009/// rendered as part of the subtree that owns their bytes, and re-emitting them
2010/// here would double them.
2011fn top_level(nodes: &[FlatNode], doc: usize) -> Vec<usize> {
2012    let mut out = Vec::new();
2013    let mut child = nodes[doc].first_child;
2014    while let Some(cid) = child {
2015        let n = &nodes[cid.0 as usize];
2016        if n.kind != Kind::Metadata {
2017            out.push(cid.0 as usize);
2018        }
2019        child = n.next_sibling;
2020    }
2021    out.extend(
2022        nodes
2023            .iter()
2024            .enumerate()
2025            .filter(|(_, n)| n.parent.is_none() && is_placed_definition(&n.kind, &n.span))
2026            .map(|(i, _)| i),
2027    );
2028    out.sort_by_key(|&i| nodes[i].span.start);
2029    out
2030}
2031
2032/// Is a parentless node of `kind` at `span` a definition the top-level walk
2033/// merges in — a footnote definition, or a link reference definition that
2034/// knows where it stands? Shared by [`top_level`] and [`top_blocks`] so the
2035/// two walks cannot disagree about what the top-level blocks are.
2036fn is_placed_definition(kind: &Kind, span: &Range<usize>) -> bool {
2037    match *kind {
2038        Kind::Footnote => true,
2039        Kind::Reference => span.end > span.start,
2040        _ => false,
2041    }
2042}
2043
2044/// The top-level blocks to hand [`build_cached`] / [`build_spliced`] — the
2045/// incremental path's twin of [`top_level`], which the two must agree with block
2046/// for block or the render paths diverge.
2047///
2048/// `child_spans(None)` gives `doc`'s children, which is all of them for an
2049/// ordinary document. A **footnote definition** is not one: twig parses `[^1]: …`
2050/// as a root beside `doc` with no parent, and indexes it at no offset either —
2051/// `node_at` inside its bytes answers `doc`, and a `query("footnote")` selector
2052/// finds nothing. Leaf used to discover them by marshalling the whole arena with
2053/// `nodes()` — the very cost the incremental path exists to avoid — behind a
2054/// byte-scan gate that gave documents with no `[^…]:` line a substring search
2055/// instead. twig 3.0's `definitions()` asks the library the question directly,
2056/// so both the marshal and the gate are gone.
2057///
2058/// The link reference definitions `definitions()` also reports are merged on
2059/// the same terms as [`top_level`] merges them — see [`is_placed_definition`].
2060///
2061/// This is the one part of the render that needs an [`Editor`] rather than a
2062/// marshalled node array. The builders themselves stay editor-free; this only
2063/// prepares their input.
2064pub(crate) fn top_blocks(editor: &mut Editor) -> Vec<QueryMatch> {
2065    let mut top = editor.child_spans(None).unwrap_or_default();
2066    let defs: Vec<QueryMatch> = definitions(editor)
2067        .into_iter()
2068        .filter(|m| is_placed_definition(&m.kind, &m.span))
2069        .collect();
2070    if defs.is_empty() {
2071        return top;
2072    }
2073    top.extend(defs);
2074    // Source order — what every offset-keyed thing downstream (rows, stops, the
2075    // splice path's block-for-block match) is built to assume.
2076    top.sort_by_key(|m| m.span.start);
2077    top
2078}
2079
2080/// Every `[^label]: …` definition in the document, in whatever order twig
2081/// reports them.
2082///
2083/// Filtered to [`Kind::Footnote`]: `definitions()` also reports the *link*
2084/// reference definitions (`[foo]: /url`), which are [`top_blocks`]'s business
2085/// and not [`crate::Doc::footnote_at_caret`]'s.
2086///
2087/// Empty when the document can't be walked, which leaves [`top_blocks`] with
2088/// the ordinary top-level children and [`crate::Doc::footnote_at_caret`] with an
2089/// undefined reference — in both cases the same answer as a document that has
2090/// no definitions, which is the right way to degrade.
2091pub(crate) fn footnote_definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2092    definitions(editor)
2093        .into_iter()
2094        .filter(|m| m.kind == Kind::Footnote)
2095        .collect()
2096}
2097
2098/// Every definition twig resolves by label rather than by position — footnote
2099/// and link reference definitions both — or nothing when the document can't be
2100/// walked.
2101fn definitions(editor: &mut Editor) -> Vec<QueryMatch> {
2102    let Ok(mut doc) = editor.document() else {
2103        return Vec::new();
2104    };
2105    doc.definitions().unwrap_or_default()
2106}
2107
2108/// The label of the footnote definition starting at `start` — the `1` in
2109/// `[^1]: …`. twig gives the `footnote` node no label of its own (no `text`, no
2110/// `name`), and the bytes that spell it belong to no child node either — the
2111/// body `para` starts its *content* past them — so the source is the only place
2112/// to read it from. `None` when what's there isn't a definition after all.
2113pub(crate) fn footnote_label(source: &str, start: usize) -> Option<&str> {
2114    let rest = source.get(start..)?.strip_prefix("[^")?;
2115    let end = rest.find("]:")?;
2116    Some(&rest[..end])
2117}
2118
2119/// Where the body of the footnote definition spanning `span` sits in `source` —
2120/// everything past the `[^1]:` marker, which is the part a reader actually wants
2121/// when they follow a reference.
2122///
2123/// Source bytes, verbatim but for the whitespace trimmed off each end: a note
2124/// that says `see *later*` answers with the asterisks in. Rendering that body is
2125/// a frontend's business the same way painting a [`Role`] is, and a caller that
2126/// wants it laid out already has the definition on screen where it was written.
2127///
2128/// The trim is what makes the common case read right — `[^1]: text` has a space
2129/// after the colon that belongs to the marker, not the note, and a definition's
2130/// span runs to the newline ending it.
2131///
2132/// The span is taken at its word, which it has only been safe to do since twig
2133/// 3.1: a djot definition's span used to run *past* its own last line, through
2134/// the blank line separating it from the next block and into that block's first
2135/// byte, so `[^2a]: a note.` came back as `"a note.\n\n["` and the offsets named
2136/// the following note's rows as well as this one's — a reader asking about one
2137/// footnote was shown two. leaf measured the body itself to get around that, and
2138/// paid for it: the scan stopped at the first blank line, so a note with a second
2139/// indented paragraph lost it. Both halves go away with the fix, since a blank
2140/// line *inside* a definition was always interior to the span and still is.
2141///
2142/// A range rather than a slice because "go to note" needs the *position* as much
2143/// as the text, and it needs the position of the body specifically: a
2144/// definition's `[^1]:` marker is decoration the caret can't occupy (the rich
2145/// view draws it as `[1] ` and gives it no stop), so aiming a caret at the
2146/// definition's first byte lands it on the nearest real stop instead — which is
2147/// up in the paragraph *above* the note. The body's first byte is a stop, and is
2148/// where a reader following a reference wants to arrive anyway.
2149pub(crate) fn footnote_body_span(source: &str, span: Range<usize>) -> Option<Range<usize>> {
2150    let rest = source.get(span.clone())?.strip_prefix("[^")?;
2151    let marker = rest.find("]:")?;
2152    // `span.start` + `[^` + the label + `]:`.
2153    let after_marker = span.start + 2 + marker + 2;
2154    let raw = source.get(after_marker..span.end)?;
2155    // Written as a start plus a length so an all-whitespace body lands on an
2156    // empty range at the end rather than an inverted one.
2157    let start = after_marker + (raw.len() - raw.trim_start().len());
2158    Some(start..start + raw.trim().len())
2159}
2160
2161/// The label of the footnote *reference* spanning `span` — the `1` in `[^1]`.
2162///
2163/// The peer of [`footnote_label`] for the other half of the pair, and needed for
2164/// the same reason: a reference whose node carries neither a `content_span` nor
2165/// a `text` still spells its label plainly in the source. `None` when the bytes
2166/// aren't a reference after all.
2167pub(crate) fn footnote_reference_label(source: &str, span: Range<usize>) -> Option<&str> {
2168    let rest = source.get(span)?.strip_prefix("[^")?;
2169    let end = rest.find(']')?;
2170    Some(&rest[..end])
2171}
2172
2173/// Where a heading's *content* starts — past the `#`s and the space the rich
2174/// view hides, for an ATX heading; the block's own start for a setext one (which
2175/// has no leading marker) and for a format that spells headings some other way.
2176///
2177/// Only an empty heading needs asking: with any content at all, the row ends on
2178/// its last glyph. Bounded to the heading's own first line so a marker-less
2179/// heading can't scan into the text under it.
2180fn heading_content_start(source: &str, span: &Range<usize>) -> usize {
2181    let end = span.end.min(source.len());
2182    let Some(line) = source.get(span.start..end) else {
2183        return span.start;
2184    };
2185    let line = line.split('\n').next().unwrap_or("");
2186    let hashes = line.len() - line.trim_start_matches('#').len();
2187    if hashes == 0 {
2188        return span.start;
2189    }
2190    let after = &line[hashes..];
2191    span.start + hashes + (after.len() - after.trim_start_matches([' ', '\t']).len())
2192}
2193
2194struct Builder<'a> {
2195    nodes: &'a [FlatNode],
2196    /// The document source, consulted to place blank-line rows at the source
2197    /// offsets the caret should occupy on them (the AST drops blank lines).
2198    source: &'a str,
2199    /// The word-wrap column budget, or `None` to emit each block as a single
2200    /// unwrapped row (the frontend wraps).
2201    wrap: Option<usize>,
2202    rows: Vec<VRow>,
2203    /// Built alongside `rows`, never instead of them — see [`TableInfo`].
2204    tables: Vec<TableInfo>,
2205    /// The end offset of the last content emitted — the anchor for blank
2206    /// separator rows so the caret never snaps onto one.
2207    last_off: usize,
2208    /// The end of the last block the walk stepped over without drawing — a
2209    /// comment, which the rich view hides. `last_off` moves past it too, for the
2210    /// separators; this is kept apart so the trailing blank lines can be counted
2211    /// from it without also being counted from a code block's closing fence,
2212    /// which `last_off` likewise ends after. `0` until a hidden block is met.
2213    stepped_over: usize,
2214    /// How many rows each block image reserves, keyed by its destination — the
2215    /// frontend's per-image height, threaded in from [`crate::Doc::set_media_rows`]
2216    /// so [`Builder::block_media`] can size the placeholder without core doing any
2217    /// I/O. A destination absent from the map (or a `0`/`1` entry) reserves the
2218    /// bare one-row placeholder, which is the whole-document default and what
2219    /// every existing test — passing an empty map — still gets.
2220    media_rows: &'a HashMap<String, usize>,
2221    /// The glyph a hard break renders as while the current inline run is built:
2222    /// a space in prose (a break folds into the flow the frontend wraps), but a
2223    /// newline (`\n`) inside a table cell, where a row is one source line and the
2224    /// only break it can carry is an explicit one that must show as a line of its
2225    /// own. Set around [`Builder::row_cells`] and otherwise left at `' '`.
2226    break_glyph: Cell<char>,
2227    /// Render a soft break (a bare newline inside a paragraph) as a line break
2228    /// where it was written, rather than folding it into the reflowed paragraph
2229    /// — the `LineFlow::Preserve` behaviour. A soft break emits a `'\n'` glyph
2230    /// (like a hard break in a cell), which [`Builder::emit_wrapped`] turns into
2231    /// a fresh visual row. `false` is the flowing-prose default. Inside a table
2232    /// cell (where `break_glyph` is already `'\n'`) it has no effect: a cell is
2233    /// one line and folds its own soft breaks regardless.
2234    preserve_soft: bool,
2235    /// The source byte range of the one line that should render its markup
2236    /// *raw* — the caret's line under `MarkupMode::Full` (see
2237    /// [`crate::Doc::reveal_line`]). `None` in every other mode and view, which
2238    /// is the delimiters-always-hidden behaviour every build had before the
2239    /// preference existed.
2240    ///
2241    /// Read only by [`Builder::revealed`], which every delimiter-bearing arm of
2242    /// [`Builder::inline`] consults. A range rather than a bare caret offset
2243    /// because the decision is per-*node*, not per-caret: a node is revealed
2244    /// when its span meets this line, so `*em*` shows both its asterisks even
2245    /// with the caret at one end of it.
2246    reveal: Option<Range<usize>>,
2247}
2248
2249impl Builder<'_> {
2250    /// Whether `span` belongs to the line that is showing its raw markup. True
2251    /// only when a reveal line is set (`MarkupMode::Full`) and the two ranges
2252    /// actually meet.
2253    ///
2254    /// Touching at an endpoint counts: an emphasis ending exactly where the line
2255    /// does is on that line, and a zero-length reveal range (the caret alone on
2256    /// a blank line) still meets a node that starts there. The test is
2257    /// deliberately generous — the failure it avoids is revealing one delimiter
2258    /// of a pair while hiding the other, which looks like corruption rather than
2259    /// like markup.
2260    fn revealed(&self, span: &Range<usize>) -> bool {
2261        self.reveal
2262            .as_ref()
2263            .is_some_and(|r| span.start <= r.end && r.start <= span.end)
2264    }
2265
2266    /// The `(opening, closing)` source byte ranges of a node's delimiters — the
2267    /// bytes its `span` holds that its `content_span` doesn't.
2268    ///
2269    /// This is how *every* inline delimiter is recovered, rather than a table of
2270    /// spellings per kind: twig gives `*em*` a span of `13..17` and a content
2271    /// span of `14..16`, so the gaps at each end are the delimiters, whatever
2272    /// they happen to be. That matters because one kind has many spellings —
2273    /// `*em*` and `_em_` are both emphasis, `` `x` `` and ``` ``x`` ``` both
2274    /// verbatim — and re-deriving the text from the source is the only way to
2275    /// show back what the author actually typed. It also gets a link's
2276    /// asymmetric `[` / `](dest)` right for free.
2277    ///
2278    /// `None` when the node has no content span, or when content and span
2279    /// coincide (nothing was elided, so there is nothing to reveal).
2280    fn delims(&self, id: usize) -> Option<(Range<usize>, Range<usize>)> {
2281        let node = &self.nodes[id];
2282        let content = node.content_span.clone()?;
2283        let span = node.span.clone();
2284        // A content span that escapes its own node's span means the two are
2285        // describing different things; reveal nothing rather than slice wildly.
2286        if content.start < span.start || content.end > span.end {
2287            return None;
2288        }
2289        let (open, close) = (span.start..content.start, content.end..span.end);
2290        // A delimiter that spans a newline isn't this line's to reveal — a setext
2291        // heading's `\n=====` underline is the case that arises in practice. It
2292        // would also inject a `'\n'` glyph, which `emit_wrapped` reads as a hard
2293        // row break, so the row would split where the author wrote no break.
2294        let multiline =
2295            |r: &Range<usize>| self.source.get(r.clone()).is_some_and(|s| s.contains('\n'));
2296        if multiline(&open) || multiline(&close) {
2297            return None;
2298        }
2299        (!open.is_empty() || !close.is_empty()).then_some((open, close))
2300    }
2301
2302    /// Emit the source bytes of `range` as revealed markup — real glyphs, each
2303    /// mapped to its own source byte and each a caret stop, so a delimiter shown
2304    /// is a delimiter that can be selected, edited and deleted like any other
2305    /// text. Styled [`Role::Delimiter`] on top of the run's own style, which is
2306    /// how a frontend tells scaffolding from prose and dims it.
2307    ///
2308    /// Deliberately *not* [`push_escaped_text`]: this is raw source, not parsed
2309    /// text, so there is no escape-driven drift between the two to correct.
2310    fn push_delim(&self, out: &mut Vec<Glyph>, range: &Range<usize>, base: Style) {
2311        let Some(text) = self.source.get(range.clone()) else {
2312            return;
2313        };
2314        push_text(out, text, range.start, base.role(Role::Delimiter));
2315    }
2316
2317    /// Render an inline node's children wrapped in its raw delimiters when the
2318    /// node is on the revealed line, and bare (delimiters resolved away) when it
2319    /// isn't — the shared body of every delimiter-bearing arm of
2320    /// [`inline`](Self::inline).
2321    ///
2322    /// `style` is the resolved styling the content still gets in *both* modes:
2323    /// revealing `*em*` shows the asterisks *and* keeps the text italic, the
2324    /// live-preview behaviour. Showing the markup is not the same as turning the
2325    /// rendering off — that is what [`crate::View::Source`] is for.
2326    fn inline_delimited(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
2327        let show = self
2328            .revealed(&self.nodes[id].span)
2329            .then(|| self.delims(id))
2330            .flatten();
2331        if let Some((open, _)) = &show {
2332            self.push_delim(out, open, style);
2333        }
2334        self.recurse(id, style, out);
2335        if let Some((_, close)) = &show {
2336            self.push_delim(out, close, style);
2337        }
2338    }
2339
2340    fn children(&self, id: usize) -> Vec<usize> {
2341        let mut out = Vec::new();
2342        let mut c = self.nodes[id].first_child;
2343        while let Some(cid) = c {
2344            out.push(cid.0 as usize);
2345            c = self.nodes[cid.0 as usize].next_sibling;
2346        }
2347        out
2348    }
2349
2350    /// Render a node's block children, a blank separator between each. `tight`
2351    /// suppresses the *fabricated* separator between adjacent children that share
2352    /// a source line boundary — a tight list item and the sub-list nested in it —
2353    /// while a real blank source line between them still opens a gap.
2354    fn blocks(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph], tight: bool) {
2355        // Frontmatter (a leading `metadata` block) is document metadata, not
2356        // prose: hide it entirely in the rich-text view. Skipping it here means
2357        // no phantom blank rows for its lines and no separator before the first
2358        // real block — the document opens straight into its content.
2359        let kids: Vec<usize> = self
2360            .children(id)
2361            .into_iter()
2362            .filter(|&c| self.nodes[c].kind != Kind::Metadata)
2363            .collect();
2364        let mut above: Option<BlockClass> = None;
2365        for child in kids {
2366            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2367            let before_sep = self.rows.len();
2368            if let Some(above) = above {
2369                self.emit_separators_before(
2370                    self.nodes[child].span.start,
2371                    pc,
2372                    !tight,
2373                    Boundary { above, below },
2374                );
2375            }
2376            // The first *drawn* child wears the first-row prefix (a bullet, a
2377            // footnote label), not the first child: a comment opening a list
2378            // item draws nothing, and the bullet belongs to what follows it.
2379            let first = if above.is_none() { pf } else { pc };
2380            if self.block_or_hidden(child, before_sep, first, pc) {
2381                above = Some(below);
2382            }
2383        }
2384    }
2385
2386    /// Render `child` after the separator [`Builder::emit_separators_before`]
2387    /// spelled for it from row `before_sep` on, and say whether it drew
2388    /// anything.
2389    ///
2390    /// A block that draws no rows — an HTML comment, which the rich view hides
2391    /// the way it hides frontmatter — is still *there* in the source, and the
2392    /// walk has to step over it: `last_off` moves past it so the next separator
2393    /// counts the blank lines from its end, not from wherever the last drawn
2394    /// block stopped. Left where it was, the separator counted every line of the
2395    /// comment as a blank row; and the cached path, whose per-block builder
2396    /// starts at offset 0, handed back a `last_off` of 0 and counted every line
2397    /// of the *document* — one phantom blank row per source line, once per
2398    /// comment. The separator drawn for it is taken back too, so a hidden block
2399    /// leaves no gap of its own: what stands either side of it meets across one
2400    /// boundary, as if the comment were not there.
2401    fn block_or_hidden(
2402        &mut self,
2403        child: usize,
2404        before_sep: usize,
2405        pf: &[Glyph],
2406        pc: &[Glyph],
2407    ) -> bool {
2408        let after_sep = self.rows.len();
2409        self.block(child, pf, pc);
2410        if self.rows.len() > after_sep {
2411            return true;
2412        }
2413        self.rows.truncate(before_sep);
2414        let end = self.nodes[child].span.end;
2415        self.last_off = self.last_off.max(end);
2416        self.stepped_over = self.stepped_over.max(end);
2417        false
2418    }
2419
2420    /// Render an explicit, ordered list of top-level blocks — [`Builder::blocks`]
2421    /// for a walk that isn't "the children of one node". The document's top level
2422    /// no longer is: a footnote definition is a root beside `doc`, not under it,
2423    /// and [`top_level`] merges it into this list by source position.
2424    ///
2425    /// The separator between blocks is spelled by the same
2426    /// [`Builder::emit_separators_before`] the incremental top-level walk in
2427    /// [`build_cached`] uses, so the two paths can't drift on how a boundary
2428    /// looks.
2429    ///
2430    /// Returns the class of the last block that drew anything — what the
2431    /// trailing blank lines close — or `None` when nothing did.
2432    fn top_blocks(&mut self, ids: &[usize]) -> Option<BlockClass> {
2433        let mut above: Option<BlockClass> = None;
2434        for &child in ids {
2435            let below = BlockClass::from_node_kind(&self.nodes[child].kind);
2436            let before_sep = self.rows.len();
2437            if let Some(above) = above {
2438                self.emit_separators_before(
2439                    self.nodes[child].span.start,
2440                    &[],
2441                    true,
2442                    Boundary { above, below },
2443                );
2444            }
2445            if self.block_or_hidden(child, before_sep, &[], &[]) {
2446                above = Some(below);
2447            }
2448        }
2449        above
2450    }
2451
2452    /// Emit the blank separator row(s) that sit between a block ending at the
2453    /// current `last_off` and the next block starting at `next_start`, wearing
2454    /// the continuation prefix `pc`. Shared by [`Builder::blocks`] and the
2455    /// incremental top-level walk so the two can't drift on how a boundary is
2456    /// spelled.
2457    ///
2458    /// The blank line(s) between two blocks are real caret stops, each needing
2459    /// its *own* source offset — one strictly past the previous block's content,
2460    /// else it collides with that block's last row and `pos_of_offset`
2461    /// (first-match-wins) would resolve the caret onto the wrong row, pinning
2462    /// downward motion there.
2463    ///
2464    /// One row *per* blank source line, not a single collapsed separator: an
2465    /// empty paragraph opened between two blocks (Enter in the gap,
2466    /// `…\n\n\n\n…`) must be a navigable empty row, not vanish — else the caret
2467    /// in it snaps onto the *next* block's start and Enter looks like it did
2468    /// nothing.
2469    fn emit_separators_before(
2470        &mut self,
2471        next_start: usize,
2472        pc: &[Glyph],
2473        synthetic: bool,
2474        boundary: Boundary,
2475    ) {
2476        let mut offs = self.blank_rows_between(self.last_off, next_start);
2477        if offs.is_empty() {
2478            if !synthetic {
2479                // A tight list item's own text sits directly above the sub-list
2480                // nested in it — no fabricated gap. The "breathe" row belongs
2481                // between free-standing blocks, not between an item and its
2482                // child list, which the source writes on the very next line. A
2483                // real blank source line (a loose list) still lands a gap below,
2484                // because `blank_rows_between` found it and we never reach here.
2485                return;
2486            }
2487            // A tight gap with no blank line (e.g. a heading directly above its
2488            // text): keep the one conventional separator row so blocks still
2489            // breathe, as they always have.
2490            offs.push(self.blank_line_offset(self.last_off, next_start));
2491        }
2492        let last = offs.len() - 1;
2493        for (k, end_src) in offs.into_iter().enumerate() {
2494            // Only the drawn-only rows carry the boundary: the navigable blank
2495            // lines between them (and every blank line under preserve-soft flow)
2496            // are somewhere text can go, not a gap between blocks, and a frontend
2497            // that shrank one would be shrinking a line the author is typing on.
2498            let drawn = !self.preserve_soft && (k == 0 || k == last);
2499            // The blank line a boundary is *drawn* with isn't a place text can
2500            // go. The first one closes the block above and the last one opens the
2501            // block below — with a single blank line, the usual case, doing both
2502            // at once. Typing on either just continues the paragraph it abuts,
2503            // since the blank line it would need to be a paragraph of its own is
2504            // the very line being typed on. So they're a gap, like a table's
2505            // border: drawn, clickable, never a caret's home.
2506            //
2507            // The lines *between* them are the real ones. That's what Enter
2508            // opens: it inserts a paragraph break (`\n\n`), which leaves a blank
2509            // line spare on each side and the caret on the navigable line
2510            // between them.
2511            //
2512            // Preserve flow is the exception: there a bare `\n` is a visible line
2513            // break the author edits directly, so a lone blank line *is* a caret
2514            // home — typing on it makes the soft break the mode exists to show,
2515            // and Enter at a line's end lands the caret on exactly this row. So no
2516            // separator is drawn-only; every blank line is navigable.
2517            self.rows.push(VRow {
2518                glyphs: pc.to_vec(),
2519                end_src,
2520                decoration: drawn,
2521                code: false,
2522                code_lang: None,
2523                directive: false,
2524                directive_label: None,
2525                media: None,
2526                task: None,
2527                leaf_directive: None,
2528                heading: None,
2529                boundary: drawn.then_some(boundary),
2530            });
2531        }
2532    }
2533
2534    fn block(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2535        let node = &self.nodes[id];
2536        match node.kind.as_str() {
2537            "doc" | "section" => self.blocks(id, pf, pc, false),
2538            "heading" => {
2539                // A heading whose only visible content is a single image — a
2540                // banner set in an `<h1>` (`<h1><picture><img></picture></h1>`),
2541                // or `# ![](banner.png)` — is a block picture, not text. Render
2542                // it as one; anything with real heading text falls through.
2543                if let Some((m, kind)) = self.media_only(id) {
2544                    self.block_media(m, kind, id, pf);
2545                    return;
2546                }
2547                let level = node.level.unwrap_or(1);
2548                let style = heading_style(level);
2549                let mut glyphs = Vec::new();
2550                // On the revealed line the `# ` comes back as real, editable
2551                // text in front of the heading. Only the opening marker: a
2552                // closing `#`-run (`## title ##`) is covered by the same
2553                // `delims` pair, and a setext underline is excluded there for
2554                // being on another line entirely.
2555                if let Some((open, close)) =
2556                    self.revealed(&node.span).then(|| self.delims(id)).flatten()
2557                {
2558                    self.push_delim(&mut glyphs, &open, style);
2559                    glyphs.extend(self.inline_children_with_trailing(id, style));
2560                    self.push_delim(&mut glyphs, &close, style);
2561                } else {
2562                    glyphs = self.inline_children_with_trailing(id, style);
2563                }
2564                // An *empty* heading — `# ` with nothing typed after it, which is
2565                // what the toolbar's H1 leaves on a blank line — has no glyph for
2566                // its row to end on, so the fallback below is the row's whole
2567                // extent: its only caret stop, and the offset every row after it
2568                // is measured from. The block's start is the wrong answer for
2569                // both, because it sits *in front of* the `# ` the rich view
2570                // hides: the caret drew (and typed) before the hashes, and the
2571                // rows below inherited an offset short by the marker's length,
2572                // which put the caret on one of them the moment the heading grew
2573                // text. Its content's start is where the caret belongs.
2574                let home = heading_content_start(self.source, &node.span);
2575                let first = self.rows.len();
2576                self.emit_wrapped(glyphs, home, pf, pc);
2577                // Stamp the level on every row the heading just emitted — a
2578                // wrapped heading's continuation rows as much as its first, and
2579                // an empty one's single glyphless row, which is the whole point
2580                // (see [`VRow::heading`]).
2581                for row in &mut self.rows[first..] {
2582                    row.heading = Some(level.min(255) as u8);
2583                }
2584            }
2585            "block_quote" => {
2586                let (start, end) = (node.span.start, node.span.end);
2587                let gutter = synth("│ ", Role::QuoteGutter, start);
2588                let f = concat(pf, &gutter);
2589                let c = concat(pc, &gutter);
2590                // A childless quote — a bare `> ` on an otherwise blank line,
2591                // which is what the toolbar's Quote button leaves there — has no
2592                // inner block to carry the gutter or a caret home, so `blocks`
2593                // emitted *nothing at all*: the quote didn't merely draw
2594                // unstyled, it disappeared, and a document that was only `> `
2595                // rendered zero rows with the caret nowhere to stand. Emit the
2596                // gutter row itself, ending just past the marker, exactly as an
2597                // empty `list_item` emits its bare bullet.
2598                if self.children(id).is_empty() {
2599                    self.push_row_at(f, end.min(self.source.len()));
2600                } else {
2601                    self.blocks(id, &f, &c, false);
2602                    self.emit_quote_trailing_lines(&c, end);
2603                }
2604            }
2605            // A generic `:::name{.class}` fenced-div container (twig's
2606            // `directive`, container form). Core is agnostic of `name` — it's
2607            // the host app's vocabulary (diaryx's `vis` for audience
2608            // visibility, say) and isn't available here regardless: twig only
2609            // threads an `element`'s tag name through `FlatNode::name`, not a
2610            // directive's own identifier. Every row gets marked `directive` (a
2611            // frontend draws a tinted panel around each maximal run, the
2612            // `code`/`code_block` recipe) and the first row carries a label —
2613            // the way a code fence's language rides only its first row.
2614            //
2615            // The label reads BOTH attribute conventions diaryx content
2616            // actually uses: twig's own dot-prefixed classes (`{.public
2617            // .family}`, one combined `class` attr) and bare pandoc-style
2618            // words with no leading dot (`{public family}` — the syntax
2619            // `diaryx_core::visibility`'s hand-rolled publish-time filter and
2620            // apps/web's directive serializer both write; twig parses each
2621            // bare word as its own attribute with an empty value, per
2622            // `languages/markdown/attributes.zig`). Reading only `.class`
2623            // would leave every *existing* diaryx `:::vis{...}` block
2624            // unlabeled.
2625            // Only the *container* form is the panel below. A `text` directive
2626            // is inline and never reaches the block walker (see `is_inline`); a
2627            // `leaf` one is a standalone block with no body, drawn as a
2628            // placeholder the way an image is.
2629            "container"
2630                if container_is_directive(node)
2631                    && node.directive_form == Some(DirectiveForm::Leaf) =>
2632            {
2633                self.block_directive(id, pf);
2634            }
2635            "container" if container_is_directive(node) => {
2636                let label = directive_attr_label(&node.attrs);
2637                let start_row = self.rows.len();
2638                self.blocks(id, pf, pc, false);
2639                for (i, row) in self.rows[start_row..].iter_mut().enumerate() {
2640                    row.directive = true;
2641                    if i == 0 {
2642                        row.directive_label = label.clone();
2643                    }
2644                }
2645                // Anchor the block's end past its closing `:::` fence, exactly as
2646                // the code-block arm anchors past its ```` ``` ````. A container's
2647                // last content row ends at its last *child*, before the fence and
2648                // the blank line under it, so the separator logic counted the
2649                // fence line as a blank row of its own and drew a second boundary
2650                // — one gap's worth of margin twice, under every fenced div.
2651                self.last_off = node.span.end;
2652            }
2653            "bullet_list" | "ordered_list" | "task_list" => {
2654                let ordered = node.kind == Kind::OrderedList;
2655                let mut item_no = 0usize;
2656                let kids = self.children(id);
2657                for (i, child) in kids.iter().copied().enumerate() {
2658                    let kind = &self.nodes[child].kind;
2659                    if *kind == Kind::ListItem || *kind == Kind::TaskListItem {
2660                        let start = self.nodes[child].span.start;
2661                        item_no += 1;
2662                        // A task item's box replaces the bullet rather than
2663                        // joining it. The `[ ] ` that spells it is markup twig
2664                        // has already consumed — the item's paragraph *content*
2665                        // starts past it — so without a drawn box a task item
2666                        // was indistinguishable from a plain bullet, ticked or
2667                        // not. `☐`/`☑` is the marker for the same reason `•` is:
2668                        // it stands where the source's own marker stands. Which
2669                        // way it faces is `checked`, straight off the node.
2670                        let checked = self.nodes[child].checked;
2671                        let marker = match (checked, ordered) {
2672                            (Some(true), _) => "☑ ".to_string(),
2673                            (Some(false), _) => "☐ ".to_string(),
2674                            (None, true) => format!("{item_no}. "),
2675                            (None, false) => "• ".to_string(),
2676                        };
2677                        let bullet = synth(&marker, Role::ListMarker, start);
2678                        let indent = synth(&" ".repeat(text_width(&marker)), Role::Body, start);
2679                        let first_row = self.rows.len();
2680                        self.block(child, &concat(pc, &bullet), &concat(pc, &indent));
2681                        // On the item's first row, the way `code_lang` rides the
2682                        // first row of its block.
2683                        if let (Some(c), Some(row)) = (checked, self.rows.get_mut(first_row)) {
2684                            row.task = Some(c);
2685                        }
2686                    } else {
2687                        // twig can nest a *following* top-level block as a direct
2688                        // child of the list rather than a sibling of it — e.g.
2689                        // `- item\n\n> quote` parses the block quote under the
2690                        // `bullet_list`. It isn't a list item, so render it de-nested:
2691                        // no bullet, at the list's own prefix, with the usual block
2692                        // separator — never `• │ quote`.
2693                        if i > 0 {
2694                            self.emit_separators_before(
2695                                self.nodes[child].span.start,
2696                                pc,
2697                                true,
2698                                Boundary {
2699                                    above: BlockClass::from_node_kind(
2700                                        &self.nodes[kids[i - 1]].kind,
2701                                    ),
2702                                    below: BlockClass::from_node_kind(&self.nodes[child].kind),
2703                                },
2704                            );
2705                        }
2706                        self.block(child, pc, pc);
2707                    }
2708                }
2709            }
2710            "list_item" | "task_list_item" => {
2711                // A childless item — the empty bullet you get the instant you
2712                // press Enter to open a new one — has no inner block to carry the
2713                // marker prefix or a caret home, so `blocks` would emit nothing
2714                // and the new bullet simply wouldn't appear until something was
2715                // typed into it. Emit the prefixed row itself, ending at a caret
2716                // stop just past the marker (the item's `span.end`), the way an
2717                // empty paragraph emits its one prefixed row via `emit_wrapped`.
2718                if self.children(id).is_empty() {
2719                    let home = self.nodes[id].span.end.min(self.source.len());
2720                    self.push_row_at(pf.to_vec(), home);
2721                } else {
2722                    // Tight: an item's text and the list nested under it butt
2723                    // together (`• a` / `  • b`), no fabricated blank row between —
2724                    // a loose item's real blank line still parts them.
2725                    self.blocks(id, pf, pc, true);
2726                }
2727            }
2728            // A footnote *definition* (`[^1]: the note`). It reaches this walker
2729            // only because [`top_level`] merges it back in — twig hangs it off no
2730            // parent at all, so a walk from `doc` never sees one and every byte
2731            // of its body used to render as nothing.
2732            //
2733            // Drawn as a hanging-indent item, the way a list item is: the marker
2734            // reads `[1] `, matching the `[1]` its references render as, so the
2735            // two can be paired by eye, and the body wraps under it. The marker
2736            // is synthetic decoration (one shared offset, never a caret stop) —
2737            // the `[^1]: ` that spells it in the source is markup, hidden like a
2738            // heading's `# `.
2739            "footnote" => {
2740                let (start, end) = (node.span.start, node.span.end);
2741                let source = self.source;
2742                let marker = format!("[{}] ", footnote_label(source, start).unwrap_or(""));
2743                let indent = " ".repeat(text_width(&marker));
2744                let f = concat(pf, &synth(&marker, Role::ListMarker, start));
2745                let c = concat(pc, &synth(&indent, Role::Body, start));
2746                if self.children(id).is_empty() {
2747                    // A definition with no body yet — the instant `[^1]: ` has
2748                    // been typed and nothing after it. `blocks` would emit
2749                    // nothing and the definition simply wouldn't appear, so emit
2750                    // the marker row itself with a caret home just past it,
2751                    // exactly as an empty list item does.
2752                    self.push_row_at(f, end.min(source.len()));
2753                } else {
2754                    self.blocks(id, &f, &c, false);
2755                }
2756            }
2757            // A link reference definition (`[foo]: /url`): resolved by label
2758            // into the links that use it, and drawn nowhere — the rich view has
2759            // no more use for its line than for a comment's. It is walked at all
2760            // (see [`top_level`]) so [`Builder::block_or_hidden`] can step the
2761            // walk past its bytes rather than count them as blank lines.
2762            "reference" => {}
2763            "table" => self.table(id, pf, pc),
2764            "code_block" => {
2765                let style = Style::default().role(Role::Code);
2766                let text = node.text.clone().unwrap_or_default();
2767                // Cut the block's *terminator*, not every trailing newline. A
2768                // block whose last line is empty spells that as a second `\n`,
2769                // and `trim_end_matches` ate it along with the terminator: the
2770                // Return that made the line got no row, so the caret placed on
2771                // it fell through to the paragraph below and typing landed
2772                // outside the block. twig's `content_span` is `text` less
2773                // exactly this one newline, so cutting one and no more is also
2774                // what keeps `code_line_offsets` lined up.
2775                let lines: Vec<&str> = text
2776                    .strip_suffix('\n')
2777                    .unwrap_or(text.as_str())
2778                    .split('\n')
2779                    .collect();
2780                // Each line at its own source offset, so the caret can walk the
2781                // code a character at a time like any other text. Where the
2782                // lines can't be lined up with the source there's no honest
2783                // offset to give, so the block maps coarsely to its start (and
2784                // stays a source-view job, as all of it once was).
2785                let offs = node
2786                    .content_span
2787                    .as_ref()
2788                    .and_then(|c| self.code_line_offsets(c, &lines));
2789                // The fence's info string, carried on the block's first row as
2790                // its language label (`None` for an indented block or a bare
2791                // fence). Kept on the row so it rides the block cache.
2792                let lang = code_language(self.source, node.span.start);
2793                // The block's syntax highlighting, a token per byte range of
2794                // each line — `None` unless the fence names a language the
2795                // grammars know (and unless the `syntax` feature is on). Done
2796                // here, once per build of the block, because the rows it
2797                // colours ride the block cache: an edit elsewhere in the
2798                // document reuses them, tokens and all.
2799                let tokens = lang.as_deref().and_then(|l| code_tokens(l, &lines));
2800                for (i, raw) in lines.iter().enumerate() {
2801                    let at = offs.as_ref().map_or(node.span.start, |o| o[i]);
2802                    // No gutter glyph: the block is set apart by the border and
2803                    // tint a frontend draws around the whole run of `code` rows,
2804                    // not by a per-line mark. Just the block prefix (a list
2805                    // indent, a quote gutter) and the code text.
2806                    let mut glyphs: Vec<Glyph> = pf.to_vec();
2807                    match tokens.as_ref().and_then(|t| t.get(i)) {
2808                        Some(spans) => push_code_text(&mut glyphs, raw, at, style, spans),
2809                        None => push_text(&mut glyphs, raw, at, style),
2810                    }
2811                    // Explicitly past the line's *text*: a blank code line has no
2812                    // glyph, and any prefix's offset would put the row's end
2813                    // inside the next line.
2814                    self.push_row_at(glyphs, at + raw.len());
2815                    if let Some(row) = self.rows.last_mut() {
2816                        row.code = true;
2817                        if i == 0 {
2818                            row.code_lang = lang.clone();
2819                        }
2820                    }
2821                }
2822                // Anchor the block's end past its closing fence. Its last content
2823                // row ends at the last code line, before the ``` and the blank
2824                // line under it; without this the separator logic would count the
2825                // closing-fence line as its own blank row and open a phantom
2826                // second gap below the block.
2827                self.last_off = node.span.end;
2828            }
2829            "thematic_break" => {
2830                let full = self.wrap.unwrap_or(UNWRAPPED_RULE_WIDTH);
2831                let w = full.saturating_sub(prefix_width(pf)).max(4);
2832                let mut glyphs = pf.to_vec();
2833                for _ in 0..w {
2834                    glyphs.push(Glyph {
2835                        ch: '─',
2836                        style: Style::default().role(Role::Rule),
2837                        src: node.span.start,
2838                        // A rule is a block the caret can sit on, as it always
2839                        // has; it maps coarsely to the block's start.
2840                        stop: true,
2841                    });
2842                }
2843                // The dashes share one caret home in front of the atomic block,
2844                // while the row's end is the second home just past its source.
2845                // Without that trailing stop a final rule made the document end
2846                // unreachable: Right could not cross it and a click in the
2847                // empty space below it snapped back before the rule.
2848                let after_line = node.span.end
2849                    + self.source[node.span.end..]
2850                        .strip_prefix("\r\n")
2851                        .map_or_else(
2852                            || usize::from(self.source[node.span.end..].starts_with('\n')),
2853                            |_| 2,
2854                        );
2855                self.push_row_at(glyphs, after_line);
2856            }
2857            // A block-level image node with no wrapping paragraph — a promoted
2858            // top-level HTML `<img>` lands as a direct `doc` child like this
2859            // (a Markdown `![](…)` comes wrapped in a `para`, handled below).
2860            "image" => self.block_media(id, MediaKind::Image, id, pf),
2861            // The same case for a promoted top-level `<video>`/`<audio>`, which
2862            // arrives as a generic `container` rather than a node kind of its
2863            // own. It can't be found by the `media_only` scan below the way a
2864            // wrapped one is: that scan looks at a wrapper's *children*, and here
2865            // the media element is itself the block.
2866            "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
2867                let kind = match element_tag(node) {
2868                    Some("audio") => MediaKind::Audio,
2869                    _ => MediaKind::Video,
2870                };
2871                self.block_media(id, kind, id, pf);
2872            }
2873            _ => {
2874                // A container of blocks, or an inline-bearing paragraph.
2875                let kids = self.children(id);
2876                // A block-level image: a paragraph (or other wrapper — a
2877                // `<picture>`, an `<h1>` banner) whose only visible content is a
2878                // single `image` node. Render it as a placeholder row + record an
2879                // [`MediaInfo`] a capable frontend replaces. An image mixed with
2880                // real text or other images on the line isn't block-level and
2881                // falls through to the inline path below, still as its alt text.
2882                if let Some((m, kind)) = self.media_only(id) {
2883                    self.block_media(m, kind, id, pf);
2884                    return;
2885                }
2886                let inline = !kids.is_empty() && kids.iter().all(|&c| is_inline(&self.nodes[c]));
2887                if inline || kids.is_empty() {
2888                    let glyphs = self.inline_children_with_trailing(id, Style::default());
2889                    if !glyphs.is_empty() {
2890                        self.emit_wrapped(glyphs, node.span.start, pf, pc);
2891                    }
2892                } else {
2893                    self.blocks(id, pf, pc, false);
2894                }
2895            }
2896        }
2897    }
2898
2899    /// Render a table as a box-drawn grid: every column as wide as its widest
2900    /// cell, the header bold and ruled off, each cell padded to its column's
2901    /// alignment. This is the *default* monospace rendering (see
2902    /// [`VisualMap::rows`]); the same cells are also published structurally as
2903    /// [`TableInfo`], so a frontend that lays the grid out in its own units draws
2904    /// from there and skips the picture built here.
2905    ///
2906    /// The alignment comes from twig's `cell.alignment` — the delimiter row
2907    /// (`|:--|--:|`) that spells it out is consumed by the parser and leaves no
2908    /// node, so the snapshot is the only source for it.
2909    ///
2910    /// Borders and padding are *decoration*: they carry the source offset of the
2911    /// text they surround, so a click lands in that cell, but they're never
2912    /// caret stops — the caret steps cell-to-cell instead of into the box art.
2913    fn table(&mut self, id: usize, pf: &[Glyph], pc: &[Glyph]) {
2914        let node_end = self.nodes[id].span.end;
2915        // twig's shape is `[caption, row, row, …]`: the caption is always
2916        // present (usually empty in Markdown) and is not part of the grid.
2917        let row_ids: Vec<usize> = self
2918            .children(id)
2919            .into_iter()
2920            .filter(|&c| self.nodes[c].kind == Kind::Row)
2921            .collect();
2922        if row_ids.is_empty() {
2923            return;
2924        }
2925        // Lay every cell out first — the column widths depend on all of them.
2926        let grid: Vec<Vec<TableCell>> = row_ids.iter().map(|&r| self.row_cells(r)).collect();
2927        let heads: Vec<bool> = row_ids
2928            .iter()
2929            .map(|&r| self.nodes[r].head.unwrap_or(false))
2930            .collect();
2931        let cols = grid.iter().map(|r| r.len()).max().unwrap_or(0);
2932        if cols == 0 {
2933            return;
2934        }
2935        let mut widths = vec![0usize; cols];
2936        for row in &grid {
2937            for (c, cell) in row.iter().enumerate() {
2938                widths[c] = widths[c].max(cell_width(&cell.glyphs));
2939            }
2940        }
2941        // Every column at its widest cell is only the *wish*; a grid wider than
2942        // the surface has its far side hanging off the edge where no amount of
2943        // caret motion can reach it. Cut it down to what's actually there, and
2944        // let the cells wrap into the space they're given.
2945        if let Some(w) = self.wrap {
2946            fit_widths(&mut widths, w.saturating_sub(prefix_width(pc)));
2947        }
2948
2949        // Where the picture starts, so a frontend drawing its own grid knows
2950        // which rows to skip. Recorded before the first border goes down.
2951        let rows_start = self.rows.len();
2952
2953        let anchor = grid[0].first().map(|c| c.start).unwrap_or(node_end);
2954        self.push_rule(&rule_text(&widths, '┌', '┬', '┐'), anchor, pf);
2955        for (ri, row) in grid.iter().enumerate() {
2956            self.push_table_row(row, &widths, pc);
2957            // The rule under the header: only where the head actually ends.
2958            let ends_head = heads[ri] && heads.get(ri + 1) == Some(&false);
2959            if ends_head {
2960                let next = grid[ri + 1].first().map(|c| c.start).unwrap_or(node_end);
2961                self.push_rule(&rule_text(&widths, '├', '┼', '┤'), next, pc);
2962            }
2963        }
2964        self.push_rule(&rule_text(&widths, '└', '┴', '┘'), node_end, pc);
2965
2966        // The same cells the picture above was drawn from, published unwrapped
2967        // and unpadded for a frontend that lays them out in pixels.
2968        self.tables.push(TableInfo {
2969            rows_span: rows_start..self.rows.len(),
2970            end_src: node_end,
2971            // The *continuation* prefix: `pf` opens the block and only its first
2972            // row wears it, but every row of a grid is a continuation of the
2973            // block the table sits in.
2974            prefix: pc.to_vec(),
2975            grid: grid
2976                .into_iter()
2977                .zip(heads)
2978                .map(|(cells, head)| TableRow { head, cells })
2979                .collect(),
2980        });
2981        // The table's own end anchors whatever separator follows it; the border
2982        // rows deliberately don't move `last_off` (they hold no content).
2983        self.last_off = node_end;
2984    }
2985
2986    /// One row of laid-out cells, in column order.
2987    fn row_cells(&self, row: usize) -> Vec<TableCell> {
2988        // A cell is one source line, so a break within it is an explicit line
2989        // break (an inline `<br>`) that must render as a line of its own — not the
2990        // flow-folding space a break is in prose.
2991        self.break_glyph.set('\n');
2992        let cells = self
2993            .children(row)
2994            .into_iter()
2995            .filter(|&c| self.nodes[c].kind == Kind::Cell)
2996            .enumerate()
2997            .map(|(col, c)| {
2998                let n = &self.nodes[c];
2999                let style = if n.head.unwrap_or(false) {
3000                    Style::default().bold()
3001                } else {
3002                    Style::default()
3003                };
3004                // A cell's own `span` is the whole row; only `content_span` bounds
3005                // its text. An EMPTY cell has no `content_span` at all — twig
3006                // records no interior for it — so both offsets would fall back to
3007                // the row's start (before its first `│`), where every empty cell
3008                // in the row collapses onto the same spot and a click or caret
3009                // there types *before* the table. Derive the cell's own interior
3010                // from the row source and this cell's column instead, so each
3011                // empty cell has a distinct, editable caret home.
3012                let span = n.content_span.clone().unwrap_or_else(|| {
3013                    let off = empty_cell_offset(
3014                        &self.source[n.span.start.min(self.source.len())
3015                            ..n.span.end.min(self.source.len())],
3016                        n.span.start,
3017                        col,
3018                    );
3019                    off..off
3020                });
3021                TableCell {
3022                    glyphs: self.inline_children(c, style),
3023                    start: span.start,
3024                    end: span.end,
3025                    align: n.alignment.unwrap_or(Alignment::Default),
3026                }
3027            })
3028            .collect();
3029        self.break_glyph.set(' ');
3030        cells
3031    }
3032
3033    /// A horizontal rule between/around rows — entirely decoration.
3034    fn push_rule(&mut self, text: &str, src: usize, prefix: &[Glyph]) {
3035        let glyphs = concat(prefix, &synth(text, Role::Rule, src));
3036        self.rows.push(VRow {
3037            glyphs,
3038            end_src: src,
3039            decoration: true,
3040            code: false,
3041            code_lang: None,
3042            directive: false,
3043            directive_label: None,
3044            media: None,
3045            task: None,
3046            leaf_directive: None,
3047            heading: None,
3048            boundary: None,
3049        });
3050    }
3051
3052    /// One `│ a │ b │` row of the grid: real cell text between decoration.
3053    ///
3054    /// A row of cells is not a row of the screen — a cell wrapped to its column
3055    /// spans several, each one `│`-divided across the full width so the grid
3056    /// stays square. Cells in the same row are laid out independently and run
3057    /// out at their own heights; a column that has run dry pads out as
3058    /// decoration while its neighbours keep going.
3059    fn push_table_row(&mut self, cells: &[TableCell], widths: &[usize], prefix: &[Glyph]) {
3060        let fallback = cells.last().map(|c| c.end).unwrap_or(0);
3061        let laid: Vec<Vec<Vec<Glyph>>> = cells
3062            .iter()
3063            .enumerate()
3064            .map(|(ci, c)| wrap_glyphs(&c.glyphs, widths.get(ci).copied().unwrap_or(0)))
3065            .collect();
3066        let height = laid.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
3067
3068        for j in 0..height {
3069            let mut glyphs = prefix.to_vec();
3070            for (ci, &w) in widths.iter().enumerate() {
3071                let cell = cells.get(ci);
3072                let line = laid.get(ci).and_then(|l| l.get(j));
3073                // The divider before this column belongs to the cell it
3074                // introduces, so clicking it lands in that cell — on this line
3075                // of it, which is what's next to the divider being clicked.
3076                let at = line
3077                    .and_then(|l| l.first().map(|g| g.src))
3078                    .or_else(|| cell.map(|c| c.start))
3079                    .unwrap_or(fallback);
3080                glyphs.extend(synth("│", Role::Rule, at));
3081                match (cell, line) {
3082                    (Some(cell), Some(line)) => {
3083                        let pad = w.saturating_sub(glyphs_width(line));
3084                        let (lead, trail) = match cell.align {
3085                            Alignment::Right => (pad, 0),
3086                            Alignment::Center => (pad / 2, pad - pad / 2),
3087                            Alignment::Left | Alignment::Default => (0, pad),
3088                        };
3089                        // Every line renders at least one space after its text
3090                        // (the gutter before `│`), so there is always somewhere
3091                        // to put the "after the last character" caret a line
3092                        // needs. It's the one padding glyph that is a stop: on
3093                        // the cell's last line that's the cell's end, and on any
3094                        // other it's the space the wrap consumed.
3095                        let last = laid[ci].len() == j + 1;
3096                        let end = match last {
3097                            true => cell.end,
3098                            false => line
3099                                .last()
3100                                .map(|g| g.src + g.ch.len_utf8())
3101                                .unwrap_or(cell.end),
3102                        };
3103                        glyphs.extend(synth(&" ".repeat(lead + 1), Role::Body, at));
3104                        glyphs.extend(line.iter().cloned());
3105                        glyphs.push(Glyph {
3106                            ch: ' ',
3107                            style: Style::default(),
3108                            src: end,
3109                            stop: true,
3110                        });
3111                        glyphs.extend(synth(&" ".repeat(trail), Role::Body, end));
3112                    }
3113                    // A ragged row, or a column whose cell ended higher up: pad
3114                    // it out so the grid stays square.
3115                    _ => {
3116                        let at = cell.map(|c| c.end).unwrap_or(fallback);
3117                        glyphs.extend(synth(&" ".repeat(w + 2), Role::Body, at));
3118                    }
3119                }
3120            }
3121            glyphs.extend(synth("│", Role::Rule, fallback));
3122            // The row ends where its last stop does. A table row has no gap
3123            // between its final cell and the border, so inventing an end past
3124            // that would be a stop with nothing under it.
3125            let end_src = glyphs
3126                .iter()
3127                .rev()
3128                .find(|g| g.stop)
3129                .map_or(fallback, |g| g.src);
3130            self.rows.push(VRow {
3131                glyphs,
3132                end_src,
3133                decoration: false,
3134                code: false,
3135                code_lang: None,
3136                directive: false,
3137                directive_label: None,
3138                media: None,
3139                task: None,
3140                leaf_directive: None,
3141                heading: None,
3142                boundary: None,
3143            });
3144        }
3145    }
3146
3147    /// Render a block-level image, video, or audio as one placeholder row: the
3148    /// `🖼 alt` / `🎬 alt` / `🔊 alt` label styled [`Role::Image`], every glyph
3149    /// mapped to the media's start offset and a caret stop there (they share the
3150    /// offset, so the stop table dedups them to a single home in front of it, as
3151    /// a rule's dashes do), and the row's end stop set past it so the caret can
3152    /// also rest after it. The row carries a [`MediaMark`] so [`media_spans`]
3153    /// publishes it as a [`MediaInfo`] a capable frontend replaces with the real
3154    /// picture or player; a plain surface paints the label as-is. `pf` is the
3155    /// block prefix (a list indent, a quote gutter) the row opens with, exactly
3156    /// as every other block honours it.
3157    fn block_media(&mut self, img: usize, kind: MediaKind, wrapper: usize, pf: &[Glyph]) {
3158        let node = &self.nodes[img];
3159        let start = node.span.start;
3160        let end = node.span.end;
3161        // An `image`'s URL is twig's `destination`; a `<video>`/`<audio>` is a
3162        // generic element, so its URL is the `src` attribute — and may be absent
3163        // entirely, the element naming its candidates in child `<source>`s.
3164        let destination = match kind {
3165            MediaKind::Image => node.destination.clone().unwrap_or_default(),
3166            MediaKind::Video | MediaKind::Audio => attr_of(node, "src").unwrap_or_default(),
3167        };
3168        let poster = match kind {
3169            MediaKind::Video => attr_of(node, "poster").unwrap_or_default(),
3170            MediaKind::Image | MediaKind::Audio => String::new(),
3171        };
3172        // The `<source>`s under the media element itself, not under `wrapper`: a
3173        // `<video>` is its own container, unlike an `<img>`, whose `<picture>`
3174        // alternatives are its *siblings* and so only reachable from the wrapper.
3175        let sources = match kind {
3176            MediaKind::Image => self.media_sources(wrapper),
3177            MediaKind::Video | MediaKind::Audio => self.media_sources(img),
3178        };
3179        let alt = self.image_alt(img);
3180        let sigil = kind.sigil();
3181        let label = if alt.is_empty() {
3182            // With no alt, name the file — but a `<video>` with neither `src` nor
3183            // alt has only its `<source>`s to be named by, so fall back to the
3184            // first candidate rather than labelling the row a bare sigil.
3185            let named = if destination.is_empty() {
3186                sources
3187                    .first()
3188                    .map(|s| s.srcset.as_str())
3189                    .unwrap_or_default()
3190            } else {
3191                &destination
3192            };
3193            format!("{sigil} {}", media_label(named))
3194        } else {
3195            format!("{sigil} {alt}")
3196        };
3197        let style = Style::default().role(Role::Image);
3198        let mut glyphs = pf.to_vec();
3199        for ch in label.chars() {
3200            glyphs.push(Glyph {
3201                ch,
3202                style,
3203                src: start,
3204                stop: true,
3205            });
3206        }
3207        // How many rows the frontend wants for this picture: the label row plus
3208        // the blank fillers below it. Absent (a GUI that lays images out in
3209        // pixels, an image that didn't resolve, or a plain surface) means the
3210        // bare one-row placeholder.
3211        let rows = self
3212            .media_rows
3213            .get(&destination)
3214            .copied()
3215            .unwrap_or(1)
3216            .max(1);
3217        // End past the image so the caret has a stop after it: the last glyph's
3218        // offset is the image *start*, not its extent, so `push_row`'s
3219        // last-glyph rule would strand the end stop inside the markup.
3220        self.push_row_at(glyphs, end);
3221        if let Some(row) = self.rows.last_mut() {
3222            row.media = Some(MediaMark {
3223                kind,
3224                destination,
3225                sources,
3226                alt,
3227                poster,
3228                rows,
3229            });
3230        }
3231        // Reserve the picture's remaining height as blank `decoration` rows: drawn
3232        // (so the frontend has the vertical room to paint the raster over them),
3233        // but holding no caret and contributing no stops — vertical motion steps
3234        // over them and the caret's only homes stay the stop in front of the image
3235        // and the one just past it, both on the label row above. They anchor at the
3236        // image's end offset so a click on the picture's lower half lands after it,
3237        // the nearest caret home. Mirrors how a table's box-rule rows reserve space
3238        // without ever holding the caret.
3239        for _ in 1..rows {
3240            self.rows.push(VRow {
3241                glyphs: Vec::new(),
3242                end_src: end,
3243                decoration: true,
3244                code: false,
3245                code_lang: None,
3246                directive: false,
3247                directive_label: None,
3248                media: None,
3249                task: None,
3250                leaf_directive: None,
3251                heading: None,
3252                boundary: None,
3253            });
3254        }
3255        self.last_off = end;
3256    }
3257
3258    /// The `<picture>` alternatives inside block-image `wrapper`, in document
3259    /// order — every `<source>` element in its subtree. Empty when there's no
3260    /// `<picture>`. Each is a `<source>`'s `media` + `srcset`; core keeps them
3261    /// verbatim and picks none (see [`MediaSource`]). A `<source>` with no
3262    /// `srcset` is dropped (nothing to load); its `media` may be empty (an
3263    /// unconditional override), which a frontend treats as always-matching.
3264    ///
3265    /// It scans the wrapper's whole subtree (via the forward `first_child` /
3266    /// `next_sibling` links, the reliable ones) rather than the `<img>`'s parent,
3267    /// for two reasons. A `<picture>` reaches core in two shapes: twig promotes a
3268    /// block `<picture>` to an `element(picture)` wrapping `[source, img]`, but
3269    /// leaves an inline one's tags as raw siblings — `[raw "<picture>", source,
3270    /// img, raw "</picture>"]` — so the `<source>`s sit at different depths in
3271    /// the two. And the editor's flat arena leaves a promoted inline node's
3272    /// `parent` back-pointer dangling on a phantom root, so only the wrapper
3273    /// (known at the call site) is a trustworthy anchor. A block image is the
3274    /// sole visible content of its wrapper, so every `<source>` under it is its
3275    /// picture's.
3276    fn media_sources(&self, wrapper: usize) -> Vec<MediaSource> {
3277        let mut out = Vec::new();
3278        self.collect_sources(wrapper, &mut out);
3279        out
3280    }
3281
3282    fn collect_sources(&self, id: usize, out: &mut Vec<MediaSource>) {
3283        for c in self.children(id) {
3284            let node = &self.nodes[c];
3285            if node.name.as_deref() == Some("source") {
3286                // `<picture>` spells its candidate `srcset`, `<video>`/`<audio>`
3287                // spell it `src`. Both mean "the URL to load", so they normalise
3288                // onto one field; `srcset` wins where (illegally) both appear.
3289                let url = attr_of(node, "srcset").or_else(|| attr_of(node, "src"));
3290                if let Some(srcset) = url {
3291                    out.push(MediaSource {
3292                        media: attr_of(node, "media").unwrap_or_default(),
3293                        srcset,
3294                        mime: attr_of(node, "type").unwrap_or_default(),
3295                    });
3296                }
3297            }
3298            self.collect_sources(c, out);
3299        }
3300    }
3301
3302    /// The single block-level media `id`'s subtree resolves to, or `None`.
3303    ///
3304    /// A wrapper is a block picture when the only *visible* thing under it is one
3305    /// image: whitespace-only text and structure-only elements (a `<picture>`'s
3306    /// `<source>`, which declares an alternate but paints nothing) don't count,
3307    /// and the search descends through wrapping elements (`<picture>`, a linking
3308    /// `<a>`). This is what makes `<p><img></p>`, a bare `<img>`, and
3309    /// `<h1><picture>…<img></picture></h1>` all render as one framed picture.
3310    /// Any real text, or a second image, means it isn't image-only — it falls
3311    /// back to inline rendering, where the image still shows as its alt text.
3312    ///
3313    /// [`FlatNode`]'s snapshot doesn't carry an element's tag name, so a
3314    /// `<source>` can't be skipped by name — but it needs no special case:
3315    /// contributing no image and no text, it's simply invisible to the scan.
3316    fn media_only(&self, id: usize) -> Option<(usize, MediaKind)> {
3317        let mut found = None;
3318        let mut count = 0usize;
3319        let mut has_text = false;
3320        self.scan_visual(id, &mut found, &mut count, &mut has_text);
3321        (count == 1 && !has_text).then(|| found.unwrap())
3322    }
3323
3324    /// Walk `id`'s subtree tallying visible leaves for [`media_only`]: each
3325    /// image, `<video>`, or `<audio>` (remembering the last, counting the total)
3326    /// and whether any non-whitespace text appears. Media isn't descended into —
3327    /// an image's inline children are alt text, and a `<video>`'s are its
3328    /// no-support fallback and its `<source>` declarations, none of which is
3329    /// document content.
3330    ///
3331    /// [`media_only`]: Self::media_only
3332    fn scan_visual(
3333        &self,
3334        id: usize,
3335        found: &mut Option<(usize, MediaKind)>,
3336        count: &mut usize,
3337        has_text: &mut bool,
3338    ) {
3339        for c in self.children(id) {
3340            let node = &self.nodes[c];
3341            match node.kind.as_str() {
3342                "image" => {
3343                    *found = Some((c, MediaKind::Image));
3344                    *count += 1;
3345                }
3346                // A `<video>`/`<audio>` reaches core as a generic `container`
3347                // (twig gives neither a semantic node, so `html_elements`
3348                // promotion leaves the tag name on `name`). Counted as media and
3349                // *not* descended into, so its `<source>` children and its
3350                // "your browser does not support…" fallback text neither add a
3351                // second count nor make the block look like text.
3352                "container" if matches!(element_tag(node), Some("video") | Some("audio")) => {
3353                    let kind = match element_tag(node) {
3354                        Some("audio") => MediaKind::Audio,
3355                        _ => MediaKind::Video,
3356                    };
3357                    *found = Some((c, kind));
3358                    *count += 1;
3359                }
3360                // Text leaves: only non-whitespace counts as visible content.
3361                // (Twig keeps the whitespace `str`s between HTML tags — the
3362                // newlines and indentation inside a `<picture>` — as real nodes.)
3363                "str" | "smart_punctuation" | "verbatim" | "inline_math" => {
3364                    if node.text.as_deref().is_some_and(|t| !t.trim().is_empty()) {
3365                        *has_text = true;
3366                    }
3367                }
3368                // Structural breaks carry no visible glyph of their own.
3369                "soft_break" | "hard_break" | "non_breaking_space" => {}
3370                // Any other wrapper (emphasis, a link, a `<picture>`) is
3371                // transparent to the scan — descend into it.
3372                _ => self.scan_visual(c, found, count, has_text),
3373            }
3374        }
3375    }
3376
3377    /// A leaf directive (`::name{…}`) as one placeholder row — the
3378    /// [`block_media`](Self::block_media) recipe, for the same reason: it is a
3379    /// block that renders as *a thing*, not as text, and the frontend paints
3380    /// whatever the host app's vocabulary makes of it.
3381    ///
3382    /// The row's glyphs are a `⧉ label` (or `⧉ name`) stand-in a plain surface
3383    /// paints as-is, every glyph anchored at the directive's start with a caret
3384    /// stop there, and the row ending past it so the caret can also rest after
3385    /// it. It carries a [`DirectiveMark`] for [`directive_spans`], and is marked
3386    /// [`directive`](VRow::directive) so a frontend already drawing the
3387    /// container form's panel frames this one identically for free.
3388    ///
3389    /// Before this, a leaf directive emitted no rows at all: it was invisible,
3390    /// held no caret, and vertical motion crossed a void where it stood.
3391    fn block_directive(&mut self, id: usize, pf: &[Glyph]) {
3392        let node = &self.nodes[id];
3393        let (start, end) = (node.span.start, node.span.end);
3394        let name = node.name.clone().unwrap_or_default();
3395        let attrs = node.attrs.clone();
3396        let label = self.image_alt(id); // its `[label]` children, flattened
3397        let shown = if label.is_empty() { &name } else { &label };
3398        let style = Style::default().role(Role::Image);
3399        let mut glyphs = pf.to_vec();
3400        for ch in format!("⧉ {shown}").chars() {
3401            glyphs.push(Glyph {
3402                ch,
3403                style,
3404                src: start,
3405                stop: true,
3406            });
3407        }
3408        // End past the directive so the caret has a stop after it — the same
3409        // reason `block_media` anchors its row at the image's end.
3410        self.push_row_at(glyphs, end);
3411        if let Some(row) = self.rows.last_mut() {
3412            row.directive = true;
3413            row.leaf_directive = Some(DirectiveMark {
3414                name,
3415                attrs,
3416                label,
3417                rows: 1,
3418            });
3419        }
3420        self.last_off = end;
3421    }
3422
3423    /// An image's alt text: the flattened text of its inline descendants (an
3424    /// image's children *are* its alt content), empty when it has none. Also a
3425    /// leaf directive's `[label]`, which is the same shape — inline children
3426    /// standing for the block.
3427    fn image_alt(&self, id: usize) -> String {
3428        let mut out = String::new();
3429        self.collect_text(id, &mut out);
3430        out
3431    }
3432
3433    /// Append every descendant's `text` to `out`, in document order. Inline text
3434    /// (`str`) nodes are leaves, so a node never contributes both its own text and
3435    /// a child's — no double counting.
3436    fn collect_text(&self, id: usize, out: &mut String) {
3437        for c in self.children(id) {
3438            if let Some(t) = &self.nodes[c].text {
3439                out.push_str(t);
3440            }
3441            self.collect_text(c, out);
3442        }
3443    }
3444
3445    fn inline_children(&self, id: usize, base: Style) -> Vec<Glyph> {
3446        let mut out = Vec::new();
3447        for c in self.children(id) {
3448            self.inline(c, base, &mut out);
3449        }
3450        out
3451    }
3452
3453    /// [`inline_children`](Self::inline_children) plus any trailing whitespace the
3454    /// block carries past its inline content (see [`trailing_ws_glyphs`]). Used
3455    /// for the leaf inline blocks — paragraphs and headings — whose own `span`
3456    /// bounds exactly one line of text, so the trailing gap is theirs. *Not* for
3457    /// a table cell, whose `span` is the whole row and would swallow the
3458    /// delimiters and neighbours between it and the row's end.
3459    ///
3460    /// [`trailing_ws_glyphs`]: Self::trailing_ws_glyphs
3461    fn inline_children_with_trailing(&self, id: usize, base: Style) -> Vec<Glyph> {
3462        let mut out = self.inline_children(id, base);
3463        out.extend(self.trailing_ws_glyphs(id, base));
3464        out
3465    }
3466
3467    /// Glyphs for whatever trailing whitespace a block's source carries past its
3468    /// last inline node — the space(s) at the end of `hello ` that Markdown and
3469    /// Djot drop from the `str` node as insignificant. twig still records them:
3470    /// a block's `content_span` ends at its last meaningful character while its
3471    /// `span` runs to the end of the line's text (before the terminating
3472    /// newline), so the gap between the two *is* that trailing whitespace.
3473    ///
3474    /// Emitting it as real caret-stop glyphs is what lets the caret be drawn
3475    /// past the last visible character. Without it, typing a space at the end of
3476    /// a paragraph moved the caret in the source but not on screen — the caret
3477    /// stuck on the last glyph until the next visible character reparsed the
3478    /// space into an interior `str` node that finally carried it.
3479    ///
3480    /// Restricted to spaces: only they are safe to synthesize one-cell-per-byte,
3481    /// and only they are what the parser silently strips. Anything else in the
3482    /// gap means the span accounting isn't what this assumes, so it's left alone.
3483    fn trailing_ws_glyphs(&self, id: usize, style: Style) -> Vec<Glyph> {
3484        let node = &self.nodes[id];
3485        let Some(content) = &node.content_span else {
3486            return Vec::new();
3487        };
3488        let (from, to) = (content.end, node.span.end);
3489        let Some(slice) = (from < to).then(|| self.source.get(from..to)).flatten() else {
3490            return Vec::new();
3491        };
3492        if slice.is_empty() || slice.bytes().any(|b| b != b' ') {
3493            return Vec::new();
3494        }
3495        slice
3496            .bytes()
3497            .enumerate()
3498            .map(|(i, _)| Glyph {
3499                ch: ' ',
3500                style,
3501                src: from + i,
3502                stop: true,
3503            })
3504            .collect()
3505    }
3506
3507    fn inline(&self, id: usize, base: Style, out: &mut Vec<Glyph>) {
3508        let node = &self.nodes[id];
3509        match node.kind.as_str() {
3510            "str" | "smart_punctuation" => push_escaped_text(
3511                out,
3512                node.text.as_deref().unwrap_or(""),
3513                node.span.clone(),
3514                self.source,
3515                base,
3516            ),
3517            "soft_break" | "hard_break" | "non_breaking_space" => {
3518                // A break renders as a real, caret-navigable glyph — but twig
3519                // gives it no span of its own (`0..0`), so the offset comes from
3520                // the text in front of it: one *past* the last glyph, which is
3521                // the newline the break stands for. Past, not on: sharing the
3522                // previous glyph's offset would put two stops on one byte, and a
3523                // caret that can't change offset can't move.
3524                let src = if node.span.start != 0 {
3525                    node.span.start
3526                } else {
3527                    out.last().map(|g| g.src + g.ch.len_utf8()).unwrap_or(0)
3528                };
3529                // A *hard* break renders as this run's break glyph — a newline
3530                // inside a table cell (its own line), the same space in prose the
3531                // frontend re-wraps. A soft break normally folds into a space;
3532                // under `LineFlow::Preserve` it renders as a `'\n'` too, so the
3533                // author's line break shows where it was written. Never inside a
3534                // cell (`break_glyph` is `'\n'` there): a cell is one line and
3535                // folds its own soft breaks regardless.
3536                let ch = if node.kind == Kind::HardBreak {
3537                    self.break_glyph.get()
3538                } else if node.kind == Kind::SoftBreak
3539                    && self.preserve_soft
3540                    && self.break_glyph.get() == ' '
3541                {
3542                    '\n'
3543                } else {
3544                    ' '
3545                };
3546                out.push(Glyph {
3547                    ch,
3548                    style: base,
3549                    src,
3550                    stop: true,
3551                });
3552            }
3553            // A cell's only spelling for an in-line break is a raw `<br>`; read it
3554            // back as one (outside a cell it stays the literal text it falls to
3555            // below). The tag's bytes carry no stop of their own — the line it
3556            // ends stops just before it, the next just after.
3557            "raw_inline" if self.break_glyph.get() == '\n' && is_br(node.text.as_deref()) => {
3558                out.push(Glyph {
3559                    ch: '\n',
3560                    style: base,
3561                    src: node.span.start,
3562                    stop: true,
3563                });
3564            }
3565            "emph" => self.inline_delimited(id, base.italic(), out),
3566            "strong" => self.inline_delimited(id, base.bold(), out),
3567            // A coloured highlight's emoji is spelling, not content: twig strips
3568            // it and records the colour on the node, so the glyphs are the
3569            // author's words and the colour rides the role. Revealed markup
3570            // still shows the emoji, because `delims` reads the source bytes
3571            // between the span and the content span — which is exactly the
3572            // `==🔴 ` the author typed.
3573            "mark" => {
3574                let color = MarkColor::from_attrs(&node.attrs);
3575                self.inline_delimited(id, base.role(Role::Mark(color)), out)
3576            }
3577            "insert" => self.inline_delimited(id, base.underline(), out),
3578            "delete" => self.inline_delimited(id, base.strikethrough(), out),
3579            // The one pair whose whole meaning is *where the glyphs sit*. Drawn
3580            // in the surrounding style otherwise, so `^**2**^` stays bold and a
3581            // superscript inside a heading keeps the heading's role — which is
3582            // exactly why this is a `Baseline` and not a `Role`.
3583            "superscript" => self.inline_delimited(id, base.baseline(Baseline::Super), out),
3584            "subscript" => self.inline_delimited(id, base.baseline(Baseline::Sub), out),
3585            "verbatim" | "inline_math" => {
3586                // The interior begins at `content_span.start` — past however many
3587                // backticks the fence used, which `span.start + 1` only guessed
3588                // right for a single one. Fall back to that guess if it's absent.
3589                let at = node
3590                    .content_span
3591                    .as_ref()
3592                    .map_or(node.span.start + 1, |c| c.start);
3593                let style = base.role(Role::Code);
3594                // Not `inline_delimited`: verbatim has no child nodes to recurse
3595                // into — its content is its own `text` — so the fences bracket a
3596                // `push_text` instead. The fences themselves keep `Role::Code`'s
3597                // sibling treatment via `push_delim`'s role override.
3598                let show = self.revealed(&node.span).then(|| self.delims(id)).flatten();
3599                if let Some((open, _)) = &show {
3600                    self.push_delim(out, open, style);
3601                }
3602                push_text(out, node.text.as_deref().unwrap_or(""), at, style);
3603                if let Some((_, close)) = &show {
3604                    self.push_delim(out, close, style);
3605                }
3606            }
3607            // A text directive (`:name[label]{…}`) — the inline form of a generic
3608            // directive. Its `[label]` children are the visible text; the name and
3609            // the `{…}` attributes are the host app's vocabulary (diaryx's
3610            // `:vis[…]`) and stay hidden markup, exactly as a link's `](dest)` is.
3611            // Drawn in the surrounding style: a role of its own would need one
3612            // every frontend maps, and the bug this fixes is that the text was
3613            // invisible, not that it was unstyled.
3614            "container" if container_is_directive(node) && !self.children(id).is_empty() => {
3615                self.recurse(id, base, out)
3616            }
3617            // No `[label]`, so there are no children to render and recursing
3618            // emitted *nothing*: the directive's bytes vanished from the document
3619            // and left no caret stop behind. What to draw instead turns on
3620            // whether the syntax looks deliberate.
3621            //
3622            // Bare `:word` almost never is. twig matches a colon followed by any
3623            // letter-led word (`scanTextDirective`, deliberately matching remark),
3624            // so ordinary prose is full of them — `:see below`, a `:smile:`
3625            // shortcode, a stray colon before a word. Those are prose, and prose
3626            // renders as itself: every byte visible, every byte a caret stop, so a
3627            // colon typed by accident can be seen and deleted. Hiding them behind
3628            // a placeholder would be the invisible-and-unreachable failure this
3629            // arm exists to fix, just wearing a nicer glyph.
3630            "container" if container_is_directive(node) && node.attrs.is_empty() => {
3631                let span = node.span.clone();
3632                push_text(
3633                    out,
3634                    self.source.get(span.clone()).unwrap_or(""),
3635                    span.start,
3636                    base,
3637                );
3638            }
3639            // `{…}` attributes, though, are unmistakably deliberate — nobody
3640            // types `:vis{.family}` by accident, and diaryx writes exactly that
3641            // inline. So an attribute-bearing directive with no label draws as a
3642            // chip on `block_directive`'s recipe (`⧉ name attrs`, `Role::Image`),
3643            // the inline peer of the leaf form's placeholder row.
3644            //
3645            // Only the first glyph is a caret stop, and the whole chip shares the
3646            // directive's start offset: the caret treats it as one atomic thing
3647            // rather than walking hidden markup a byte at a time, and a paragraph
3648            // holding nothing but a chip still has a stop to be navigated to.
3649            "container" if container_is_directive(node) => {
3650                let start = node.span.start;
3651                let name = node.name.clone().unwrap_or_default();
3652                let shown = match directive_attr_label(&node.attrs) {
3653                    Some(attrs) if !name.is_empty() => format!("⧉ {name} {attrs}"),
3654                    Some(attrs) => format!("⧉ {attrs}"),
3655                    None => format!("⧉ {name}"),
3656                };
3657                let style = base.role(Role::Image);
3658                for (i, ch) in shown.chars().enumerate() {
3659                    out.push(Glyph {
3660                        ch,
3661                        style,
3662                        src: start,
3663                        stop: i == 0,
3664                    });
3665                }
3666            }
3667            // A footnote reference (`[^1]`). The label bracketed is what a reader
3668            // needs — bare, `note1` reads as a typo rather than a reference — so
3669            // the `^` is hidden as the spelling artefact it is (a link's
3670            // `](dest)` goes the same way) and the brackets are kept as
3671            // decoration: one shared offset, never a caret stop, like a table's
3672            // borders, so the caret walks the label alone.
3673            //
3674            // Styled `Role::Link`: a reference *is* a link to its definition, and
3675            // every frontend already paints that role. A role of its own would
3676            // need one in each of them, and what a frontend needs to tell the two
3677            // apart is not a paint colour but an answer to "what does clicking
3678            // here do" — which is [`Doc::footnote_at_caret`]'s job, not a glyph's.
3679            //
3680            // Raised, though, because that a reference is *set* differently from
3681            // the prose it interrupts is exactly what makes it read as a
3682            // reference. `[1]` at body size reads as bracketed text.
3683            "footnote_reference" => {
3684                let style = base.role(Role::Link);
3685                // Revealed, the reference is just its source bytes: the `^` that
3686                // is normally elided comes back and every byte becomes a real
3687                // stop, so the brackets stop being decoration and start being
3688                // text. That's the whole point of the mode, and it replaces the
3689                // hand-built chip below rather than decorating it — including the
3690                // raised baseline, since what's on screen there is source, and
3691                // source is set as prose.
3692                if self.revealed(&node.span) {
3693                    self.push_delim(out, &node.span, style);
3694                    return;
3695                }
3696                let style = style.baseline(Baseline::Super);
3697                // The label's own span, so its glyphs map to their true bytes.
3698                // Absent one, it starts past the `[^` that opens the reference.
3699                let (label, at) = match &node.content_span {
3700                    Some(c) => (self.source.get(c.clone()).unwrap_or(""), c.start),
3701                    None => (node.text.as_deref().unwrap_or(""), node.span.start + 2),
3702                };
3703                out.push(Glyph {
3704                    ch: '[',
3705                    style,
3706                    src: node.span.start,
3707                    stop: false,
3708                });
3709                push_text(out, label, at, style);
3710                out.push(Glyph {
3711                    ch: ']',
3712                    style,
3713                    src: node.span.end.saturating_sub(1),
3714                    stop: false,
3715                });
3716            }
3717            "link" | "url" | "email" => {
3718                let style = base.role(Role::Link);
3719                if self.children(id).is_empty() {
3720                    // A bare autolink (`<a@b.c>`, a naked URL): the destination
3721                    // *is* the visible text, so there is nothing elided to
3722                    // reveal and both modes draw the same thing.
3723                    push_text(
3724                        out,
3725                        node.destination
3726                            .as_deref()
3727                            .or(node.text.as_deref())
3728                            .unwrap_or("link"),
3729                        node.span.start,
3730                        style,
3731                    );
3732                } else {
3733                    // An inline link reveals asymmetrically — `[` before the
3734                    // label, `](dest)` after it — which the generic
3735                    // span-minus-content derivation already produces.
3736                    self.inline_delimited(id, style, out);
3737                }
3738            }
3739            _ => {
3740                if self.children(id).is_empty() {
3741                    if let Some(t) = &node.text {
3742                        push_text(out, t, node.span.start, base);
3743                    }
3744                } else {
3745                    self.recurse(id, base, out);
3746                }
3747            }
3748        }
3749    }
3750
3751    fn recurse(&self, id: usize, style: Style, out: &mut Vec<Glyph>) {
3752        for c in self.children(id) {
3753            self.inline(c, style, out);
3754        }
3755    }
3756
3757    /// Lay a block's inline `glyphs` into visual rows, prefixing the first with
3758    /// `pf` and the rest with `pc`. A preserved soft break arrives as a `'\n'`
3759    /// glyph (see the `soft_break` arm): a hard row boundary that splits the
3760    /// glyphs so each run lays out on its own and the author's line structure
3761    /// shows on screen. The `'\n'` is dropped from the row it closes and its
3762    /// source offset becomes that row's end stop — exactly how a table cell's
3763    /// in-line `<br>` is handled — so the caret can rest at the line's end
3764    /// without a zero-width control char leaking into what the frontends render.
3765    /// With no `'\n'` present (the folding default, and every build that isn't
3766    /// `LineFlow::Preserve`) there is one run and this is byte-identical to
3767    /// laying the glyphs out directly.
3768    fn emit_wrapped(&mut self, glyphs: Vec<Glyph>, block_start: usize, pf: &[Glyph], pc: &[Glyph]) {
3769        if !glyphs.iter().any(|g| g.ch == '\n') {
3770            self.emit_line(glyphs, block_start, pf, pc, None);
3771            return;
3772        }
3773        // Each run up to a '\n' is a line of its own: the first wears the block's
3774        // opening prefix, every later one the continuation prefix, and the break's
3775        // own offset ends the run's last row. The break glyph is dropped. A
3776        // trailing '\n' flushes its run and leaves nothing behind, so no spurious
3777        // blank row follows it.
3778        let mut run: Vec<Glyph> = Vec::new();
3779        let mut first = true;
3780        for g in glyphs {
3781            if g.ch == '\n' {
3782                let lead = if first { pf } else { pc };
3783                self.emit_line(std::mem::take(&mut run), block_start, lead, pc, Some(g.src));
3784                first = false;
3785            } else {
3786                run.push(g);
3787            }
3788        }
3789        if !run.is_empty() {
3790            let lead = if first { pf } else { pc };
3791            self.emit_line(run, block_start, lead, pc, None);
3792        }
3793    }
3794
3795    /// Word-wrap a single line of `glyphs` (no interior line breaks) to the
3796    /// available width and push the visual rows, prefixing the first with `pf`
3797    /// and the rest with `pc`. `end`, when set, is the source offset that ends
3798    /// the line's final row — the offset of the break that terminated it, which
3799    /// the caller has already stripped from `glyphs`; when `None` the row ends
3800    /// just past its last glyph, as an unbroken block's does.
3801    fn emit_line(
3802        &mut self,
3803        glyphs: Vec<Glyph>,
3804        block_start: usize,
3805        pf: &[Glyph],
3806        pc: &[Glyph],
3807        end: Option<usize>,
3808    ) {
3809        // The line's final row ends at `end` when a break gave one, else just
3810        // past its last glyph (`push_row`'s default).
3811        let push_last = |b: &mut Self, row: Vec<Glyph>| match end {
3812            Some(e) => b.push_row_at(row, e),
3813            None => b.push_row(row, block_start),
3814        };
3815
3816        // No column budget: emit the whole line as one row and let the frontend
3817        // wrap it at its own (pixel) width.
3818        let Some(width) = self.wrap else {
3819            let row = if glyphs.is_empty() {
3820                pf.to_vec()
3821            } else {
3822                concat(pf, &glyphs)
3823            };
3824            push_last(self, row);
3825            return;
3826        };
3827
3828        // Split into words (maximal non-space runs), each carrying the space
3829        // glyph that followed it (so its source offset is preserved).
3830        let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
3831        let mut word: Vec<Glyph> = Vec::new();
3832        for g in glyphs {
3833            if g.ch == ' ' {
3834                words.push((std::mem::take(&mut word), Some(g)));
3835            } else {
3836                word.push(g);
3837            }
3838        }
3839        if !word.is_empty() {
3840            words.push((word, None));
3841        }
3842        if words.is_empty() {
3843            // An empty block (or an empty preserved line) still occupies one
3844            // (prefixed) row.
3845            push_last(self, pf.to_vec());
3846            return;
3847        }
3848
3849        let mut line: Vec<Glyph> = Vec::new();
3850        let mut used = 0usize;
3851        let mut first = true;
3852        for (w, space) in words {
3853            let avail = width
3854                .saturating_sub(prefix_width(if first { pf } else { pc }))
3855                .max(1);
3856            let cells = glyphs_width(&w);
3857            if used > 0 && used + cells > avail {
3858                let row = concat(if first { pf } else { pc }, &line);
3859                self.push_row(row, block_start);
3860                line = Vec::new();
3861                used = 0;
3862                first = false;
3863            }
3864            used += cells;
3865            line.extend(w);
3866            if let Some(sp) = space {
3867                used += 1;
3868                line.push(sp);
3869            }
3870        }
3871        let row = concat(if first { pf } else { pc }, &line);
3872        push_last(self, row);
3873    }
3874
3875    /// The source offset of each line of a code block's `text`.
3876    ///
3877    /// `content` is the block's `content_span` — where twig says the body lives
3878    /// in the source, fences already excluded. Its lines run 1:1 with the
3879    /// rendered `text` lines, so no search is needed; each is anchored at the
3880    /// *end* of its source line, which places it past whatever indent `text` had
3881    /// stripped (a fenced block's fences, an indented one's leading spaces)
3882    /// without having to know how much there was.
3883    ///
3884    /// `None` when the body and the rendered lines don't line up — a coarse
3885    /// fallback the caller turns into the block's start offset.
3886    fn code_line_offsets(&self, content: &Range<usize>, lines: &[&str]) -> Option<Vec<usize>> {
3887        let mut src_lines: Vec<(usize, &str)> = Vec::new();
3888        let mut at = content.start;
3889        for l in self.source.get(content.start..content.end)?.split('\n') {
3890            src_lines.push((at, l));
3891            at += l.len() + 1;
3892        }
3893        if src_lines.len() != lines.len() {
3894            return None;
3895        }
3896        Some(
3897            lines
3898                .iter()
3899                .zip(&src_lines)
3900                .map(|(l, (start, sl))| start + sl.len().saturating_sub(l.len()))
3901                .collect(),
3902        )
3903    }
3904
3905    fn push_row(&mut self, glyphs: Vec<Glyph>, fallback: usize) {
3906        // Step past the character the *source* holds at the last glyph's offset,
3907        // not past the glyph's own `ch`. The two agree for ordinary text, but a
3908        // glyph is not always the character it stands on: `synth` decoration and
3909        // a substituted run (an image's `⧉ label`) share one offset by design.
3910        // Trusting `ch` there yields an offset inside a multi-byte character,
3911        // which every later slice of `source` panics on.
3912        let end_src = glyphs
3913            .last()
3914            .map(|g| {
3915                let at = g.src.min(self.source.len());
3916                at + self.source[at..].chars().next().map_or(0, char::len_utf8)
3917            })
3918            .unwrap_or(fallback);
3919        self.push_row_at(glyphs, end_src);
3920    }
3921
3922    /// Push a row with an explicit end stop, for content that knows its own
3923    /// extent better than its last glyph does.
3924    fn push_row_at(&mut self, glyphs: Vec<Glyph>, end_src: usize) {
3925        self.last_off = end_src;
3926        self.rows.push(VRow {
3927            glyphs,
3928            end_src,
3929            decoration: false,
3930            code: false,
3931            code_lang: None,
3932            directive: false,
3933            directive_label: None,
3934            media: None,
3935            task: None,
3936            leaf_directive: None,
3937            heading: None,
3938            boundary: None,
3939        });
3940    }
3941
3942    /// The quote's own trailing marker lines: the `>` / `> ` lines that lie past
3943    /// its last child but inside its span, one gutter row each.
3944    ///
3945    /// Pressing Enter at the end of `> a` writes `> a\n>\n> \n` — twig's
3946    /// spelling, and the right one. Those last two lines hold no block (a
3947    /// `block_quote`'s `content_span` still stops at its last child) so the
3948    /// children walk never reaches them, and they used to fall all the way to
3949    /// the document-level [`Builder::emit_trailing_blank_lines`], which knows no
3950    /// prefix: the gutter simply stopped, and a writer adding a line to a quote
3951    /// watched it draw as plain prose.
3952    ///
3953    /// This is only answerable since twig 3.2.0, where a Markdown `block_quote`'s
3954    /// span covers its own trailing marker lines (it reported `0..3` for that
3955    /// source and now reports `0..8`). Before that the lines belonged to no node
3956    /// at any level, and the only way to draw them was to sniff `>` off the raw
3957    /// source and re-derive the nesting depth by counting markers — format
3958    /// inference this crate exists to keep out of the render path.
3959    ///
3960    /// Each row is a real caret home rather than a decoration gap: the writer
3961    /// spelled every one of these lines with a marker of its own, so each is a
3962    /// line of the quote to stand on, not the spacing between two blocks (which
3963    /// is [`Builder::emit_separators_before`]'s, and falls *between* children
3964    /// where this never looks).
3965    fn emit_quote_trailing_lines(&mut self, pc: &[Glyph], end: usize) {
3966        let end = end.min(self.source.len());
3967        let mut at = self.rows.last().map_or(0, |r| r.end_src);
3968        // Walk line by line from the last child's end to the quote's, taking each
3969        // line's *end* as the row's offset — the caret home at the end of a line
3970        // is where one on an empty quoted line belongs, and it keeps every row's
3971        // offset distinct from its neighbours'.
3972        while at < end {
3973            let Some(k) = self.source[at..end].find('\n') else {
3974                break;
3975            };
3976            let line_start = at + k + 1;
3977            let line_end = self.source[line_start..end]
3978                .find('\n')
3979                .map_or(end, |i| line_start + i);
3980            self.push_row_at(pc.to_vec(), line_end);
3981            at = line_end;
3982        }
3983    }
3984
3985    /// The source offset the caret rests at on the blank line separating a block
3986    /// that ends at `prev_end` from the next block starting at `next_start`:
3987    /// just past the newline that terminates the previous block, but kept
3988    /// strictly before the next block so the offset is unique to this row.
3989    fn blank_line_offset(&self, prev_end: usize, next_start: usize) -> usize {
3990        let after_nl = self.source[prev_end..]
3991            .find('\n')
3992            .map_or(prev_end, |p| prev_end + p + 1);
3993        after_nl.min(next_start.saturating_sub(1)).max(prev_end)
3994    }
3995
3996    /// The source offset of each blank row between a block ending at `prev_end`
3997    /// and content starting at `next_start` — one per blank source line. The
3998    /// first newline terminates the previous block's line; every line it opens up
3999    /// to (but not including) the line that holds `next_start` is a blank row the
4000    /// caret can occupy. Offsets are unique and ascending so `pos_of_offset`
4001    /// resolves each to its own row. Empty when the two blocks are tight (no
4002    /// blank line between them).
4003    fn blank_rows_between(&self, prev_end: usize, next_start: usize) -> Vec<usize> {
4004        // Spans aren't always in tidy source order (e.g. a block after
4005        // frontmatter can start *before* the previous block's rendered content
4006        // ends). There's no blank line to place then — fall back to the clamped
4007        // single separator (an empty return) rather than slicing an inverted
4008        // range.
4009        if next_start <= prev_end {
4010            return Vec::new();
4011        }
4012        let gap = &self.source[prev_end..next_start];
4013        let Some(nl) = gap.find('\n') else {
4014            return Vec::new();
4015        };
4016        // The line holding `next_start` belongs to the next block; blank rows
4017        // stop before it.
4018        let next_line_start = self.source[..next_start].rfind('\n').map_or(0, |p| p + 1);
4019        let mut offs = Vec::new();
4020        let mut start = prev_end + nl + 1;
4021        while start < next_line_start {
4022            offs.push(start);
4023            match self.source[start..next_start].find('\n') {
4024                Some(k) => start += k + 1,
4025                None => break,
4026            }
4027        }
4028        offs
4029    }
4030
4031    /// Blank lines the user typed past the end of the last block (e.g. two
4032    /// `Enter`s to open a fresh paragraph) leave no AST node, so nothing renders
4033    /// and the caret appears stuck on the old line. Reconstruct one empty row
4034    /// per extra trailing newline from the source, each at its own offset, so
4035    /// the caret rides down onto the new line the moment it's created.
4036    ///
4037    /// `above` is the class of the last block in the document — the one this gap
4038    /// closes. A document with no blocks at all has nothing above these rows, and
4039    /// [`BlockClass::Paragraph`] is the honest answer there too: what they are is
4040    /// empty paragraphs, on both sides of the gap.
4041    fn emit_trailing_blank_lines(&mut self, above: BlockClass, hidden_end: usize) {
4042        // With no rows at all the count starts past any hidden frontmatter, not
4043        // at 0: its newlines are not trailing blank lines, and counting them
4044        // opened phantom rows *inside* the metadata for a frontmatter-only file.
4045        //
4046        // Or past the last hidden block, if that is later: a closing comment
4047        // draws no row, and its lines are not blank lines the author opened.
4048        let last_end = self
4049            .rows
4050            .last()
4051            .map_or(hidden_end, |r| r.end_src)
4052            .max(self.stepped_over);
4053        if last_end >= self.source.len() {
4054            return;
4055        }
4056        // The first newline after the last content just terminates that line, so
4057        // a lone trailing `\n` (an ordinary file ending) opens no blank row. A
4058        // *second* newline opens an empty paragraph: render it the way a block
4059        // boundary is rendered — a blank spacer row, then the empty paragraph row
4060        // the caret rests on — so the just-pressed-Enter view already shows the
4061        // gap it will keep once text is typed, and typing doesn't shift the line
4062        // down. One row per trailing newline (each its own caret offset), the
4063        // last landing at the document end where the caret sits.
4064        let extra = self.source[last_end..].matches('\n').count();
4065        if extra < 2 {
4066            return;
4067        }
4068        for k in 1..=extra {
4069            self.rows.push(VRow {
4070                glyphs: Vec::new(),
4071                end_src: last_end + k,
4072                // As between two blocks: the first blank row is the gap that
4073                // closes the block above, not somewhere to type. Nothing follows
4074                // to need a gap of its own, though, so every row after it is a
4075                // real empty paragraph — the end of the document bounds the last
4076                // one the way a following block would. Preserve flow makes even
4077                // that first row navigable, as it does every blank line.
4078                decoration: !self.preserve_soft && k == 1,
4079                code: false,
4080                code_lang: None,
4081                directive: false,
4082                directive_label: None,
4083                media: None,
4084                task: None,
4085                leaf_directive: None,
4086                heading: None,
4087                // The one drawn row here is a block boundary like any other —
4088                // "rendered the way a block boundary is rendered" is the whole
4089                // point of it — so it says so, and a frontend spacing boundaries
4090                // spaces this one the same. The rows below it are navigable empty
4091                // paragraphs, not gaps.
4092                boundary: (!self.preserve_soft && k == 1).then_some(Boundary {
4093                    above,
4094                    below: BlockClass::Paragraph,
4095                }),
4096            });
4097        }
4098    }
4099}
4100
4101// ── display width ────────────────────────────────────────────────────────────
4102//
4103// Two things a row can be counted in, and they are not the same number:
4104//
4105//   *glyphs*, one per codepoint — how the text is stored here, and what an
4106//   index into `VRow::glyphs` means; and
4107//   *columns*, one per terminal cell — where the text is drawn, and what every
4108//   `col` in this crate means.
4109//
4110// `你` is one glyph in two columns. Counting columns with `glyphs.len()` (or,
4111// in the source view, `chars().count()`) is the same number only for the ASCII
4112// that most fixtures are written in, and drifts one cell per wide character
4113// everywhere else — the caret drawn a column short of the text it types into.
4114// Everything below converts between the two; nothing else should have to.
4115
4116/// The display width of `s` in terminal cells.
4117///
4118/// Measured per grapheme cluster, because that is the unit a surface advances
4119/// by: `👨‍👩‍👧` is five codepoints measuring 2 + 0 + 2 + 0 + 2 cells one at a
4120/// time, but the character they spell is drawn in 2. Both frontends already
4121/// measure it that way — ratatui asks `unicode-width` per cluster, and the GUI
4122/// asks its own text system — so the caret only lands where the text is if this
4123/// agrees with them.
4124pub fn text_width(s: &str) -> usize {
4125    UnicodeWidthStr::width(s)
4126}
4127
4128/// One grapheme cluster of a laid-out row: the glyphs that spell it, and the
4129/// cells it is drawn in.
4130///
4131/// The cluster, not the glyph, is what has a width. A row's glyphs are one per
4132/// codepoint, so an accented letter or an emoji is several of them drawn in one
4133/// character's worth of cells — the glyph that opens the cluster claims those
4134/// cells, and the ones continuing it are drawn *inside* them rather than beside
4135/// them. It's the same cluster the stop table is built on: the opening glyph is
4136/// the one a caret can rest on, and so the only one whose column it can be
4137/// drawn at.
4138struct Cluster {
4139    /// Index of the glyph that opens it.
4140    glyph: usize,
4141    /// The display column it starts at.
4142    col: usize,
4143    /// How many cells it is drawn in. Zero for a cluster with no width of its
4144    /// own (a lone joiner), which therefore sits at no column at all.
4145    cells: usize,
4146}
4147
4148/// Walk a row's glyphs as the clusters they spell, in column order.
4149fn clusters(glyphs: &[Glyph]) -> Vec<Cluster> {
4150    let text: String = glyphs.iter().map(|g| g.ch).collect();
4151    let mut out = Vec::new();
4152    let (mut glyph, mut col) = (0, 0);
4153    for cluster in text.graphemes(true) {
4154        let cells = text_width(cluster);
4155        out.push(Cluster { glyph, col, cells });
4156        // One glyph per codepoint, so a cluster spans exactly its own.
4157        glyph += cluster.chars().count();
4158        col += cells;
4159    }
4160    out
4161}
4162
4163/// The display width of a run of glyphs.
4164fn glyphs_width(glyphs: &[Glyph]) -> usize {
4165    clusters(glyphs).last().map_or(0, |c| c.col + c.cells)
4166}
4167
4168/// A cell's display width — the widest of its lines, since an in-cell `\n` break
4169/// splits it into several. Sizes the column that must hold every line.
4170fn cell_width(glyphs: &[Glyph]) -> usize {
4171    glyphs
4172        .split(|g| g.ch == '\n')
4173        .map(glyphs_width)
4174        .max()
4175        .unwrap_or(0)
4176}
4177
4178/// Whether a raw inline HTML tag is a line break (`<br>`, `<br/>`, `<br />`,
4179/// case-insensitively) — the one tag a table cell reads as an in-cell break.
4180fn is_br(text: Option<&str>) -> bool {
4181    let Some(t) = text else { return false };
4182    matches!(
4183        t.trim().to_ascii_lowercase().replace(' ', "").as_str(),
4184        "<br>" | "<br/>"
4185    )
4186}
4187
4188impl VRow {
4189    /// The row's width in display columns — and so the column of the caret
4190    /// placed past its last glyph, which is the rightmost column it can occupy.
4191    fn width(&self) -> usize {
4192        glyphs_width(&self.glyphs)
4193    }
4194
4195    /// The display column glyph `i` is drawn at. Glyphs continuing a cluster
4196    /// report the column of the glyph that opened it, since that is where they
4197    /// are drawn; none of them is ever a stop, so no caret is placed by it.
4198    fn col_of_glyph(&self, i: usize) -> usize {
4199        clusters(&self.glyphs)
4200            .iter()
4201            .rev()
4202            .find(|c| c.glyph <= i)
4203            .map_or(0, |c| c.col)
4204    }
4205
4206    /// The glyph drawn at display column `col`, or `None` past the row's last
4207    /// cell.
4208    ///
4209    /// A column landing on the *second* cell of a wide glyph resolves to that
4210    /// glyph: half a character is not a place to be, so clicking either cell of
4211    /// `你` means `你`, and the caret comes to rest at its start — the column it
4212    /// would be drawn at anyway. That rule is what makes the mapping invertible:
4213    /// every offset has one column, and every column has one offset.
4214    fn glyph_at_col(&self, col: usize) -> Option<usize> {
4215        clusters(&self.glyphs)
4216            .into_iter()
4217            .find(|c| col < c.col + c.cells)
4218            .map(|c| c.glyph)
4219    }
4220}
4221
4222// ── helpers ──────────────────────────────────────────────────────────────────
4223
4224/// The caret home inside an *empty* table cell (`col`, 0-based) of a row whose
4225/// source is `row_src` starting at byte `row_start`. twig gives an empty cell no
4226/// `content_span`, so its interior is read from the pipes: cell `col` lies
4227/// between the `col`-th and `col+1`-th unescaped `│`/`|`, and the home is one
4228/// space past the opening one — mimicking the `| ` padding a filled cell has,
4229/// and never at or past the closing pipe. So `|  |  |` gives the two cells
4230/// distinct, editable homes instead of both collapsing onto the row's start.
4231fn empty_cell_offset(row_src: &str, row_start: usize, col: usize) -> usize {
4232    let bytes = row_src.as_bytes();
4233    let mut pipes = Vec::new();
4234    for (i, &b) in bytes.iter().enumerate() {
4235        if b == b'|' && (i == 0 || bytes[i - 1] != b'\\') {
4236            pipes.push(i);
4237        }
4238    }
4239    match (pipes.get(col).copied(), pipes.get(col + 1).copied()) {
4240        (Some(open), Some(close)) => {
4241            let lo = open + 1; // just inside the opening pipe
4242            let hi = close.saturating_sub(1); // just inside the closing pipe
4243            let inside = if hi < lo {
4244                lo
4245            } else {
4246                (open + 2).clamp(lo, hi)
4247            };
4248            row_start + inside
4249        }
4250        (Some(open), None) => row_start + open + 1,
4251        _ => row_start,
4252    }
4253}
4254
4255/// One laid-out table cell: its rendered text, the source range that text
4256/// occupies (`start`/`end` are the caret anchors decoration points at), and the
4257/// column alignment its padding honours.
4258///
4259/// `glyphs` is the cell's inline content *unwrapped* — the box-drawn rows wrap
4260/// it to a column width, but a frontend laying the grid out itself needs the
4261/// text before that decision was made.
4262#[derive(Clone)]
4263pub struct TableCell {
4264    pub glyphs: Vec<Glyph>,
4265    pub start: usize,
4266    pub end: usize,
4267    pub align: Alignment,
4268}
4269
4270/// One row of a table's grid, as the document spells it — not as it's drawn.
4271#[derive(Clone)]
4272pub struct TableRow {
4273    /// A header row: drawn bold, and ruled off from the body below it.
4274    pub head: bool,
4275    pub cells: Vec<TableCell>,
4276}
4277
4278/// A table's structure, published alongside the box-drawn rows that spell it.
4279///
4280/// The rows in [`VisualMap::rows`] are the *default monospace* picture of a
4281/// table: every border a `│`, every column a whole number of character cells.
4282/// That picture is exactly right on any monospace surface, and unfixable off one
4283/// — in a proportional font the `│`s of two rows land at different x and the grid
4284/// shears. So a frontend that draws its own geometry reads this instead: the
4285/// cells, their alignment, and which rows are the head, with no opinion about
4286/// how wide a column is or what a border looks like.
4287///
4288/// Both are always built. The TUI paints `rows` and ignores this; the GUI skips
4289/// `rows` for the span in `rows_span` and draws from here. They describe the
4290/// same cells, so the caret lands on the same offsets either way.
4291#[derive(Clone)]
4292pub struct TableInfo {
4293    /// The `VisualMap::rows` this table's picture occupies, borders included —
4294    /// what a frontend drawing its own table skips over.
4295    pub rows_span: Range<usize>,
4296    /// The source span of the table node, and the offset its trailing caret
4297    /// stop sits at.
4298    pub end_src: usize,
4299    /// The block prefix every row of this table carries — a blockquote's `│ `
4300    /// gutter, a list item's indent. Empty for a table at the top level.
4301    ///
4302    /// A frontend drawing its own grid has to render this and start the table
4303    /// past it, exactly as the picture does; a table nested in a quote that
4304    /// draws flush at the left margin has left the quote.
4305    pub prefix: Vec<Glyph>,
4306    pub grid: Vec<TableRow>,
4307}
4308
4309/// A fenced or indented code block, named by the [`VisualMap::rows`] it occupies.
4310///
4311/// Unlike a table, the rows *are* the block's content — a frontend still paints
4312/// them, it just draws a border and a tinted background around the whole span
4313/// and lets the code inside scroll horizontally instead of wrapping. So this
4314/// carries only the row range; there's no structural alternative to the picture
4315/// the way [`TableInfo`] is one. Derived from [`VRow::code`] — see
4316/// [`code_block_spans`].
4317#[derive(Clone, Debug, PartialEq, Eq)]
4318pub struct CodeBlockInfo {
4319    /// The contiguous run of [`VisualMap::rows`] this code block spans, blank
4320    /// code lines included.
4321    pub rows_span: Range<usize>,
4322    /// The block's language, from a fenced block's info string — what a frontend
4323    /// paints as a small label on the box (`` ```rust `` → `Some("rust")`).
4324    /// `None` for a fence written without one, or an indented block. Editing it
4325    /// goes through [`crate::Doc::set_code_language`], which re-finds the fence
4326    /// in the AST, so this stays a display string.
4327    pub lang: Option<String>,
4328}
4329
4330/// A block-level image (`![alt](url)` on its own line), named by the single
4331/// [`VisualMap::rows`] row it occupies.
4332///
4333/// Like [`CodeBlockInfo`], the row *is* the block's default rendering — a plain
4334/// surface paints the `🖼 alt` placeholder glyphs as-is. An image-capable
4335/// frontend instead **skips the row in `rows_span`** and paints the resolved
4336/// picture there, exactly as it skips a [`TableInfo`]'s box-drawn rows. Derived
4337/// from [`VRow::media`] by [`media_spans`], so it survives the row reuse of
4338/// [`BlockCache`] and [`build_spliced`].
4339#[derive(Clone, Debug, PartialEq, Eq)]
4340pub struct MediaInfo {
4341    /// The [`VisualMap::rows`] rows this media's placeholder occupies — what a
4342    /// capable frontend replaces with the picture or player.
4343    pub rows_span: Range<usize>,
4344    /// Whether this is a picture, a movie, or a sound — which widget the
4345    /// frontend builds over [`rows_span`](MediaInfo::rows_span). A frontend that
4346    /// handles only some kinds leaves the rest as core's placeholder rows, which
4347    /// already read sensibly on their own.
4348    pub kind: MediaKind,
4349    /// The media's link destination — a path, URL, or `data:` URI, verbatim from
4350    /// the AST. A frontend resolves a relative path against the document's own
4351    /// directory; core does no I/O. For a `<picture>` this is the `<img>`
4352    /// fallback — the source used when no [`sources`](MediaInfo::sources) media
4353    /// query matches (or the frontend has no theme). Empty when a `<video>`/
4354    /// `<audio>` carries no `src` and names its candidates in `<source>`s
4355    /// instead; [`resolve`](MediaInfo::resolve) already accounts for that.
4356    pub destination: String,
4357    /// The `<source>` alternatives in document order, or empty for a plain
4358    /// image. See [`MediaSource`]; a theme- or codec-aware frontend picks one and
4359    /// otherwise loads [`destination`](MediaInfo::destination).
4360    pub sources: Vec<MediaSource>,
4361    /// The media's alt text, flattened from its inline children (empty when it
4362    /// has none).
4363    pub alt: String,
4364    /// A `<video poster="…">`'s still frame, or empty when there is none — an
4365    /// image destination, resolved exactly as [`destination`] is.
4366    ///
4367    /// [`destination`]: MediaInfo::destination
4368    pub poster: String,
4369}
4370
4371/// One leaf directive (`::name{…}`) as a frontend sees it: which rows its
4372/// placeholder occupies, its type, and its attributes. A plain surface paints
4373/// the `⧉ name` placeholder glyphs as-is; a frontend that knows the host app's
4374/// vocabulary **skips the rows in `rows_span`** and paints the real thing there,
4375/// exactly as an image-capable one does with [`MediaInfo`]. Derived from
4376/// [`VRow::leaf_directive`] by [`directive_spans`].
4377///
4378/// Core resolves nothing here — it has no idea what an `embed` or a `toc` is,
4379/// and deliberately so: the directive vocabulary belongs to the app on top.
4380#[derive(Clone, Debug, PartialEq, Eq)]
4381pub struct DirectiveInfo {
4382    /// The [`VisualMap::rows`] rows this directive's placeholder occupies — the
4383    /// label row plus any blank fillers under it.
4384    pub rows_span: Range<usize>,
4385    /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
4386    pub name: String,
4387    /// Its `{…}` attributes in source order; a bare one has a `None` value.
4388    pub attrs: Vec<(String, Option<String>)>,
4389    /// Its `[label]` text, flattened from its inline children (empty when it has
4390    /// none) — what the placeholder row shows.
4391    pub label: String,
4392}
4393
4394impl DirectiveInfo {
4395    /// The value of attribute `key`, if it has one with a value. The convenience
4396    /// a frontend reaches for first (`info.attr("src")`), since almost every
4397    /// directive that draws as something real is pointed at by one attribute.
4398    pub fn attr(&self, key: &str) -> Option<&str> {
4399        self.attrs
4400            .iter()
4401            .find(|(k, _)| k == key)
4402            .and_then(|(_, v)| v.as_deref())
4403    }
4404}
4405
4406impl MediaInfo {
4407    /// The image URL to load under `scheme`: the first [`sources`] `<source>`
4408    /// whose media query matches, else the [`destination`] `<img>` fallback. The
4409    /// pick is a `<source>`'s first `srcset` URL or the destination — a frontend
4410    /// resolves whichever it gets against the document directory exactly as it
4411    /// resolves `destination`, and reserves/keys the picture under `destination`
4412    /// regardless, so a theme switch just re-picks without disturbing the layout.
4413    ///
4414    /// Only `prefers-color-scheme` is understood (that's what a light/dark banner
4415    /// uses); a `<source>` with any other media query is skipped, and one with no
4416    /// media at all always matches (an unconditional override). With no matching
4417    /// source — including every frontend that can't/doesn't theme and passes
4418    /// [`ColorScheme::Light`] to a dark-only picture — it's the plain `<img>`.
4419    ///
4420    /// [`sources`]: MediaInfo::sources
4421    /// [`destination`]: MediaInfo::destination
4422    pub fn resolve(&self, scheme: ColorScheme) -> &str {
4423        if let Some(url) = self
4424            .sources
4425            .iter()
4426            .find(|s| media_matches(&s.media, scheme))
4427            .and_then(|s| first_srcset_url(&s.srcset))
4428        {
4429            return url;
4430        }
4431        // A `<video>`/`<audio>` may carry no `src` of its own, naming its
4432        // candidates only in child `<source>`s — none of which matched above,
4433        // because a codec-typed `<source>` has no media query and core judges no
4434        // MIME types. Falling through to an empty destination would hand the
4435        // frontend nothing to load, so take the first candidate URL instead and
4436        // let the frontend reject it if it can't decode it. An `<img>` never
4437        // reaches this: its `src` is the picture.
4438        if self.destination.is_empty()
4439            && let Some(url) = self
4440                .sources
4441                .iter()
4442                .find_map(|s| first_srcset_url(&s.srcset))
4443        {
4444            return url;
4445        }
4446        &self.destination
4447    }
4448
4449    /// The **still picture** that stands for this media under `scheme`, for a
4450    /// frontend that can rasterize an image but not play a movie — a terminal, or
4451    /// a GUI still growing its player. `None` when there is no picture to draw,
4452    /// which is the honest answer for audio and for a poster-less video: the
4453    /// caller leaves core's labelled placeholder row, which already reads as
4454    /// *a thing that isn't text*.
4455    ///
4456    /// This exists so those frontends never hand a `.mp4` to an image decoder.
4457    /// That fails harmlessly today (a failed decode falls back to the same
4458    /// placeholder), but it spends a file read and a decode attempt per frame to
4459    /// arrive where this gets in one match.
4460    pub fn still(&self, scheme: ColorScheme) -> Option<&str> {
4461        match self.kind {
4462            MediaKind::Image => Some(self.resolve(scheme)),
4463            // A `poster` is an image destination, so it resolves the same way —
4464            // but it is named directly and has no `<source>` alternatives of its
4465            // own, so it needs no theme matching.
4466            MediaKind::Video if !self.poster.is_empty() => Some(&self.poster),
4467            MediaKind::Video | MediaKind::Audio => None,
4468        }
4469    }
4470}
4471
4472/// A frontend's active color scheme — what a `<picture>`'s `prefers-color-scheme`
4473/// `<source>`s are matched against by [`MediaInfo::resolve`]. A frontend with no
4474/// notion of theme passes [`Light`](ColorScheme::Light), the web's own default.
4475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4476pub enum ColorScheme {
4477    Light,
4478    Dark,
4479}
4480
4481/// Whether a `<source media="…">` query applies under `scheme`. Empty media is
4482/// an unconditional `<source>` (always matches); otherwise only a
4483/// `prefers-color-scheme: dark|light` feature is understood — anything else
4484/// (a width query, `print`, …) doesn't match, so resolution falls through to the
4485/// next source or the `<img>`. Deliberately lax about the surrounding syntax
4486/// (`(prefers-color-scheme: dark)`, `screen and (prefers-color-scheme:dark)`):
4487/// it keys off the feature and its value, which is all the theme case needs.
4488fn media_matches(media: &str, scheme: ColorScheme) -> bool {
4489    let media = media.trim();
4490    if media.is_empty() {
4491        return true;
4492    }
4493    let lower = media.to_ascii_lowercase();
4494    let Some(after) = lower
4495        .split_once("prefers-color-scheme")
4496        .map(|(_, rest)| rest)
4497    else {
4498        return false;
4499    };
4500    // Skip the `:` and any spaces to reach the value word.
4501    let value = after.trim_start_matches([':', ' ', '\t']);
4502    let wanted = match scheme {
4503        ColorScheme::Light => "light",
4504        ColorScheme::Dark => "dark",
4505    };
4506    value.starts_with(wanted)
4507}
4508
4509/// The first URL in a `srcset`: its first comma-separated candidate, before any
4510/// `1x`/`2x`/width descriptor. The theme case only ever puts one URL per
4511/// `<source>`, so the first candidate is the picture.
4512fn first_srcset_url(srcset: &str) -> Option<&str> {
4513    let first = srcset.split(',').next()?.trim();
4514    first.split_whitespace().next().filter(|u| !u.is_empty())
4515}
4516
4517/// The narrowest a column may be squeezed. Below a few characters a column
4518/// stops carrying text and just shreds it one letter per line, which is worse
4519/// than letting the grid run wide.
4520const MIN_COL_WIDTH: usize = 3;
4521
4522/// Shrink `widths` until the grid fits `avail` screen columns, taking from the
4523/// widest column each time so the loss is shared out rather than falling on
4524/// whichever column happens to be last. No column goes below
4525/// [`MIN_COL_WIDTH`]; a table with more columns than the surface has room for
4526/// still overflows, which is the honest outcome — there's nothing left to give.
4527fn fit_widths(widths: &mut [usize], avail: usize) {
4528    // Chrome: each column is its content plus a gutter either side, and every
4529    // column is closed by a `│` — with one more opening the row.
4530    let budget = avail.saturating_sub(3 * widths.len() + 1);
4531    while widths.iter().sum::<usize>() > budget {
4532        let Some(w) = widths.iter_mut().filter(|w| **w > MIN_COL_WIDTH).max() else {
4533            return;
4534        };
4535        *w -= 1;
4536    }
4537}
4538
4539/// Word-wrap `glyphs` into lines of at most `width` columns, hard-breaking any
4540/// single word too long to fit.
4541///
4542/// Unlike a paragraph — where an overlong word just trails off the end of the
4543/// line — a table column is a hard boundary: a glyph past it lands on top of
4544/// the border, or on the next cell. So the width here is a promise, and a word
4545/// that won't keep it is broken.
4546///
4547/// The space at a break is dropped rather than hung past the edge. Its offset
4548/// isn't lost: the caller gives every line an end stop just past its last
4549/// glyph, which is exactly where that space was.
4550///
4551/// `width` is in display columns, and a break only ever falls between grapheme
4552/// clusters. Both matter to more than the picture: the caller anchors each
4553/// line's end stop just past its last glyph, so a line cut mid-cluster would
4554/// put a caret stop inside a character — reachable by Down or a click, and the
4555/// next Backspace would take the cluster apart from the middle.
4556///
4557/// An explicit in-cell break (a `\n` glyph, from a `<br>`) is a hard boundary:
4558/// each run between the breaks wraps on its own and the results stack. The break
4559/// glyphs are dropped — the caller's per-line end stop already sits exactly where
4560/// each break was, so no offset is lost.
4561fn wrap_glyphs(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
4562    if glyphs.iter().any(|g| g.ch == '\n') {
4563        return glyphs
4564            .split(|g| g.ch == '\n')
4565            .flat_map(|seg| wrap_segment(seg, width))
4566            .collect();
4567    }
4568    wrap_segment(glyphs, width)
4569}
4570
4571/// [`wrap_glyphs`] for a run with no explicit breaks — the word-wrap proper.
4572fn wrap_segment(glyphs: &[Glyph], width: usize) -> Vec<Vec<Glyph>> {
4573    let width = width.max(1);
4574    // Words are maximal non-space runs, each carrying the space that followed it
4575    // — which survives only if the next word joins it on this line.
4576    let mut words: Vec<(Vec<Glyph>, Option<Glyph>)> = Vec::new();
4577    let mut word: Vec<Glyph> = Vec::new();
4578    for g in glyphs {
4579        if g.ch == ' ' {
4580            words.push((std::mem::take(&mut word), Some(g.clone())));
4581        } else {
4582            word.push(g.clone());
4583        }
4584    }
4585    if !word.is_empty() {
4586        words.push((word, None));
4587    }
4588
4589    let mut lines: Vec<Vec<Glyph>> = Vec::new();
4590    let mut line: Vec<Glyph> = Vec::new();
4591    let mut used = 0usize;
4592    let mut gap: Option<Glyph> = None;
4593    for (word, space) in words {
4594        for chunk in hard_break(&word, width) {
4595            let sep = gap.is_some() as usize;
4596            let cells = glyphs_width(chunk);
4597            if !line.is_empty() && used + sep + cells > width {
4598                lines.push(std::mem::take(&mut line));
4599                used = 0;
4600                gap = None; // the break swallows the space
4601            }
4602            if let Some(sp) = gap.take() {
4603                line.push(sp);
4604                used += 1;
4605            }
4606            line.extend_from_slice(chunk);
4607            used += cells;
4608        }
4609        gap = space;
4610    }
4611    // An empty cell is still one (empty) line — it has an end the caret can
4612    // sit at, which is how you type into it.
4613    if !line.is_empty() || lines.is_empty() {
4614        lines.push(line);
4615    }
4616    lines
4617}
4618
4619/// Break a single word into pieces of at most `width` columns, cutting only
4620/// between grapheme clusters — the replacement for slicing it into fixed runs
4621/// of glyphs, which measures a wide character as one column and can cut an
4622/// emoji in half.
4623///
4624/// A cluster wider than the whole column still gets a piece to itself: there is
4625/// nowhere legal to cut it, and overflowing by a cell is better than splitting a
4626/// character. An empty word yields no pieces at all, which is what keeps a
4627/// double space from opening a line of its own.
4628fn hard_break(word: &[Glyph], width: usize) -> Vec<&[Glyph]> {
4629    let mut out = Vec::new();
4630    if word.is_empty() {
4631        return out;
4632    }
4633    let (mut start, mut used) = (0usize, 0usize);
4634    for c in clusters(word) {
4635        if used > 0 && used + c.cells > width {
4636            out.push(&word[start..c.glyph]);
4637            start = c.glyph;
4638            used = 0;
4639        }
4640        used += c.cells;
4641    }
4642    out.push(&word[start..]);
4643    out
4644}
4645
4646/// A table rule spanning `widths`, e.g. `┌──────┬─────┐`. Each column is its
4647/// content width plus the one-space gutter on either side.
4648fn rule_text(widths: &[usize], left: char, mid: char, right: char) -> String {
4649    let mut s = String::new();
4650    s.push(left);
4651    for (i, w) in widths.iter().enumerate() {
4652        if i > 0 {
4653            s.push(mid);
4654        }
4655        for _ in 0..w + 2 {
4656            s.push('─');
4657        }
4658    }
4659    s.push(right);
4660    s
4661}
4662
4663/// Push real document text: each glyph maps to its own source byte, and the one
4664/// that opens a grapheme cluster is the caret stop for the whole cluster.
4665///
4666/// Per cluster rather than per codepoint because a cluster is the character the
4667/// user sees, and it's the unit backspace and delete already step by. A stop
4668/// inside 👨‍👩‍👧 — five codepoints strung together with joiners — is a caret
4669/// parked in the middle of a character: one press of Right lands there, and the
4670/// next Backspace severs a joiner from what it joined, leaving a dangling ZWJ in
4671/// the source. The rest of the cluster still gets its glyph (it has to be
4672/// drawn); it just isn't somewhere to stand.
4673fn push_text(out: &mut Vec<Glyph>, text: &str, base_src: usize, style: Style) {
4674    for (gi, cluster) in text.grapheme_indices(true) {
4675        for (ci, ch) in cluster.char_indices() {
4676            out.push(Glyph {
4677                ch,
4678                style,
4679                src: base_src + gi + ci,
4680                stop: ci == 0,
4681            });
4682        }
4683    }
4684}
4685
4686/// [`push_text`] for one line of a highlighted code block: the same glyphs at
4687/// the same offsets, each additionally carrying the [`Token`] of the span it
4688/// falls in — `spans` being the line's entry from [`code_tokens`], ascending
4689/// byte ranges *into `text`*. A byte between spans keeps `style` as it is.
4690///
4691/// Offsets are what matters here: a token changes how a glyph is painted and
4692/// nothing about where it is or which source byte it stands on, so a caret
4693/// walks a highlighted block exactly as it walks an unhighlighted one.
4694fn push_code_text(
4695    out: &mut Vec<Glyph>,
4696    text: &str,
4697    base_src: usize,
4698    style: Style,
4699    spans: &[(Range<usize>, Token)],
4700) {
4701    let mut spans = spans.iter().peekable();
4702    for (gi, cluster) in text.grapheme_indices(true) {
4703        // Spans are ascending, so the one covering this cluster's first byte
4704        // is at or after the one that covered the last; step past those ended.
4705        while spans.peek().is_some_and(|(r, _)| r.end <= gi) {
4706            spans.next();
4707        }
4708        let token = spans
4709            .peek()
4710            .filter(|(r, _)| r.contains(&gi))
4711            .map(|(_, t)| *t);
4712        // A cluster is classed whole, by its first byte: a grammar that split
4713        // an emoji's scalars between two tokens would otherwise split the
4714        // glyph, and no grammar means to.
4715        let style = style.token(token);
4716        for (ci, ch) in cluster.char_indices() {
4717            out.push(Glyph {
4718                ch,
4719                style,
4720                src: base_src + gi + ci,
4721                stop: ci == 0,
4722            });
4723        }
4724    }
4725}
4726
4727/// One line's highlighting — `crate::syntax::LineTokens`, spelled here so the
4728/// shape exists whether or not the feature that fills it does.
4729type LineTokens = Vec<(Range<usize>, Token)>;
4730
4731/// The syntax highlighting for a code block's lines, or `None` when the fence's
4732/// language is not one the grammars know. Without the `syntax` feature nothing
4733/// is known, and every code glyph draws in the plain code colour.
4734#[cfg(feature = "syntax")]
4735fn code_tokens(lang: &str, lines: &[&str]) -> Option<Vec<LineTokens>> {
4736    crate::syntax::highlight(lang, lines)
4737}
4738
4739#[cfg(not(feature = "syntax"))]
4740fn code_tokens(_lang: &str, _lines: &[&str]) -> Option<Vec<LineTokens>> {
4741    None
4742}
4743
4744/// Emit an inline `str`/`smart_punctuation` run, mapping every visible char back
4745/// to its *true* source byte even when the source carries backslash escapes the
4746/// parsed `text` dropped (`\*` → `*`). The naive `span.start + text_offset`
4747/// mapping [`push_text`] uses drifts by one byte after each escape, so a caret or
4748/// click past an escaped `*` would land on the wrong character; walking the text
4749/// against its source keeps them aligned, and the hidden escape backslash gets no
4750/// glyph of its own (it is a spelling artefact, not something the caret lands on).
4751fn push_escaped_text(
4752    out: &mut Vec<Glyph>,
4753    text: &str,
4754    span: Range<usize>,
4755    source: &str,
4756    style: Style,
4757) {
4758    let end = span.end.min(source.len());
4759    let src = source.get(span.start..end).unwrap_or("");
4760    // Fast path — no dropped bytes, so text and source align 1:1 (the common
4761    // case: prose with no escapes). Byte lengths equal ⇒ no backslash was eaten.
4762    if src.len() == text.len() {
4763        push_text(out, text, span.start, style);
4764        return;
4765    }
4766    // Slow path: some `\` was consumed. Walk char-by-char, skipping a backslash
4767    // in the source exactly when it escapes the next visible char (a real escape),
4768    // never when it is a literal backslash the parse kept (that case has equal
4769    // lengths and takes the fast path above).
4770    let sb = src.as_bytes();
4771    let mut si = 0usize;
4772    'text: for (_, cluster) in text.grapheme_indices(true) {
4773        for (ci, ch) in cluster.char_indices() {
4774            // The text outlasted the source it is being mapped onto. In a
4775            // consistent document that cannot happen on this path: the slow path
4776            // is only entered when the two lengths differ, and everything that
4777            // makes them differ makes the *source* the longer one — an escape
4778            // backslash the parse ate, or source folded into a neighbouring node.
4779            // A `smart_punctuation` node reports its canonical ASCII spelling
4780            // (`--`, `...`, `"`), which is never longer than what was written.
4781            //
4782            // So reaching here means `span` was measured against a document that
4783            // `source` is no longer, and there is no honest offset left to give
4784            // the remaining characters. Stop: the row comes out short, which is
4785            // a wrong picture of a document that is already inconsistent. The
4786            // alternative was `si` stepping past the end and the slice below
4787            // panicking — which is what it did, in a paint loop.
4788            if si >= sb.len() {
4789                break 'text;
4790            }
4791            // Advance to the source character this one came from, stepping over
4792            // whatever the parse dropped on the way. An escape backslash is the
4793            // common case, but not the only one: a span can cover source that
4794            // was folded into a neighbouring node (smart punctuation next to a
4795            // bracket gives `text: "]"` over a source span of `"…]"`). Advancing
4796            // by the *text* character's length assumed escapes were the only
4797            // divergence, so one dropped multi-byte character desynchronized
4798            // every glyph after it — placing `]` inside the `…` before it.
4799            while si < sb.len() && !src[si..].starts_with(ch) {
4800                si += src[si..].chars().next().map_or(1, char::len_utf8);
4801            }
4802            out.push(Glyph {
4803                ch,
4804                style,
4805                src: span.start + si.min(src.len()),
4806                stop: ci == 0,
4807            });
4808            si += src[si..]
4809                .chars()
4810                .next()
4811                .map_or(ch.len_utf8(), char::len_utf8);
4812        }
4813    }
4814}
4815
4816/// Build synthetic decoration glyphs (a bullet, a gutter) all pointing at `src`,
4817/// each carrying `role` so the frontend can style it (`Role::Body` for plain
4818/// padding). Synthetic glyphs are never caret stops — they share one offset, so
4819/// the caret steps over them (a click still lands at `src`).
4820fn synth(text: &str, role: Role, src: usize) -> Vec<Glyph> {
4821    let style = Style::default().role(role);
4822    text.chars()
4823        .map(|ch| Glyph {
4824            ch,
4825            style,
4826            src,
4827            stop: false,
4828        })
4829        .collect()
4830}
4831
4832fn concat(a: &[Glyph], b: &[Glyph]) -> Vec<Glyph> {
4833    let mut v = a.to_vec();
4834    v.extend_from_slice(b);
4835    v
4836}
4837
4838/// The columns a row's prefix (a bullet, a quote gutter, an indent) takes up
4839/// before the text it introduces — what the wrap budget has left to spend.
4840fn prefix_width(prefix: &[Glyph]) -> usize {
4841    glyphs_width(prefix)
4842}
4843
4844/// The label shown for an image with no alt text: the final path segment of its
4845/// destination (`img/cat.png` → `cat.png`), the whole destination when it has no
4846/// separator, and `"image"` when it's empty. A `data:` URI (which has no useful
4847/// tail) shows its scheme so the placeholder isn't a wall of base64.
4848fn media_label(dest: &str) -> String {
4849    if dest.is_empty() {
4850        return "image".to_string();
4851    }
4852    if dest.starts_with("data:") {
4853        return "data:…".to_string();
4854    }
4855    // Trim a query/fragment so a URL's `?v=2#frag` doesn't ride along.
4856    let clean = dest.split(['?', '#']).next().unwrap_or(dest);
4857    let tail = clean
4858        .trim_end_matches('/')
4859        .rsplit(['/', '\\'])
4860        .next()
4861        .unwrap_or(clean);
4862    if tail.is_empty() {
4863        dest.to_string()
4864    } else {
4865        tail.to_string()
4866    }
4867}
4868
4869/// A directive's attributes read as a human label — what a frontend puts on a
4870/// container's tinted panel, and what an attribute-bearing inline directive
4871/// shows in its chip.
4872///
4873/// Reads BOTH conventions diaryx content actually uses: twig's own dot-prefixed
4874/// classes (`{.public .family}`, arriving as one combined `class` attr) and bare
4875/// pandoc-style words with no leading dot (`{public family}` — what
4876/// `diaryx_core::visibility`'s publish-time filter and apps/web's directive
4877/// serializer both write, and which twig parses as one valueless attribute
4878/// each). Reading only `.class` would leave every *existing* diaryx `:::vis{…}`
4879/// block unlabeled. A `key=value` attr is configuration rather than a name, so
4880/// it contributes nothing. `None` when nothing readable is left.
4881fn directive_attr_label(attrs: &[(String, Option<String>)]) -> Option<String> {
4882    let mut parts: Vec<String> = Vec::new();
4883    for (k, v) in attrs {
4884        if k == "class" {
4885            if let Some(v) = v
4886                && !v.is_empty()
4887            {
4888                parts.push(v.clone());
4889            }
4890        } else if v.as_deref().unwrap_or("").is_empty() {
4891            parts.push(k.clone());
4892        }
4893    }
4894    (!parts.is_empty()).then(|| parts.join(" "))
4895}
4896
4897fn heading_style(level: u32) -> Style {
4898    // Just the role — a frontend decides how a heading of this level *looks*
4899    // (the terminal cycles a color and bolds it, the GUI scales the font). The
4900    // author wrote no emphasis here, so core records none. `level as u8` is safe:
4901    // Markdown/Djot cap headings at 6.
4902    Style::default().role(Role::Heading(level.min(255) as u8))
4903}
4904
4905/// Is this `container` node a *directive* (`:::note{…}`, `::embed{…}`,
4906/// `:vis[…]`) rather than an HTML element (`<video>`, `<picture>`, `<div>`)?
4907///
4908/// twig 2.8 folded `div`/`span`/`directive`/`element` into one `container` kind,
4909/// and left nothing that separated them: `kind`, `name` and `directive_form` all
4910/// agree, field for field, on an HTML `<div>` and a Markdown `:::div`. Leaf
4911/// answered it by sniffing the span for whichever of `:` or `<` came first.
4912/// twig 3.0 records the answer at parse time as [`ContainerOrigin`], so this is
4913/// now the parser's own knowledge rather than a guess rebuilt from the bytes it
4914/// consumed.
4915pub(crate) fn container_is_directive(node: &FlatNode) -> bool {
4916    node.origin == Some(ContainerOrigin::Directive)
4917}
4918
4919/// The tag a `container` node carries when it is an HTML element rather than a
4920/// directive — `Some("video")` for a promoted `<video>`, `None` for a `:::note`
4921/// or for any node that is not a container at all.
4922pub(crate) fn element_tag(node: &FlatNode) -> Option<&str> {
4923    (node.origin == Some(ContainerOrigin::Element))
4924        .then_some(node.name.as_deref())
4925        .flatten()
4926}
4927
4928pub(crate) fn is_inline(node: &FlatNode) -> bool {
4929    // A directive is inline only in its `text` form (`:name[label]{…}`); the
4930    // `leaf` and `container` forms are blocks. All three report the same `kind`,
4931    // so the form is the only thing telling them apart — and getting it wrong
4932    // costs a whole paragraph: a text directive misread as a block makes its
4933    // paragraph fail the "all children inline" test in `block`, and the line is
4934    // then walked as a container of blocks, rendering as empty rows with no
4935    // caret home at all.
4936    //
4937    // An HTML element shares the `container` kind but never the `text` form, so
4938    // it answers `false` here and is walked as the block it is.
4939    if node.kind == Kind::Container {
4940        return container_is_directive(node) && node.directive_form == Some(DirectiveForm::Text);
4941    }
4942    is_inline_kind(&node.kind)
4943}
4944
4945/// [`is_inline`] by kind alone — for the ancestor walks, whose `QueryMatch`es
4946/// carry no `directive_form`. It answers `false` for every directive, which its
4947/// callers must (and do) reconcile: they pair it with `is_block_container`,
4948/// which claims every directive, so the pair's verdict is the same one a form
4949/// would have given. Anything looking at a *directive itself* wants [`is_inline`]
4950/// and a real node.
4951pub(crate) fn is_inline_kind(kind: &Kind) -> bool {
4952    matches!(
4953        kind,
4954        Kind::Str
4955            | Kind::SoftBreak
4956            | Kind::HardBreak
4957            | Kind::NonBreakingSpace
4958            | Kind::Emph
4959            | Kind::Strong
4960            | Kind::Mark
4961            | Kind::Insert
4962            | Kind::Delete
4963            | Kind::Verbatim
4964            | Kind::InlineMath
4965            | Kind::DisplayMath
4966            | Kind::Url
4967            | Kind::Email
4968            | Kind::Link
4969            | Kind::Image
4970            | Kind::SmartPunctuation
4971            | Kind::Superscript
4972            | Kind::Subscript
4973            | Kind::FootnoteReference
4974    )
4975}
4976
4977/// Assert two maps are identical down to every glyph, stop, and table span — the
4978/// contract `build_cached` and `build_spliced` must hold against `build`. Lives
4979/// at module scope (not in `mod tests`) so the Doc-driven differential test in
4980/// `doc.rs` can reach it and the private `stops` field it compares.
4981#[cfg(test)]
4982pub(crate) fn assert_maps_eq(a: &VisualMap, b: &VisualMap, ctx: &str) {
4983    assert_eq!(a.rows.len(), b.rows.len(), "row count ({ctx})");
4984    for (i, (ra, rb)) in a.rows.iter().zip(&b.rows).enumerate() {
4985        assert_eq!(ra.end_src, rb.end_src, "row {i} end_src ({ctx})");
4986        assert_eq!(ra.decoration, rb.decoration, "row {i} decoration ({ctx})");
4987        // The incremental walk labels a boundary from a query match's kind
4988        // string and the whole-arena walk from a `FlatNode`'s; this is what says
4989        // the two doors reach the same answer.
4990        assert_eq!(ra.boundary, rb.boundary, "row {i} boundary ({ctx})");
4991        assert_eq!(ra.code, rb.code, "row {i} code ({ctx})");
4992        assert_eq!(ra.code_lang, rb.code_lang, "row {i} code_lang ({ctx})");
4993        assert_eq!(
4994            ra.glyphs.len(),
4995            rb.glyphs.len(),
4996            "row {i} glyph count ({ctx})"
4997        );
4998        for (j, (ga, gb)) in ra.glyphs.iter().zip(&rb.glyphs).enumerate() {
4999            assert_eq!(
5000                (ga.ch, ga.src, ga.stop, ga.style),
5001                (gb.ch, gb.src, gb.stop, gb.style),
5002                "row {i} glyph {j} ({ctx})"
5003            );
5004        }
5005    }
5006    assert_eq!(a.content_start, b.content_start, "content_start ({ctx})");
5007    assert_eq!(a.stops, b.stops, "stops ({ctx})");
5008    assert_eq!(a.tables.len(), b.tables.len(), "table count ({ctx})");
5009    for (i, (ta, tb)) in a.tables.iter().zip(&b.tables).enumerate() {
5010        assert_eq!(ta.rows_span, tb.rows_span, "table {i} rows_span ({ctx})");
5011        assert_eq!(ta.end_src, tb.end_src, "table {i} end_src ({ctx})");
5012    }
5013    assert_eq!(a.code_blocks, b.code_blocks, "code_blocks ({ctx})");
5014    assert_eq!(a.media, b.media, "images ({ctx})");
5015}
5016
5017#[cfg(test)]
5018mod tests {
5019    use super::*;
5020    use twig::{Editor, Format, NodeId};
5021
5022    fn map(src: &str) -> VisualMap {
5023        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5024        build_t(&ed.nodes().unwrap(), src, Some(80))
5025    }
5026
5027    /// [`map`] over a Djot source. Djot is the format that spells superscript
5028    /// and subscript at all — Markdown has no syntax for either.
5029    fn map_djot(src: &str) -> VisualMap {
5030        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5031        build_t(&ed.nodes().unwrap(), src, Some(80))
5032    }
5033
5034    /// The baseline every glyph spelling `ch` was built with, in row order —
5035    /// how a test reads a raised or lowered run off the map without caring
5036    /// which row it landed on.
5037    fn baselines_of(m: &VisualMap, ch: char) -> Vec<Baseline> {
5038        m.rows
5039            .iter()
5040            .flat_map(|r| r.glyphs.iter())
5041            .filter(|g| g.ch == ch)
5042            .map(|g| g.style.baseline)
5043            .collect()
5044    }
5045
5046    /// [`map`] at a chosen wrap width.
5047    fn map_at(src: &str, wrap: Option<usize>) -> VisualMap {
5048        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5049        build_t(&ed.nodes().unwrap(), src, wrap)
5050    }
5051
5052    /// [`map`], but with twig's `directives` extension on (off by twig's own
5053    /// default) — the `:::name{.class}` fenced-div containers leaf-core's
5054    /// `"directive"` wysiwyg arm renders.
5055    fn map_directives(src: &str) -> VisualMap {
5056        let mut ed = Editor::new_ext(
5057            src.as_bytes(),
5058            Format::Markdown,
5059            twig::MarkdownExtensions {
5060                directives: true,
5061                ..Default::default()
5062            },
5063        )
5064        .unwrap();
5065        build_t(&ed.nodes().unwrap(), src, Some(80))
5066    }
5067
5068    /// [`map`] with soft breaks preserved (`LineFlow::Preserve`).
5069    fn map_preserve(src: &str, wrap: Option<usize>) -> VisualMap {
5070        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5071        build(&ed.nodes().unwrap(), src, wrap, true, &HashMap::new(), None)
5072    }
5073
5074    /// The cache-free reference [`build`], with no per-image height overrides —
5075    /// every block image stays its default one-row placeholder. The tests that
5076    /// need a taller image drive it through [`crate::Doc::set_media_rows`] instead.
5077    fn build_t(nodes: &[FlatNode], src: &str, wrap: Option<usize>) -> VisualMap {
5078        build(nodes, src, wrap, false, &HashMap::new(), None)
5079    }
5080
5081    /// An arena and a string that disagree — spans reaching past the source they
5082    /// are built against.
5083    ///
5084    /// [`crate::Doc`] keeps the two in step, so this is a "cannot happen" that
5085    /// nonetheless *did*: `examples/bench` timed a loop of `edit_range` and then
5086    /// went on handing the grown editor's spans to a builder holding the string
5087    /// from before it, and every run ended in a slice panic rather than a
5088    /// number. `push_escaped_text` was already written to survive the mismatch —
5089    /// it clamps the span's end and falls back to an empty slice — and this is
5090    /// the half of that intent it did not carry through.
5091    ///
5092    /// Rendering the wrong thing is the acceptable answer here; panicking in a
5093    /// paint loop is not.
5094    #[test]
5095    fn a_source_shorter_than_the_arena_built_over_it_renders_rather_than_panicking() {
5096        // An escape puts the run on `push_escaped_text`'s slow path — the fast
5097        // path is a length comparison that a truncated source fails anyway.
5098        let src = "alpha \\*beta\\* gamma delta epsilon\n";
5099        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5100        let nodes = ed.nodes().unwrap();
5101
5102        // Every truncation of it, so the cut lands before, inside and after the
5103        // escaped run rather than only where one hand-picked index put it.
5104        for cut in 0..=src.len() {
5105            if !src.is_char_boundary(cut) {
5106                continue;
5107            }
5108            let map = build_t(&nodes, &src[..cut], Some(80));
5109            for row in &map.rows {
5110                for g in &row.glyphs {
5111                    assert!(
5112                        g.src <= src.len(),
5113                        "cut {cut}: glyph {:?} points past the source at {}",
5114                        g.ch,
5115                        g.src
5116                    );
5117                }
5118            }
5119        }
5120    }
5121
5122    fn rendered(m: &VisualMap) -> String {
5123        m.rows
5124            .iter()
5125            .map(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>())
5126            .collect::<Vec<_>>()
5127            .join("\n")
5128    }
5129
5130    /// Render a source both ways: `build` over the whole marshalled arena (the
5131    /// reference), and `build_cached` driven the way [`crate::Doc`] drives it —
5132    /// top-level blocks from `child_spans`, per-block subtrees on a miss.
5133    fn render_both(
5134        ed: &mut Editor,
5135        src: &str,
5136        wrap: Option<usize>,
5137        cache: &mut BlockCache,
5138    ) -> (VisualMap, VisualMap) {
5139        let all = ed.nodes().unwrap();
5140        let media_rows = HashMap::new();
5141        let plain = build(&all, src, wrap, false, &media_rows, None);
5142        let top = top_blocks(ed);
5143        let cached = build_cached(&top, src, wrap, false, &media_rows, None, cache, |id| {
5144            ed.subtree(NodeId(id)).unwrap_or_default()
5145        });
5146        (plain, cached)
5147    }
5148
5149    /// The whole correctness claim of the block cache: `build_cached` produces a
5150    /// byte-identical map to `build`, on a fresh cache *and* — the case that
5151    /// actually exercises reuse-and-shift plus per-block subtree marshalling — on
5152    /// a warm cache after the source has been edited underneath it.
5153    /// **Every glyph must stand on the character it claims.** A row's source
5154    /// extent is computed from its last glyph's offset, so a glyph carrying an
5155    /// offset that is not its own character's start yields a row end inside a
5156    /// multi-byte character — and every later slice of the source panics on it.
5157    ///
5158    /// Reproduces a real crash from a journal entry: a bracketed elision inside
5159    /// a blockquote (`[…]`) gave the closing bracket a `text` of `"]"` over a
5160    /// source span covering `"…]"`, because the parse folded the ellipsis into a
5161    /// neighbouring node. `push_escaped_text` walked that span assuming a
5162    /// dropped backslash was the only way text and source could diverge, so the
5163    /// `]` landed on the `…`'s first byte:
5164    /// `byte index 1236 is not a char boundary; it is inside '…'`.
5165    #[test]
5166    fn a_glyph_never_lands_inside_the_character_before_it() {
5167        let src = "> engage with it rather than look away. […]\n>\n> The through-line\n";
5168        let vmap = map(src);
5169        for (r, row) in vmap.rows.iter().enumerate() {
5170            assert!(
5171                src.is_char_boundary(row.end_src.min(src.len())),
5172                "row {r} ends at {} — inside a character",
5173                row.end_src
5174            );
5175            for g in &row.glyphs {
5176                assert!(
5177                    src.is_char_boundary(g.src.min(src.len())),
5178                    "row {r} has {:?} at {}, which is inside a character",
5179                    g.ch,
5180                    g.src
5181                );
5182            }
5183        }
5184        // The elision survives, and its bracket sits on the real `]`.
5185        let text: String = vmap
5186            .rows
5187            .iter()
5188            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
5189            .collect();
5190        assert!(text.contains("[…]"), "the elision should render: {text:?}");
5191        let close = vmap
5192            .rows
5193            .iter()
5194            .flat_map(|r| r.glyphs.iter())
5195            .find(|g| g.ch == ']')
5196            .expect("a closing bracket");
5197        assert_eq!(
5198            src[close.src..].chars().next(),
5199            Some(']'),
5200            "the bracket glyph should stand on the source's own `]`"
5201        );
5202    }
5203
5204    #[test]
5205    fn build_cached_matches_build() {
5206        let docs = [
5207            "# Title\n\nThe quick brown fox.\n\nAnother paragraph here.\n",
5208            "## H\n\n- one\n- two\n- three\n\n> a quote\n> continued\n",
5209            "para one\n\n```\ncode\nlines\n```\n\nafter code\n",
5210            "| a | b |\n|---|---|\n| 1 | 2 |\n\ntext after a table\n",
5211            "line\n- \nsetext?\n\nreal para\n\n\n\ntrailing blanks\n",
5212            "> quote with **bold** and a [link](https://x.dev)\n>\n> - item\n> - item2\n\ntail\n",
5213            "intro\n\n![a cat](img/cat.png)\n\nbetween\n\n![](https://x.dev/logo.svg)\n\nend\n",
5214            "- text item\n- ![alt](pic.png)\n- more text\n",
5215            // Footnotes: twig parses each definition as a root beside `doc`, so
5216            // these are the docs where the reference build and the incremental
5217            // one could disagree about what the top-level blocks even are.
5218            "A claim[^1] and another[^src].\n\n[^1]: First note.\n\n[^src]: Second.\n\ntail\n",
5219            "note[^a]\n\n[^a]: body **bold**\n    wrapped on\n    three lines\n\nafter\n",
5220            // No trailing newline. twig closes the document's last block on the
5221            // virtual newline it supplies at EOF, so that block's `span.end` is
5222            // `source.len() + 1` — a range that slices no bytes at all. Keying
5223            // the block cache off such a slice made every last block hash alike;
5224            // see [`block_bytes`].
5225            "# Title\n\nThe quick brown fox.\n\nA tail with no newline",
5226            "A claim[^1] and another[^src].\n\n[^1]: First note.\n[^src]: Second, ending the file.",
5227            // Comments draw nothing. The per-block builder the cached path
5228            // renders one with starts at offset 0 and, drawing nothing, never
5229            // moved — so the walk went on from 0 and spelled every line of the
5230            // document as a blank row. One at the start, one between blocks,
5231            // one at the end, so each position is covered.
5232            "<!-- lead -->\n\npara\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n\n<!-- trail -->\n",
5233            // Link reference definitions: roots beside `doc` like footnotes,
5234            // but drawing nothing. Alone between blocks, glued under a
5235            // paragraph, and closing the file under a comment — the README
5236            // shape.
5237            "see [a] and [b]\n\n[a]: /a\n\nmid\n[b]: /b \"bee\"\n\nend [c]\n\n<!-- links -->\n[c]: /c\n",
5238        ];
5239        for wrap in [None, Some(80usize), Some(20)] {
5240            for src in docs {
5241                let ctx = format!("wrap={wrap:?} src={src:?}");
5242                let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5243                let mut cache = BlockCache::default();
5244
5245                // 1) Fresh cache equals the cache-free build.
5246                let (plain, cached) = render_both(&mut ed, src, wrap, &mut cache);
5247                assert_maps_eq(&plain, &cached, &format!("fresh {ctx}"));
5248
5249                // 2) Type a char mid-document, reparse, rebuild with the now-warm
5250                //    cache: the edited block is re-marshalled and re-rendered,
5251                //    every block below it is reused shifted, and the result must
5252                //    still match a from-scratch build.
5253                let at = (src.len() / 2..=src.len())
5254                    .find(|&i| src.is_char_boundary(i))
5255                    .unwrap();
5256                ed.edit_range(at, at, "Z").unwrap();
5257                let src2 = ed.source_str().unwrap();
5258                let (plain2, cached2) = render_both(&mut ed, &src2, wrap, &mut cache);
5259                assert_maps_eq(&plain2, &cached2, &format!("after insert {ctx}"));
5260
5261                // 3) Delete it again: offsets shift back the other way, and the
5262                //    warm cache must not hand back stale shifted rows.
5263                ed.edit_range(at, at + 1, "").unwrap();
5264                let src3 = ed.source_str().unwrap();
5265                let (plain3, cached3) = render_both(&mut ed, &src3, wrap, &mut cache);
5266                assert_maps_eq(&plain3, &cached3, &format!("after delete {ctx}"));
5267            }
5268        }
5269    }
5270
5271    /// A document that does not end in a newline is the one place twig hands
5272    /// leaf a top-level span that addresses no source: the last block is closed
5273    /// on the virtual newline the parser supplies at EOF, so its `span.end` is
5274    /// `source.len() + 1`. The block cache keys on the bytes under that span, and
5275    /// reading the out-of-range slice as *no bytes* broke it two ways at once —
5276    /// [`block_bytes`] has the full account. Both ways are checked here, because
5277    /// they fail independently.
5278    #[test]
5279    fn a_block_running_past_the_last_byte_still_keys_the_cache_by_its_own_bytes() {
5280        // One: two overrunning blocks collide. A footnote definition is a root
5281        // beside `doc` that [`top_blocks`] merges into the top level, while the
5282        // `section` above it spans the definition's bytes too — so when the
5283        // definition ends the file, both blocks end past it. The second was
5284        // served the first's rows, and the definition rendered as a copy of the
5285        // heading.
5286        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.";
5287        let mut ed = Editor::new_str(src, Format::Djot).unwrap();
5288        let (plain, cached) = render_both(&mut ed, src, Some(80), &mut BlockCache::default());
5289        assert_maps_eq(&plain, &cached, "a definition ending the file");
5290        let text = rendered(&cached);
5291        assert!(
5292            text.ends_with("[note] A note with a word for a label."),
5293            "the last definition should render itself: {text:?}"
5294        );
5295        assert_eq!(
5296            text.matches("A heading with a reference").count(),
5297            1,
5298            "the heading should render exactly once: {text:?}"
5299        );
5300
5301        // Two: one overrunning block goes stale. Its bytes are its cache key, so
5302        // a block that keeps hashing the same however it is edited is served the
5303        // rows built before the edit — the whole last line frozen as the user
5304        // types in it.
5305        let mut cache = BlockCache::default();
5306        let first = "first para\n\n# A heading\n\nlast para with no newline";
5307        let mut ed = Editor::new_str(first, Format::Djot).unwrap();
5308        let (_, warm) = render_both(&mut ed, first, Some(80), &mut cache);
5309        assert!(rendered(&warm).ends_with("last para with no newline"));
5310
5311        let second = "first para\n\n# A heading\n\nDIFFERENT text without a newline";
5312        let mut ed = Editor::new_str(second, Format::Djot).unwrap();
5313        let (plain, cached) = render_both(&mut ed, second, Some(80), &mut cache);
5314        assert_maps_eq(&plain, &cached, "edited last block, warm cache");
5315        let text = rendered(&cached);
5316        assert!(
5317            text.ends_with("DIFFERENT text without a newline"),
5318            "the warm cache served the pre-edit rows: {text:?}"
5319        );
5320    }
5321
5322    #[test]
5323    fn resolves_markup_to_plain_text() {
5324        let text = rendered(&map("# Title\n\na **bold** word\n"));
5325        assert!(!text.contains('#'), "heading marker shown: {text:?}");
5326        assert!(!text.contains("**"), "strong delimiters shown: {text:?}");
5327        assert!(text.contains("Title") && text.contains("bold word"));
5328    }
5329
5330    #[test]
5331    fn every_glyph_points_at_its_source_byte() {
5332        let src = "a **bold** c\n";
5333        let m = map(src);
5334        for row in &m.rows {
5335            for g in &row.glyphs {
5336                // A real (non-synthetic) glyph's source byte is the glyph's char.
5337                if g.src < src.len()
5338                    && src.is_char_boundary(g.src)
5339                    && let Some(sc) = src[g.src..].chars().next()
5340                    && sc == g.ch
5341                {
5342                    continue;
5343                }
5344                // Synthetic prefixes (none here) would be the only exceptions.
5345                panic!("glyph {:?} at src {} doesn't match source", g.ch, g.src);
5346            }
5347        }
5348    }
5349
5350    #[test]
5351    fn offset_and_position_round_trip_on_visible_text() {
5352        let m = map("hello world\n");
5353        let (r, c) = m.pos_of_offset(6); // the 'w'
5354        assert_eq!(m.offset_of_pos(r, c), 6);
5355    }
5356
5357    #[test]
5358    fn visible_utf16_indices_count_the_text_the_system_sees() {
5359        // Hidden delimiters, a two-unit emoji, and a block gap — every way the
5360        // visible text's UTF-16 length parts company with a source byte count.
5361        let src = "a **b\u{1F600}** c\n\nd\n";
5362        let m = map(src);
5363        let end = m.snap_to_stop(src.len());
5364        let text = m.visible_text(0, end);
5365        assert_eq!(text, "a b\u{1F600} c\nd");
5366
5367        // Forward: the index of each offset is where that character sits in
5368        // the visible string, in UTF-16 units.
5369        for (i, (src_off, _)) in m.visible_items(0, end).iter().enumerate() {
5370            let expect: usize = text.chars().take(i).map(char::len_utf16).sum();
5371            assert_eq!(
5372                m.visible_utf16_len(0, *src_off),
5373                expect,
5374                "utf16 index of source offset {src_off}"
5375            );
5376            // And back: the index resolves to the offset it came from.
5377            assert_eq!(m.offset_at_visible_utf16(end, expect), Some(*src_off));
5378        }
5379        // Inside the emoji's surrogate pair resolves to the emoji.
5380        let emoji_src = src.find('\u{1F600}').unwrap();
5381        let emoji_idx = m.visible_utf16_len(0, emoji_src);
5382        assert_eq!(
5383            m.offset_at_visible_utf16(end, emoji_idx + 1),
5384            Some(emoji_src)
5385        );
5386        // At or past the end is nobody's character.
5387        let total = m.visible_utf16_len(0, end);
5388        assert_eq!(total, text.encode_utf16().count());
5389        assert_eq!(m.offset_at_visible_utf16(end, total), None);
5390    }
5391
5392    #[test]
5393    fn unwrapped_mode_emits_one_row_per_paragraph() {
5394        // A long paragraph that would wrap under a column budget stays a single
5395        // row when wrap is None (the GUI wraps it at pixel width instead).
5396        let long = "one two three four five six seven eight nine ten eleven twelve\n";
5397        let mut ed = Editor::new_str(long, Format::Markdown).unwrap();
5398        let wrapped = build_t(&ed.nodes().unwrap(), long, Some(12));
5399        let unwrapped = build_t(&ed.nodes().unwrap(), long, None);
5400        assert!(wrapped.num_rows() > 1, "narrow column should wrap");
5401        assert_eq!(unwrapped.num_rows(), 1, "no budget should keep it one row");
5402        // Every glyph's source byte is preserved in the single row.
5403        let text: String = unwrapped.rows[0].glyphs.iter().map(|g| g.ch).collect();
5404        assert_eq!(text.trim_end(), long.trim_end());
5405    }
5406
5407    fn line_texts(m: &VisualMap) -> Vec<String> {
5408        m.rows
5409            .iter()
5410            .map(|r| {
5411                // Trim the trailing whitespace a row may carry — the zero-width
5412                // '\n' that closes a preserved line, and any space glyph left at
5413                // a wrap boundary (both real caret stops, neither visible text).
5414                r.glyphs
5415                    .iter()
5416                    .map(|g| g.ch)
5417                    .collect::<String>()
5418                    .trim_end()
5419                    .to_string()
5420            })
5421            .collect()
5422    }
5423
5424    #[test]
5425    fn preserve_lays_each_soft_break_on_its_own_row() {
5426        // A soft break (a bare newline inside a paragraph) folds into a space by
5427        // default — the whole paragraph is one reflowed row...
5428        let src = "one two\nthree four\n";
5429        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5430        let folded = build_t(&ed.nodes().unwrap(), src, None);
5431        assert_eq!(folded.num_rows(), 1, "fold: one reflowed row");
5432        assert_eq!(
5433            line_texts(&folded),
5434            vec!["one two three four"],
5435            "break folded to a space"
5436        );
5437
5438        // ...and under Preserve it renders where it was written, a row per line.
5439        let kept = map_preserve(src, None);
5440        assert_eq!(
5441            line_texts(&kept),
5442            vec!["one two", "three four"],
5443            "preserve: a row per line"
5444        );
5445    }
5446
5447    #[test]
5448    fn a_preserved_break_keeps_the_newline_offset_as_a_caret_stop() {
5449        // The break must leave a caret stop at the newline byte, or the caret
5450        // could not rest at the end of the first line. The '\n' glyph is dropped
5451        // from the row (so nothing stray renders); its offset (7 here) becomes the
5452        // row's end stop instead — the same offset the folded space would carry.
5453        let src = "one two\nthree four\n";
5454        let m = map_preserve(src, None);
5455        assert!(
5456            !m.rows[0].glyphs.iter().any(|g| g.ch == '\n'),
5457            "the break glyph is dropped"
5458        );
5459        assert_eq!(
5460            m.rows[0].end_src, 7,
5461            "the first row ends at the newline byte"
5462        );
5463        assert!(m.is_stop(7), "the newline offset is a caret stop");
5464        // Row end offsets stay strictly ascending — no two rows pin one offset.
5465        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
5466        assert!(
5467            offs.windows(2).all(|w| w[0] < w[1]),
5468            "offsets not unique: {offs:?}"
5469        );
5470    }
5471
5472    #[test]
5473    fn preserved_lines_wrap_independently() {
5474        // Each preserved line wraps to the column on its own; the break between
5475        // them is hard, so a word never crosses it — "gamma" and "delta" could
5476        // share a row on width alone but the soft break keeps them apart.
5477        let src = "alpha beta gamma\ndelta epsilon\n";
5478        let m = map_preserve(src, Some(12));
5479        assert_eq!(
5480            line_texts(&m),
5481            vec!["alpha beta", "gamma", "delta", "epsilon"],
5482            "each source line wraps on its own"
5483        );
5484    }
5485
5486    #[test]
5487    fn an_empty_paragraph_between_blocks_renders_its_own_rows() {
5488        // "A", then two blank lines (an empty paragraph opened with Enter), then
5489        // "B": the empty paragraph must be navigable rows, not collapsed onto B.
5490        // Rows: "A", spacer, empty-paragraph, spacer, "B" — each blank row a
5491        // distinct source offset.
5492        let m = map("A\n\n\n\nB\n");
5493        let text: Vec<String> = m
5494            .rows
5495            .iter()
5496            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5497            .collect();
5498        assert_eq!(text, vec!["A", "", "", "", "B"], "got {text:?}");
5499        let offs: Vec<usize> = m.rows.iter().map(|r| r.end_src).collect();
5500        // Strictly ascending — no two rows share an offset (else the caret pins).
5501        assert!(
5502            offs.windows(2).all(|w| w[0] < w[1]),
5503            "offsets not unique: {offs:?}"
5504        );
5505    }
5506
5507    #[test]
5508    fn a_tight_block_boundary_still_gets_one_separator() {
5509        // A heading directly above text (no blank line between) keeps the single
5510        // conventional separator row, as before.
5511        let m = map("# H\ntext\n");
5512        let text: Vec<String> = m
5513            .rows
5514            .iter()
5515            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5516            .collect();
5517        assert_eq!(text, vec!["H", "", "text"], "got {text:?}");
5518    }
5519
5520    #[test]
5521    fn an_escaped_delimiter_renders_without_its_backslash_and_maps_true_offsets() {
5522        // `a\*b` renders the three visible chars `a * b` — the escape backslash
5523        // is hidden — and every glyph points at its real source byte, so a caret
5524        // past the escape lands right (the `*` at source 2, `b` at source 3, not
5525        // the drifted 1/2 the naive text-offset mapping gave).
5526        let m = map("a\\*b\n");
5527        let row: Vec<(char, usize)> = m.rows[0].glyphs.iter().map(|g| (g.ch, g.src)).collect();
5528        assert_eq!(row, vec![('a', 0), ('*', 2), ('b', 3)], "got {row:?}");
5529    }
5530
5531    #[test]
5532    fn an_escaped_hash_stays_a_paragraph_and_shows_the_hash() {
5533        // `\# hi` is a paragraph beginning with a literal `#`, not a heading —
5534        // the backslash is hidden, the `#` shown at its true offset.
5535        let m = map("\\# hi\n");
5536        let text: String = m.rows[0].glyphs.iter().map(|g| g.ch).collect();
5537        assert_eq!(text, "# hi");
5538        assert_eq!(
5539            m.rows[0].glyphs[0].src, 1,
5540            "the # is at source byte 1, past the \\"
5541        );
5542    }
5543
5544    #[test]
5545    fn a_tight_nested_list_hangs_its_sublist_directly_under_the_item() {
5546        // A list item's own text and the sub-list nested under it are written on
5547        // adjacent source lines, so the rich view butts them together — no
5548        // fabricated blank row. Regression: the synthetic "breathe" separator
5549        // used to open a gap between `• a` and its `  • b`.
5550        assert_eq!(rendered(&map("- a\n  - b\n")), "• a\n  • b");
5551    }
5552
5553    #[test]
5554    fn a_loose_nested_list_keeps_its_real_blank_line() {
5555        // A genuine blank source line (a loose list) still parts the item from
5556        // its sub-list — only the *fabricated* separator is suppressed, never a
5557        // real one the author typed. The gap row wears the item's continuation
5558        // prefix (the two-space indent), so it renders as "  ", not empty.
5559        assert_eq!(rendered(&map("- a\n\n  - b\n")), "• a\n  \n  • b");
5560    }
5561
5562    #[test]
5563    fn frontmatter_is_hidden_and_the_document_opens_into_its_content() {
5564        // Leading YAML frontmatter renders nothing — no phantom blank rows for
5565        // its lines, no leading gap — and `content_start` points at the first
5566        // real block so the caret floor can keep out of the hidden metadata.
5567        let fm = "---\nconfig: prov.yaml\ncontents:\n- '[Sample](sample.md)'\n---\n";
5568        let src = format!("{fm}# leaf\n\nA line.\n");
5569        let m = map(&src);
5570        let text = rendered(&m);
5571        assert!(
5572            !text.contains("config"),
5573            "frontmatter body leaked: {text:?}"
5574        );
5575        assert!(!text.contains("prov"), "frontmatter body leaked: {text:?}");
5576        assert_eq!(
5577            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5578            "leaf"
5579        );
5580        assert_eq!(
5581            m.content_start,
5582            fm.len(),
5583            "floor should be the first real block"
5584        );
5585    }
5586
5587    #[test]
5588    fn a_frontmatter_only_document_puts_the_floor_after_the_frontmatter() {
5589        // Nothing to render, so the caret floor is the end of the hidden
5590        // frontmatter — not 0, which is *before* the opening `---` and made the
5591        // first keystroke in a fresh metadata-only note land ahead of it. And
5592        // the frontmatter's own newlines are not trailing blank lines: they used
5593        // to open phantom rows at offsets 1..4, inside the metadata.
5594        let src = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
5595        let m = map(src);
5596        assert_eq!(m.content_start, src.len(), "floor must clear the metadata");
5597        assert!(
5598            m.rows.is_empty(),
5599            "frontmatter must render no rows: {:?}",
5600            rendered(&m)
5601        );
5602        assert!(
5603            m.stops.is_empty(),
5604            "no stop may sit inside the metadata: {:?}",
5605            m.stops
5606        );
5607    }
5608
5609    #[test]
5610    fn a_frontmatter_only_document_still_counts_its_real_blank_lines() {
5611        // Two blank lines after the frontmatter are the author's empty paragraph
5612        // and still render, counted from the metadata's end rather than from 0.
5613        let fm = "---\ntitle: n\n---\n";
5614        let m = map(&format!("{fm}\n\n"));
5615        assert_eq!(m.content_start, fm.len());
5616        assert_eq!(m.rows.len(), 2, "the two trailing newlines each open a row");
5617        assert!(
5618            m.rows.iter().all(|r| r.end_src > fm.len()),
5619            "rows must sit past the frontmatter"
5620        );
5621    }
5622
5623    #[test]
5624    fn a_document_without_frontmatter_has_a_zero_floor() {
5625        let m = map("# leaf\n\nbody\n");
5626        assert_eq!(m.content_start, 0);
5627    }
5628
5629    #[test]
5630    fn trailing_spaces_become_caret_stops_so_the_caret_can_be_drawn_past_them() {
5631        // Markdown/Djot drop the trailing space in `hello ` from the `str` node,
5632        // so without help the row would end at `hello` and the caret couldn't be
5633        // drawn past column 5 — typing a space at a line's end wouldn't move it
5634        // on screen until the next visible character reparsed the space into an
5635        // interior node. The builder recovers it from the block's span/content_span
5636        // gap and emits it as a real, caret-stoppable glyph.
5637        let m = map("hello \n");
5638        assert_eq!(
5639            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5640            "hello "
5641        );
5642        assert_eq!(
5643            m.rows[0].end_src, 6,
5644            "the row now ends past the trailing space"
5645        );
5646        // The caret can rest both on and past the space.
5647        assert_eq!(m.pos_of_offset(5), (0, 5), "between 'o' and the space");
5648        assert_eq!(m.pos_of_offset(6), (0, 6), "past the space");
5649        // Two trailing spaces, both stops.
5650        let m = map("hello  \n");
5651        assert_eq!(
5652            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5653            "hello  "
5654        );
5655        assert_eq!(m.pos_of_offset(7), (0, 7));
5656    }
5657
5658    #[test]
5659    fn a_headings_trailing_space_is_a_caret_stop_too() {
5660        // The hidden `# ` marker means `# hi ` renders as `hi ` in three columns;
5661        // the caret past the trailing space lands on the third.
5662        let m = map("# hi \n");
5663        assert_eq!(
5664            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
5665            "hi "
5666        );
5667        assert_eq!(m.pos_of_offset(5), (0, 3));
5668    }
5669
5670    #[test]
5671    fn a_table_cells_trailing_padding_is_not_mistaken_for_block_trailing_space() {
5672        // A cell's own `span` is the whole row, so the trailing-whitespace
5673        // recovery must not run for cells or it would swallow the `│` delimiters
5674        // and neighbours between the cell text and the row's end. The grid stays
5675        // exactly as before.
5676        let text = rendered(&map(TABLE));
5677        assert!(
5678            text.contains("│ Pear │   3 │"),
5679            "cell padding disturbed:\n{text}"
5680        );
5681    }
5682
5683    #[test]
5684    fn a_click_below_the_last_row_lands_on_the_last_stop_not_offset_zero() {
5685        // A drag into the empty space under a short document used to resolve to
5686        // offset 0 — the wrong direction, and not even a caret stop when the
5687        // document opens on hidden frontmatter (its `content_start` floor is not
5688        // a stop), which crashed the caret invariant. It now lands on the last
5689        // stop: the end of the document, where dragging downward should reach.
5690        let fm = "---\ntitle: n\n---\n";
5691        let m = map(&format!("{fm}# Hi\n\nbody\n"));
5692        let below = m.num_rows() + 5;
5693        let off = m.offset_of_pos(below, 0);
5694        assert!(
5695            m.is_stop(off),
5696            "offset {off} from a below-content click is not a stop"
5697        );
5698        assert_eq!(
5699            off,
5700            m.stops.last().copied().unwrap(),
5701            "should be the document's last stop"
5702        );
5703        assert!(
5704            off > fm.len(),
5705            "must not fall onto the hidden frontmatter floor"
5706        );
5707    }
5708
5709    #[test]
5710    fn offset_of_pos_is_a_stop_for_every_row_including_past_the_end() {
5711        // The invariant the caret motion asserts: whatever cell a click names,
5712        // the offset it resolves to is one the caret can actually rest at.
5713        for src in [
5714            "hello \n",
5715            "# A heading here \n\nbody text goes on \n",
5716            "---\nk: v\n---\n# Title\n\nprose here that wraps a bit \n",
5717        ] {
5718            let m = map(src);
5719            for row in 0..m.num_rows() + 3 {
5720                for col in 0..30 {
5721                    let off = m.offset_of_pos(row, col);
5722                    assert!(
5723                        m.is_stop(off),
5724                        "row {row} col {col} → {off} is not a stop in {src:?}"
5725                    );
5726                }
5727            }
5728        }
5729    }
5730
5731    /// `| Name | Qty |` with Name left-aligned and Qty right-aligned.
5732    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
5733
5734    #[test]
5735    fn a_table_renders_as_an_aligned_grid() {
5736        let text = rendered(&map(TABLE));
5737        assert_eq!(
5738            text,
5739            "┌──────┬─────┐\n\
5740             │ Name │ Qty │\n\
5741             ├──────┼─────┤\n\
5742             │ Pear │   3 │\n\
5743             │ Fig  │  12 │\n\
5744             └──────┴─────┘",
5745            "got:\n{text}"
5746        );
5747    }
5748
5749    #[test]
5750    fn table_columns_honour_their_alignment() {
5751        // Centre and default(left) come straight from twig's cell.alignment —
5752        // the delimiter row it's spelled in is consumed and has no node.
5753        let text = rendered(&map("| A | Bee |\n| --- | :---: |\n| x | y |\n"));
5754        assert!(text.contains("│ x │  y  │"), "centred column: {text:?}");
5755    }
5756
5757    #[test]
5758    fn table_borders_are_decoration_the_caret_never_lands_on() {
5759        let m = map(TABLE);
5760        // The rules are whole decoration rows.
5761        for r in [0, 2, 5] {
5762            assert!(m.rows[r].decoration, "row {r} should be a decoration rule");
5763            assert!(
5764                !m.rows[r].glyphs.iter().any(|g| g.stop),
5765                "row {r} has a stop"
5766            );
5767        }
5768        // A content row's `│` and padding are decoration; only the cell text
5769        // and each cell's one end-stop are stops.
5770        let header = &m.rows[1];
5771        assert!(!header.decoration);
5772        for g in &header.glyphs {
5773            if g.ch == '│' {
5774                assert!(!g.stop, "a border is not a caret stop");
5775            }
5776        }
5777        let stops: String = header
5778            .glyphs
5779            .iter()
5780            .filter(|g| g.stop)
5781            .map(|g| g.ch)
5782            .collect();
5783        assert_eq!(stops, "Name Qty ", "cell text plus one end-stop space each");
5784    }
5785
5786    #[test]
5787    fn a_cell_maps_to_its_own_source_text() {
5788        let m = map(TABLE);
5789        // "Pear" starts at byte 32 in TABLE; the caret there draws on the 'P'.
5790        let pear = TABLE.find("Pear").unwrap();
5791        let (r, c) = m.pos_of_offset(pear);
5792        assert_eq!(m.rows[r].glyphs[c].ch, 'P');
5793        assert_eq!(m.offset_of_pos(r, c), pear, "round trips");
5794    }
5795
5796    #[test]
5797    fn a_wide_table_is_cut_to_fit_and_its_cells_wrap() {
5798        // Columns wider than the surface used to run off the right edge, where
5799        // nothing could reach them. They're cut to the budget instead, and the
5800        // text wraps down inside the column — the header rule stays put, and
5801        // an alignment holds on every line of a wrapped cell, not just the first.
5802        let src = "| Ingredient | Notes |\n|---|---:|\n\
5803                   | flour milled coarse | sift it twice |\n| salt | a pinch |\n";
5804        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5805        let m = build_t(&ed.nodes().unwrap(), src, Some(30));
5806        let text = rendered(&m);
5807        assert_eq!(
5808            text,
5809            "┌──────────────┬─────────────┐\n\
5810             │ Ingredient   │       Notes │\n\
5811             ├──────────────┼─────────────┤\n\
5812             │ flour milled │     sift it │\n\
5813             │ coarse       │       twice │\n\
5814             │ salt         │     a pinch │\n\
5815             └──────────────┴─────────────┘",
5816            "got:\n{text}"
5817        );
5818        for (r, row) in m.rows.iter().enumerate() {
5819            assert!(
5820                row.glyphs.len() <= 30,
5821                "row {r} overflows: {}",
5822                row.glyphs.len()
5823            );
5824        }
5825    }
5826
5827    #[test]
5828    fn a_column_too_narrow_for_a_word_breaks_it_rather_than_spilling() {
5829        // A paragraph lets an overlong word trail off the end of the line; a
5830        // table column can't — a glyph past the border lands on the border.
5831        let src = "| A | B |\n|---|---|\n| antidisestablishmentarianism | x |\n";
5832        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
5833        let m = build_t(&ed.nodes().unwrap(), src, Some(20));
5834        for (r, row) in m.rows.iter().enumerate() {
5835            assert!(
5836                row.glyphs.len() <= 20,
5837                "row {r} overflows: {}",
5838                row.glyphs.len()
5839            );
5840        }
5841        // Broken across lines, but whole: every letter is still drawn, at its
5842        // own source byte, where the caret can reach it.
5843        let word = "antidisestablishmentarianism";
5844        let at = src.find(word).unwrap();
5845        for (i, ch) in word.char_indices() {
5846            assert!(
5847                m.rows
5848                    .iter()
5849                    .flat_map(|r| r.glyphs.iter())
5850                    .any(|g| g.stop && g.src == at + i && g.ch == ch),
5851                "{ch:?} at {} was lost to the break",
5852                at + i
5853            );
5854        }
5855    }
5856
5857    #[test]
5858    fn a_code_block_maps_each_line_to_its_own_source_text() {
5859        // Every glyph used to point at the block's start, which made the whole
5860        // block one offset — visible, but impossible to put a caret inside.
5861        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
5862        let m = map(src);
5863        for row in &m.rows {
5864            for g in row.glyphs.iter().filter(|g| g.stop) {
5865                assert_eq!(
5866                    src[g.src..].chars().next(),
5867                    Some(g.ch),
5868                    "glyph {:?} at {} isn't the source byte it claims",
5869                    g.ch,
5870                    g.src
5871                );
5872            }
5873        }
5874    }
5875
5876    #[test]
5877    fn an_indented_code_block_maps_past_its_stripped_indent() {
5878        // twig strips the four-space indent, so `text` isn't a source slice and
5879        // the lines have to be re-found. Offsets land on the code, not the indent.
5880        let src = "    indented\n    code\n";
5881        let m = map(src);
5882        let stops: Vec<(char, usize)> = m
5883            .rows
5884            .iter()
5885            .flat_map(|r| r.glyphs.iter().filter(|g| g.stop).map(|g| (g.ch, g.src)))
5886            .collect();
5887        assert_eq!(
5888            stops[0],
5889            ('i', 4),
5890            "first line should start past the indent"
5891        );
5892        assert!(
5893            stops.contains(&('c', 17)),
5894            "second line misplaced: {stops:?}"
5895        );
5896    }
5897
5898    #[test]
5899    fn a_fenced_block_whose_code_echoes_its_info_string_maps_to_the_code() {
5900        // The one case that defeats a forward search: the opening fence
5901        // ```` ```rust ```` ends with the same text as the code under it.
5902        let src = "```rust\nrust\n```\n";
5903        let m = map(src);
5904        let first = m.rows[0].glyphs.iter().find(|g| g.stop).unwrap();
5905        assert_eq!(first.src, 8, "matched the info string, not the code");
5906    }
5907
5908    #[test]
5909    fn a_code_block_carries_no_gutter_and_is_published_as_a_row_span() {
5910        // The old `▏ ` gutter is gone: a code row is the block prefix (none, at
5911        // the top level) plus the code text, and the whole run is named in
5912        // `code_blocks` so a frontend can box it.
5913        let src = "para\n\n```\ncode\nlines\n```\n\nafter\n";
5914        let m = map(src);
5915        assert_eq!(m.code_blocks.len(), 1, "one code block");
5916        let span = m.code_blocks[0].rows_span.clone();
5917        let rows: Vec<String> = m.rows[span.clone()]
5918            .iter()
5919            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5920            .collect();
5921        assert_eq!(rows, vec!["code".to_string(), "lines".to_string()]);
5922        assert!(!rendered(&m).contains('▏'), "gutter still drawn");
5923        assert!(
5924            m.rows[span].iter().all(|r| r.code),
5925            "every row in the span is flagged code"
5926        );
5927    }
5928
5929    #[test]
5930    fn an_empty_last_line_in_a_code_block_is_a_row_of_its_own() {
5931        // `trim_end_matches('\n')` cut the block's terminator *and* the newline
5932        // that spells a trailing empty line, so the row the Return had just made
5933        // never appeared and the caret on it fell through to the block below.
5934        // Every empty line is a row, wherever in the block it falls.
5935        let src = "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n";
5936        let m = map(src);
5937        let span = m.code_blocks[0].rows_span.clone();
5938        let rows: Vec<String> = m.rows[span.clone()]
5939            .iter()
5940            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
5941            .collect();
5942        assert_eq!(
5943            rows,
5944            vec!["alpha".to_string(), "beta".to_string(), String::new()],
5945            "the empty last line gets a row"
5946        );
5947        assert!(
5948            m.rows[span.clone()].iter().all(|r| r.code),
5949            "the empty row is flagged code like the rest of the block"
5950        );
5951        // And it is the *source's* empty line, not a coarse fallback to the
5952        // block start: the offset the caret resolves to is the one Return made.
5953        let empty = span.end - 1;
5954        assert_eq!(
5955            m.rows[empty].end_src,
5956            src.find("beta\n\n").unwrap() + "beta\n".len(),
5957            "the empty row maps to the line the Return opened"
5958        );
5959
5960        // Nothing is invented where there is no empty line, and a second one is
5961        // a second row.
5962        assert_eq!(
5963            map("```\nalpha\nbeta\n```\n").code_blocks[0]
5964                .rows_span
5965                .len(),
5966            2,
5967            "a block that ends at its last code line keeps two rows"
5968        );
5969        assert_eq!(
5970            map("```\nalpha\n\n\n```\n").code_blocks[0].rows_span.len(),
5971            3,
5972            "two trailing empty lines are two rows"
5973        );
5974    }
5975
5976    #[test]
5977    fn a_directive_container_is_tinted_and_labeled_on_its_first_row() {
5978        // diaryx's `:::vis{.public .family}` visibility block, and any other
5979        // `:::name{.class}` fenced div — core is agnostic of `name`.
5980        let src = ":::vis{.public .family}\nhello\n\nworld\n:::\nafter\n";
5981        let m = map_directives(src);
5982
5983        let content_rows: Vec<usize> = (0..m.rows.len()).filter(|&i| m.rows[i].directive).collect();
5984        assert!(!content_rows.is_empty(), "some row is flagged directive");
5985
5986        let after_rows: Vec<usize> = (0..m.rows.len())
5987            .filter(|&i| !content_rows.contains(&i) && !m.rows[i].glyphs.is_empty())
5988            .collect();
5989        assert!(
5990            after_rows.iter().all(|&i| !m.rows[i].directive),
5991            "content outside the fence isn't tinted"
5992        );
5993
5994        let labels: Vec<&str> = content_rows
5995            .iter()
5996            .filter_map(|&i| m.rows[i].directive_label.as_deref())
5997            .collect();
5998        assert_eq!(
5999            labels,
6000            vec!["public family"],
6001            "only the first row carries the label"
6002        );
6003
6004        assert_eq!(
6005            rendered(&m)
6006                .lines()
6007                .filter(|l| !l.is_empty())
6008                .collect::<Vec<_>>(),
6009            vec!["hello", "world", "after"],
6010            "fence markers don't leak into the rendered text"
6011        );
6012    }
6013
6014    #[test]
6015    fn a_bare_word_directive_is_labeled_same_as_dot_classes() {
6016        // diaryx_core::visibility's own `:::vis{public family}` — no leading
6017        // dots — is what apps/web's directive serializer and the native
6018        // publish-time filter both actually write today, distinct from twig's
6019        // `.class` convention. Both must label the same way so every existing
6020        // diaryx `:::vis{...}` block reads, not just newly dot-authored ones.
6021        let src = ":::vis{public family}\nhello\n:::\n";
6022        let m = map_directives(src);
6023        let label = m.rows.iter().find_map(|r| r.directive_label.clone());
6024        assert_eq!(label.as_deref(), Some("public family"));
6025    }
6026
6027    #[test]
6028    fn a_text_directive_keeps_its_paragraph_visible() {
6029        // Regression: an inline `:name[label]{…}` used to make its paragraph
6030        // fail the "all children inline" test, so the whole line was walked as
6031        // a container of blocks and rendered as empty rows with NO caret stops —
6032        // the text vanished from the editor and the caret couldn't enter it.
6033        // diaryx's inline `:vis[…]` is exactly this shape.
6034        let src = "Text with :abbr[HTML]{title=\"HyperText\"} inline.\n";
6035        let m = map_directives(src);
6036        assert_eq!(rendered(&m).trim_end(), "Text with HTML inline.");
6037        // Every character of the line is a caret home, markup excluded — the
6038        // label reads as ordinary text, the way a link's does.
6039        let stops: usize = m
6040            .rows
6041            .iter()
6042            .map(|r| r.glyphs.iter().filter(|g| g.stop).count())
6043            .sum();
6044        assert_eq!(stops, "Text with HTML inline.".chars().count());
6045        // It is inline, so it is not the container form's tinted panel.
6046        assert!(m.rows.iter().all(|r| !r.directive));
6047    }
6048
6049    #[test]
6050    fn a_text_directives_label_maps_to_its_true_source_bytes() {
6051        // Regression (needs twig-doc >= 2.5.0): twig parses a `[label]` as a
6052        // detached slice, and until it rebased the enclosing scan's segments
6053        // onto it every node inside the label reported a span of `(0,0)`. Read
6054        // by anything that trusts a span that means "byte 0", so the label's
6055        // glyphs mapped to the START OF THE DOCUMENT — a click on the label put
6056        // the caret at the top of the file, its stops collided with the real
6057        // first line's, and an edit there landed on the wrong bytes entirely.
6058        //
6059        // The sibling test `a_text_directive_keeps_its_paragraph_visible` only
6060        // counts stops, which is exactly why this went unnoticed: the right
6061        // NUMBER of stops at completely wrong offsets.
6062        let src = "x :abbr[HTML]{title=\"y\"} z\n";
6063        let m = map_directives(src);
6064        let stops: Vec<(char, usize)> = m
6065            .rows
6066            .iter()
6067            .flat_map(|r| &r.glyphs)
6068            .filter(|g| g.stop)
6069            .map(|g| (g.ch, g.src))
6070            .collect();
6071        // `HTML` sits at 8..12. The name, brackets and `{…}` are hidden markup
6072        // the caret steps over, so the line's stops run 0, 1, 8..12, then 24.
6073        assert_eq!(
6074            stops,
6075            [
6076                ('x', 0),
6077                (' ', 1),
6078                ('H', 8),
6079                ('T', 9),
6080                ('M', 10),
6081                ('L', 11),
6082                (' ', 24),
6083                ('z', 25)
6084            ]
6085        );
6086    }
6087
6088    #[test]
6089    fn every_glyph_in_a_directive_label_points_at_its_source_byte() {
6090        // The `every_glyph_points_at_its_source_byte` invariant, extended over
6091        // directive labels now that their offsets are real. Nested markup is
6092        // included: its delimiters are hidden, so the visible glyphs must skip
6093        // them and still name their own bytes.
6094        let src = "x :abbr[a *b* c] y and :vis[family only] z\n";
6095        let m = map_directives(src);
6096        for g in m.rows.iter().flat_map(|r| &r.glyphs).filter(|g| g.stop) {
6097            let at = src[g.src..].chars().next();
6098            assert_eq!(
6099                at,
6100                Some(g.ch),
6101                "glyph {:?} claims byte {}, which is {at:?}",
6102                g.ch,
6103                g.src
6104            );
6105        }
6106        assert_eq!(rendered(&m).trim_end(), "x a b c y and family only z");
6107    }
6108
6109    #[test]
6110    fn a_directive_labels_nested_emphasis_keeps_both_its_style_and_its_offsets() {
6111        let src = "x :abbr[a *b* c] y\n";
6112        let m = map_directives(src);
6113        let b = m
6114            .rows
6115            .iter()
6116            .flat_map(|r| &r.glyphs)
6117            .find(|g| g.ch == 'b')
6118            .expect("the emphasised char");
6119        assert!(b.style.italic, "the label's *b* lost its emphasis");
6120        assert_eq!(b.src, 11, "the label's *b* lost its source byte");
6121    }
6122
6123    #[test]
6124    fn a_bare_colon_word_renders_as_the_prose_it_almost_always_is() {
6125        // Regression: twig matches a colon followed by any letter-led word, so
6126        // ordinary prose is full of "text directives" nobody meant to write.
6127        // With no `[label]` there are no children, and the arm recursed into
6128        // them — rendering *nothing*. The word vanished from the document with
6129        // no caret stop left behind, so it could not even be deleted.
6130        for src in ["a :word b\n", "note :see below\n", ":smile: hi\n"] {
6131            let m = map_directives(src);
6132            assert_eq!(
6133                rendered(&m).trim_end(),
6134                src.trim_end(),
6135                "prose was eaten: {src:?}"
6136            );
6137        }
6138    }
6139
6140    #[test]
6141    fn a_bare_colon_word_keeps_every_byte_a_caret_stop() {
6142        let src = "a :word b\n";
6143        let m = map_directives(src);
6144        // Nothing here is markup, so nothing is hidden: each byte maps to
6145        // itself and can be stood on, which is what makes the colon deletable.
6146        let stops: Vec<(char, usize)> = m
6147            .rows
6148            .iter()
6149            .flat_map(|r| &r.glyphs)
6150            .filter(|g| g.stop)
6151            .map(|g| (g.ch, g.src))
6152            .collect();
6153        assert_eq!(
6154            stops,
6155            "a :word b"
6156                .chars()
6157                .enumerate()
6158                .map(|(i, c)| (c, i))
6159                .collect::<Vec<_>>()
6160        );
6161    }
6162
6163    #[test]
6164    fn an_attribute_bearing_text_directive_draws_a_chip() {
6165        // `{…}` is deliberate in a way a bare colon is not — diaryx writes
6166        // `:vis{.family}` inline — so this one reads as an embed, on the same
6167        // `⧉ label` recipe the leaf form's placeholder row uses.
6168        // Both attribute conventions label it: twig's dot-prefixed classes and
6169        // the bare pandoc-style words diaryx also writes.
6170        for src in ["a :vis{.family} b\n", "a :vis{family} b\n"] {
6171            let m = map_directives(src);
6172            assert_eq!(rendered(&m).trim_end(), "a ⧉ vis family b", "{src:?}");
6173        }
6174        // A `key=value` attr is configuration, not a name, so it adds nothing.
6175        let m = map_directives("a :foo{title=\"x\"} b\n");
6176        assert_eq!(rendered(&m).trim_end(), "a ⧉ foo b");
6177    }
6178
6179    #[test]
6180    fn a_directive_chip_is_one_atomic_caret_stop_at_its_own_offset() {
6181        let src = "a :vis{.family} b\n";
6182        let m = map_directives(src);
6183        let stops: Vec<usize> = m
6184            .rows
6185            .iter()
6186            .flat_map(|r| &r.glyphs)
6187            .filter(|g| g.stop)
6188            .map(|g| g.src)
6189            .collect();
6190        // The chip contributes exactly one stop, at the directive's start (2),
6191        // so the caret steps over it whole instead of walking hidden markup a
6192        // byte at a time. `{.family}`'s bytes (3..15) are never stood on.
6193        assert_eq!(stops, [0, 1, 2, 15, 16]);
6194    }
6195
6196    #[test]
6197    fn a_paragraph_holding_only_a_chip_is_still_navigable() {
6198        // With no stop of its own the row would be unreachable — the caret
6199        // could never be put on the line to edit or delete the directive.
6200        let m = map_directives(":vis{.family}\n");
6201        assert!(
6202            m.row_is_navigable(0),
6203            "a chip-only paragraph has no caret home"
6204        );
6205        assert_eq!(
6206            m.offset_of_pos(0, 0),
6207            0,
6208            "its caret home isn't the directive's start"
6209        );
6210    }
6211
6212    #[test]
6213    fn a_ratio_or_a_clock_time_is_never_a_directive() {
6214        // twig requires a letter after the colon, so these stay prose — the
6215        // verbatim arm must not be reached for them at all.
6216        let src = "ratio 3:4 and 10:30\n";
6217        assert_eq!(
6218            rendered(&map_directives(src)).trim_end(),
6219            "ratio 3:4 and 10:30"
6220        );
6221    }
6222
6223    #[test]
6224    fn a_leaf_directive_is_a_placeholder_row_with_its_attrs_published() {
6225        // `::name{…}` is a standalone block with no body — an embed, a table of
6226        // contents. It used to emit no rows at all: invisible, no caret home,
6227        // vertical motion crossing a void. Now it draws the image recipe's
6228        // placeholder and publishes what the host app needs to paint the real
6229        // thing.
6230        let src = "before\n\n::embed{src=\"demo.html\" height=\"400\"}\n\nafter\n";
6231        let m = map_directives(src);
6232
6233        let row = m
6234            .rows
6235            .iter()
6236            .position(|r| r.leaf_directive.is_some())
6237            .expect("a placeholder row");
6238        assert_eq!(
6239            m.rows[row].glyphs.iter().map(|g| g.ch).collect::<String>(),
6240            "⧉ embed"
6241        );
6242        assert!(
6243            m.rows[row].glyphs.iter().any(|g| g.stop),
6244            "the caret can land on it"
6245        );
6246        assert!(
6247            m.rows[row].directive,
6248            "a frontend frames it like the container form"
6249        );
6250
6251        assert_eq!(m.directives.len(), 1);
6252        let info = &m.directives[0];
6253        assert_eq!(info.name, "embed");
6254        assert_eq!(info.rows_span, row..row + 1);
6255        assert_eq!(info.attr("src"), Some("demo.html"));
6256        assert_eq!(info.attr("height"), Some("400"));
6257        assert_eq!(info.attr("nope"), None);
6258        // The prose around it is untouched.
6259        assert!(rendered(&m).contains("before") && rendered(&m).contains("after"));
6260    }
6261
6262    #[test]
6263    fn a_leaf_directive_shows_its_label_and_honours_its_prefix() {
6264        // A `[label]` names the placeholder (the way an image's alt does), and a
6265        // quoted directive keeps the quote's gutter — it is a block like any
6266        // other, not a special case that escapes its container.
6267        let m = map_directives("::embed[Audience demo]{src=\"demo.html\"}\n");
6268        assert_eq!(rendered(&m).trim_end(), "⧉ Audience demo");
6269        assert_eq!(m.directives[0].label, "Audience demo");
6270
6271        let quoted = map_directives("> ::embed{src=\"x.html\"}\n");
6272        assert_eq!(rendered(&quoted).trim_end(), "│ ⧉ embed");
6273        assert_eq!(quoted.directives[0].name, "embed");
6274    }
6275
6276    #[test]
6277    fn a_container_directive_is_still_a_panel_not_a_placeholder() {
6278        // The three forms must not bleed into each other: only the leaf form is
6279        // a placeholder, and only the container form tints the blocks it wraps.
6280        let m = map_directives(":::note{.warning}\nBody\n:::\n");
6281        assert!(
6282            m.directives.is_empty(),
6283            "a container publishes no placeholder"
6284        );
6285        assert!(m.rows.iter().all(|r| r.leaf_directive.is_none()));
6286        assert_eq!(rendered(&m).trim_end(), "Body");
6287        assert!(
6288            m.rows
6289                .iter()
6290                .any(|r| r.directive && r.directive_label.as_deref() == Some("warning"))
6291        );
6292    }
6293
6294    /// A production-path build with both extensions on — the only way to put a
6295    /// promoted HTML element and a directive in one document, which is what the
6296    /// `container` kind made necessary to tell apart. Returns the whole `Doc`
6297    /// because [`VisualMap`] is not `Clone`; read `doc.vmap`.
6298    fn doc_built(src: &str) -> crate::Doc {
6299        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
6300        doc.build_visual(80);
6301        doc
6302    }
6303
6304    /// Every `container` node in `src`, parsed the way production does (both
6305    /// extensions on), paired with what [`container_is_directive`] makes of it.
6306    fn containers(src: &str) -> Vec<(String, bool, Option<DirectiveForm>)> {
6307        let mut ed = Editor::new_ext(
6308            src.as_bytes(),
6309            Format::Markdown,
6310            twig::MarkdownExtensions {
6311                directives: true,
6312                html_elements: true,
6313                ..Default::default()
6314            },
6315        )
6316        .unwrap();
6317        ed.nodes()
6318            .unwrap()
6319            .iter()
6320            .filter(|n| n.kind == Kind::Container)
6321            .map(|n| {
6322                (
6323                    n.name.clone().unwrap_or_default(),
6324                    container_is_directive(n),
6325                    n.directive_form,
6326                )
6327            })
6328            .collect()
6329    }
6330
6331    #[test]
6332    fn a_directive_and_an_html_element_are_told_apart_by_spelling_not_by_form() {
6333        // twig 2.8 folded `div`/`span`/`directive`/`element` into one `container`
6334        // kind. `directive_form` reads as though it separates them and does not:
6335        // a block-level `<div>` reports `Some(DirectiveForm::Container)` exactly
6336        // as a `:::note` does. Trusting it would draw directive chrome — a tinted
6337        // panel, a `.class` audience label — on every pasted Slack/Docs div.
6338        for (src, name, want) in [
6339            (":::note{.a}\nbody\n:::\n", "note", true),
6340            ("::embed{src=x}\n", "embed", true),
6341            ("a :vis[hi]{.b} b\n", "vis", true),
6342            ("<div class=\"x\">\nhi\n</div>\n", "div", false),
6343            ("<video src=\"v.mp4\" controls></video>\n", "video", false),
6344            ("<audio src=\"a.mp3\" controls></audio>\n", "audio", false),
6345            ("<figure>\n\nhi\n\n</figure>\n", "figure", false),
6346            // The `:` in an attribute must not read as a directive opener: the
6347            // `<` of the tag comes first, and first one wins.
6348            (
6349                "<video src=\"http://x.test/v.mp4\" controls></video>\n",
6350                "video",
6351                false,
6352            ),
6353            (
6354                "<source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\n",
6355                "source",
6356                false,
6357            ),
6358        ] {
6359            let found = containers(src);
6360            let hit = found.iter().find(|(n, ..)| n == name);
6361            let Some((_, is_directive, form)) = hit else {
6362                panic!("no `{name}` container in {src:?} — found {found:?}");
6363            };
6364            assert_eq!(*is_directive, want, "{name} in {src:?} (form was {form:?})");
6365        }
6366
6367        // And the reason this can't just read the field: for the one collision
6368        // that matters, the field says the same thing for both.
6369        let div = containers("<div class=\"x\">\nhi\n</div>\n");
6370        let note = containers(":::note{.a}\nbody\n:::\n");
6371        assert_eq!(
6372            div[0].2, note[0].2,
6373            "if these ever differ, `directive_form` became usable and this rule can go"
6374        );
6375    }
6376
6377    #[test]
6378    fn a_directive_nested_in_a_quote_or_list_is_still_a_directive() {
6379        // A container's span opens with its *block prefix*, not its own markup —
6380        // `> ::embed{…}` starts at the `>`. Reading only the first byte to tell a
6381        // directive from an element (both `container` since 2.8) therefore misses
6382        // every nested one, and the placeholder silently renders as nothing.
6383        for (src, ctx) in [
6384            ("> ::embed{src=\"x\"}\n", "quoted"),
6385            ("- ::embed{src=\"x\"}\n", "listed"),
6386            (">> ::embed{src=\"x\"}\n", "twice quoted"),
6387        ] {
6388            let m = map_directives(src);
6389            assert_eq!(m.directives.len(), 1, "{ctx} directive was lost");
6390            assert_eq!(m.directives[0].name, "embed", "{ctx}");
6391        }
6392    }
6393
6394    #[test]
6395    fn a_video_is_still_media_and_not_a_directive() {
6396        // The other side of the same coin: `<video>` is a `container` too, and
6397        // must reach `block_media` rather than the directive arms.
6398        let doc = doc_built("<video src=\"clip.mp4\" controls></video>\n");
6399        assert_eq!(doc.vmap.media.len(), 1, "the video is block media");
6400        assert!(
6401            doc.vmap.rows.iter().all(|r| !r.directive),
6402            "the video drew directive chrome"
6403        );
6404    }
6405
6406    #[test]
6407    fn a_directive_needs_the_extension_flag() {
6408        // `map` (twig's default extensions) leaves `directives` off — the fence
6409        // renders as literal paragraph text, same as any other unrecognized
6410        // punctuation, never corrupting or panicking.
6411        let src = ":::vis{.public}\nhello\n:::\n";
6412        let m = map(src);
6413        assert!(m.rows.iter().all(|r| !r.directive));
6414        assert!(rendered(&m).contains(":::vis{.public}"));
6415    }
6416
6417    #[test]
6418    fn a_footnote_reference_keeps_its_paragraph_visible() {
6419        // Regression: `footnote_reference` was in neither `is_inline_kind` nor
6420        // the inline walker, so a paragraph carrying one failed the "all children
6421        // inline" test, was walked as a container of blocks, and rendered as
6422        // empty rows with no caret stop anywhere — the whole line vanished.
6423        let src = "A claim[^1] and more.\n";
6424        let m = map(src);
6425        assert_eq!(rendered(&m).trim_end(), "A claim[1] and more.");
6426        // The `^` is spelling, not text: hidden the way a link's `](dest)` is.
6427        assert!(!rendered(&m).contains('^'));
6428    }
6429
6430    #[test]
6431    fn a_footnote_reference_is_raised_and_the_prose_around_it_is_not() {
6432        // What makes `[1]` read as a reference rather than as bracketed text.
6433        // The brackets ride with the label: the chip is one raised mark.
6434        let m = map("A claim[^1] and more.\n");
6435        assert_eq!(baselines_of(&m, '1'), vec![Baseline::Super]);
6436        assert_eq!(baselines_of(&m, '['), vec![Baseline::Super]);
6437        assert_eq!(baselines_of(&m, ']'), vec![Baseline::Super]);
6438        assert_eq!(baselines_of(&m, 'A'), vec![Baseline::Normal]);
6439    }
6440
6441    #[test]
6442    fn a_footnote_reference_keeps_the_link_role_it_had() {
6443        // The raised baseline is added to the role, not swapped for it: every
6444        // frontend already paints `Role::Link`, and a reference is one.
6445        let m = map("A claim[^1].\n");
6446        let label = m
6447            .rows
6448            .iter()
6449            .flat_map(|r| &r.glyphs)
6450            .find(|g| g.ch == '1')
6451            .unwrap();
6452        assert_eq!(label.style.role, Role::Link);
6453        assert_eq!(label.style.baseline, Baseline::Super);
6454    }
6455
6456    /// The [`Role`] of the first glyph spelling `ch` — how a test reads one
6457    /// run's styling off a map without caring which row it landed on.
6458    fn role_of(m: &VisualMap, ch: char) -> Role {
6459        m.rows
6460            .iter()
6461            .flat_map(|r| r.glyphs.iter())
6462            .find(|g| g.ch == ch)
6463            .unwrap_or_else(|| panic!("no glyph spelling {ch:?}"))
6464            .style
6465            .role
6466    }
6467
6468    #[test]
6469    fn a_markdown_highlight_is_a_mark_and_a_coloured_one_names_its_colour() {
6470        // twig 3.3's `highlight`/`highlight_colors`, which `parse_extensions`
6471        // turns on for every leaf document: `==text==` is a `mark` in Markdown
6472        // and not the literal `==` it used to be, and `==🔴 text==` is one
6473        // carrying a colour.
6474        //
6475        // `doc_built` rather than `map`, deliberately — the extensions are
6476        // leaf's choice, not twig's default, so a test that parsed bare
6477        // Markdown here would be testing a document leaf never builds.
6478        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
6479        assert_eq!(role_of(&doc.vmap, 'y'), Role::Mark(None));
6480        assert_eq!(
6481            role_of(&doc.vmap, 'r'),
6482            Role::Mark(Some(MarkColor::Red)),
6483            "the `data-color` twig stripped the emoji into"
6484        );
6485        assert_eq!(role_of(&doc.vmap, 'P'), Role::Body);
6486    }
6487
6488    #[test]
6489    fn the_emoji_that_named_a_highlight_is_markup_and_never_drawn() {
6490        // The colour is *spelling*: twig strips the emoji out of the mark's
6491        // content, so the reader sees the words and the wash, never the circle.
6492        // Drawing it would put a character in the rendered text that the author
6493        // wrote as syntax — the same mistake as drawing an emphasis's `*`.
6494        let doc = doc_built("Plain ==yes== and ==🔴 red== ok\n");
6495        let drawn: String = doc.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6496        assert_eq!(drawn, "Plain yes and red ok");
6497    }
6498
6499    #[test]
6500    fn a_superscript_and_a_subscript_sit_off_the_baseline() {
6501        // Regression: both rendered flat, so the toolbar's superscript button
6502        // produced markup that looked exactly like the text around it.
6503        let m = map_djot("H~2~O and x^2^\n");
6504        assert_eq!(baselines_of(&m, '2'), vec![Baseline::Sub, Baseline::Super]);
6505        assert_eq!(baselines_of(&m, 'H'), vec![Baseline::Normal]);
6506        assert_eq!(baselines_of(&m, 'O'), vec![Baseline::Normal]);
6507    }
6508
6509    #[test]
6510    fn a_raised_glyph_keeps_the_style_it_was_raised_out_of() {
6511        // Why this is a `Baseline` and not a `Role`: raising a glyph says where
6512        // it sits, and must not cost it what it already was.
6513        let m = map_djot("# Heading x^2^\n");
6514        let two = m
6515            .rows
6516            .iter()
6517            .flat_map(|r| &r.glyphs)
6518            .find(|g| g.ch == '2')
6519            .unwrap();
6520        assert_eq!(two.style.baseline, Baseline::Super);
6521        assert_eq!(two.style.role, Role::Heading(1), "still heading text");
6522    }
6523
6524    #[test]
6525    fn a_footnote_references_brackets_are_decoration_and_only_its_label_is_a_stop() {
6526        let src = "see[^note] here\n";
6527        let m = map(src);
6528        // `[^note]` spans 3..10, its label `note` 5..9. The caret walks the
6529        // label; the brackets are drawn but never stood on, as a table's are,
6530        // and the `[^`/`]` bytes are stepped over like any hidden delimiter.
6531        let stops: Vec<usize> = m
6532            .rows
6533            .iter()
6534            .flat_map(|r| &r.glyphs)
6535            .filter(|g| g.stop)
6536            .map(|g| g.src)
6537            .collect();
6538        for off in 5..9 {
6539            assert!(
6540                stops.contains(&off),
6541                "label byte {off} isn't a caret stop: {stops:?}"
6542            );
6543        }
6544        for off in [3usize, 4, 9] {
6545            assert!(
6546                !stops.contains(&off),
6547                "delimiter byte {off} is a caret stop: {stops:?}"
6548            );
6549        }
6550    }
6551
6552    #[test]
6553    fn a_task_item_draws_its_box_where_the_bullet_would_be() {
6554        // Regression: the `[ ] ` is markup twig consumes — the item's paragraph
6555        // content starts past it — so a task item used to render as `• todo`,
6556        // identical to a plain bullet and with no way to see it was ticked.
6557        let m = map("- [ ] todo\n- [x] done\n- plain\n");
6558        assert_eq!(rendered(&m), "☐ todo\n☑ done\n• plain");
6559
6560        // The tick rides the item's first row, for a GUI that paints its own box.
6561        let ticks: Vec<Option<bool>> = m.rows.iter().map(|r| r.task).collect();
6562        assert_eq!(ticks, [Some(false), Some(true), None]);
6563    }
6564
6565    #[test]
6566    fn a_task_items_box_survives_a_wrap_and_marks_only_the_first_row() {
6567        let m = map_at(
6568            "- [x] a much longer task that has to wrap somewhere\n",
6569            Some(20),
6570        );
6571        assert!(m.rows.len() > 1, "the item should wrap: {:?}", rendered(&m));
6572        assert_eq!(m.rows[0].task, Some(true));
6573        assert!(
6574            m.rows[1..].iter().all(|r| r.task.is_none()),
6575            "only the first row"
6576        );
6577        // The continuation lines hang under the box, not under column zero.
6578        assert!(
6579            rendered(&m)
6580                .lines()
6581                .nth(1)
6582                .is_some_and(|l| l.starts_with("  "))
6583        );
6584    }
6585
6586    #[test]
6587    fn a_bracket_in_an_items_prose_is_not_a_checkbox() {
6588        // `task_checked` finds the box past the list marker; a plain item whose
6589        // text merely contains a bracket has none, and must keep its bullet.
6590        let m = map("- see [1] below\n");
6591        assert_eq!(rendered(&m), "• see [1] below");
6592        assert_eq!(m.rows[0].task, None);
6593    }
6594
6595    #[test]
6596    fn a_footnote_definition_renders_where_it_was_written() {
6597        // Regression: twig parses `[^1]: …` as a root *beside* `doc` — not a
6598        // child of it — so the walk from `doc` never reached one and every byte
6599        // of the note's body rendered as nothing at all.
6600        let src = "A claim[^1].\n\n[^1]: The note body.\n\nAfter.\n";
6601        let m = map(src);
6602        let text = rendered(&m);
6603        assert!(
6604            text.contains("The note body."),
6605            "the note body is invisible: {text:?}"
6606        );
6607        // In source order — between the paragraph that cites it and the one
6608        // after — not hoisted to the end, and marked to match its reference.
6609        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
6610        assert_eq!(lines, ["A claim[1].", "[1] The note body.", "After."]);
6611    }
6612
6613    #[test]
6614    fn a_footnote_definitions_body_maps_to_its_own_source_bytes() {
6615        let src = "x[^a].\n\n[^a]: body\n";
6616        let m = map(src);
6617        // `body` sits at 14..18. Its glyphs must map there — a marker that ate
6618        // the offsets would put the caret in the wrong place on every click.
6619        let body: Vec<(char, usize)> = m
6620            .rows
6621            .iter()
6622            .flat_map(|r| &r.glyphs)
6623            .filter(|g| g.stop && g.src >= 14)
6624            .map(|g| (g.ch, g.src))
6625            .collect();
6626        assert_eq!(body, [('b', 14), ('o', 15), ('d', 16), ('y', 17)]);
6627    }
6628
6629    #[test]
6630    fn an_empty_footnote_definition_still_shows_its_marker() {
6631        // The instant `[^1]: ` has been typed and nothing after it. `blocks`
6632        // renders no child, so without the explicit marker row the definition
6633        // wouldn't appear at all until something was typed into it.
6634        let src = "x[^1]\n\n[^1]:\n";
6635        let m = map(src);
6636        assert!(
6637            rendered(&m).contains("[1] "),
6638            "no marker row: {:?}",
6639            rendered(&m)
6640        );
6641    }
6642
6643    #[test]
6644    fn a_footnote_definition_wearing_a_long_label_indents_its_wrapped_body() {
6645        let src = "x[^src]\n\n[^src]: one two three four five six seven\n";
6646        let m = map_at(src, Some(24));
6647        let text = rendered(&m);
6648        let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
6649        // Continuation lines hang under the marker, as a list item's do — the
6650        // indent is the marker's own width, not a fixed one.
6651        assert_eq!(lines[1].trim_end(), "[src] one two three four");
6652        assert!(
6653            lines[2].starts_with("      "),
6654            "body doesn't hang: {:?}",
6655            lines[2]
6656        );
6657        assert_eq!(lines[2].trim(), "five six seven");
6658    }
6659
6660    #[test]
6661    fn a_code_block_leaves_exactly_one_blank_row_below_it() {
6662        // The closing fence line used to be miscounted as a blank separator,
6663        // opening a phantom second gap under the block. One block boundary is
6664        // one blank row, code block or not.
6665        let src = "para\n\n```\ncode\n```\n\nafter\n";
6666        let m = map(src);
6667        let code_end = m.code_blocks[0].rows_span.end;
6668        let after = m
6669            .rows
6670            .iter()
6671            .position(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "after")
6672            .unwrap();
6673        assert_eq!(
6674            after - code_end,
6675            1,
6676            "exactly one row between code and 'after'"
6677        );
6678    }
6679
6680    #[test]
6681    fn a_fenced_block_publishes_its_language_on_its_code_block() {
6682        // The info string becomes the block's label; a bare fence and an indented
6683        // block carry none.
6684        assert_eq!(
6685            map("```rust\nlet x = 1;\n```\n").code_blocks[0]
6686                .lang
6687                .as_deref(),
6688            Some("rust")
6689        );
6690        assert_eq!(map("```\nplain\n```\n").code_blocks[0].lang, None);
6691        assert_eq!(map("    indented\n").code_blocks[0].lang, None);
6692    }
6693
6694    /// The token every glyph spelling `ch` carries, in row order — how a test
6695    /// reads a block's highlighting off the map.
6696    fn tokens_of(m: &VisualMap, ch: char) -> Vec<Option<Token>> {
6697        m.rows
6698            .iter()
6699            .flat_map(|r| r.glyphs.iter())
6700            .filter(|g| g.ch == ch)
6701            .map(|g| g.style.token)
6702            .collect()
6703    }
6704
6705    #[cfg(feature = "syntax")]
6706    #[test]
6707    fn a_fenced_block_in_a_known_language_carries_tokens() {
6708        // `let` is a keyword, the string literal a string, and the plain
6709        // identifier `x` nothing at all — it draws in the code colour. Every
6710        // glyph is still `Role::Code`: a token is beside the role, not instead.
6711        let m = map("```rust\nlet x = \"s\";\n```\n");
6712        assert_eq!(tokens_of(&m, 'l'), vec![Some(Token::Keyword)]);
6713        assert_eq!(tokens_of(&m, 'x'), vec![None]);
6714        assert_eq!(tokens_of(&m, '"'), vec![Some(Token::String); 2]);
6715        assert!(
6716            m.rows
6717                .iter()
6718                .filter(|r| r.code)
6719                .flat_map(|r| r.glyphs.iter())
6720                .all(|g| g.style.role == Role::Code),
6721            "a token replaced the code role"
6722        );
6723    }
6724
6725    #[cfg(feature = "syntax")]
6726    #[test]
6727    fn a_token_changes_nothing_about_where_a_glyph_is() {
6728        // The same block with and without a language it can be highlighted in
6729        // lays out identically: same rows, same offsets, same stops. Only the
6730        // token differs, so the caret walks a highlighted block as it walked an
6731        // unhighlighted one.
6732        let hl = map("```rust\nlet x = 1; // c\nfn f() {}\n```\n");
6733        let plain = map("```text\nlet x = 1; // c\nfn f() {}\n```\n");
6734        assert_eq!(hl.rows.len(), plain.rows.len());
6735        for (a, b) in hl.rows.iter().zip(&plain.rows) {
6736            assert_eq!(a.end_src, b.end_src);
6737            assert_eq!(a.glyphs.len(), b.glyphs.len());
6738            for (ga, gb) in a.glyphs.iter().zip(&b.glyphs) {
6739                assert_eq!((ga.ch, ga.src, ga.stop), (gb.ch, gb.src, gb.stop));
6740                assert_eq!(ga.style.token(None), gb.style);
6741            }
6742        }
6743        assert!(tokens_of(&hl, 'l').iter().any(Option::is_some));
6744        assert!(tokens_of(&plain, 'l').iter().all(Option::is_none));
6745    }
6746
6747    #[test]
6748    fn a_block_with_no_language_to_highlight_in_carries_no_tokens() {
6749        // A bare fence, an indented block, a fence in a language no grammar
6750        // covers, and inline code all draw as plain code — and so does a
6751        // `rust` fence when the `syntax` feature is off.
6752        for src in [
6753            "```\nlet x = 1;\n```\n",
6754            "    let x = 1;\n",
6755            "```no-such-language\nlet x = 1;\n```\n",
6756            "a `let x` b\n",
6757        ] {
6758            assert!(
6759                tokens_of(&map(src), 'l').iter().all(Option::is_none),
6760                "{src:?} was highlighted"
6761            );
6762        }
6763        #[cfg(not(feature = "syntax"))]
6764        assert!(
6765            tokens_of(&map("```rust\nlet x = 1;\n```\n"), 'l')
6766                .iter()
6767                .all(Option::is_none)
6768        );
6769    }
6770
6771    #[test]
6772    fn inline_code_is_not_a_code_block() {
6773        // A `code` span inside prose is styled by role, not boxed: it's part of a
6774        // normal paragraph row, so it names no `code_blocks` entry.
6775        let m = map("a `snippet` b\n");
6776        assert!(m.code_blocks.is_empty(), "inline code wrongly boxed");
6777        assert!(
6778            m.rows.iter().all(|r| !r.code),
6779            "inline code flagged a code row"
6780        );
6781    }
6782
6783    #[test]
6784    fn caret_steps_over_hidden_delimiters() {
6785        // "a **bold** c": bytes 8,9 are the closing ** — no glyph. Moving right
6786        // from 'd' (src 7) lands on the space before 'c' (src 10), not inside **.
6787        let m = map("a **bold** c\n");
6788        let (r, c) = m.pos_of_offset(7);
6789        assert_eq!(m.offset_of_pos(r, c + 1), 10);
6790    }
6791
6792    // ── the structural view of a table ───────────────────────────────────────
6793
6794    #[test]
6795    fn a_table_is_published_structurally_beside_its_picture() {
6796        let m = map(TABLE);
6797        let t = &m.tables[0];
6798        let cell = |r: usize, c: usize| -> String {
6799            t.grid[r].cells[c].glyphs.iter().map(|g| g.ch).collect()
6800        };
6801        assert_eq!(t.grid.len(), 3, "head + two body rows");
6802        assert_eq!(
6803            (cell(0, 0), cell(0, 1), cell(1, 0), cell(2, 1)),
6804            ("Name".into(), "Qty".into(), "Pear".into(), "12".into())
6805        );
6806        assert_eq!(
6807            t.grid.iter().map(|r| r.head).collect::<Vec<_>>(),
6808            [true, false, false]
6809        );
6810        // The alignment the delimiter row spelled, carried per cell — the only
6811        // place it survives, since the parser consumes that row.
6812        assert!(matches!(t.grid[1].cells[0].align, Alignment::Left));
6813        assert!(matches!(t.grid[1].cells[1].align, Alignment::Right));
6814    }
6815
6816    #[test]
6817    fn a_block_media_is_published_structurally_beside_its_placeholder() {
6818        let m = map("intro\n\n![a cat](img/cat.png)\n\nend\n");
6819        assert_eq!(m.media.len(), 1, "one block image");
6820        let img = &m.media[0];
6821        assert_eq!(img.destination, "img/cat.png");
6822        assert_eq!(img.alt, "a cat");
6823        // The placeholder row named by `rows_span` carries the label a plain
6824        // surface paints and a capable frontend replaces.
6825        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
6826        assert_eq!(
6827            img.rows_span.end - img.rows_span.start,
6828            1,
6829            "one placeholder row"
6830        );
6831        assert_eq!(row_text(img.rows_span.start), "🖼 a cat");
6832        // The row carries the mark `media_spans` derives the side-table from.
6833        assert!(m.rows[img.rows_span.start].media.is_some());
6834    }
6835
6836    #[test]
6837    fn an_image_without_alt_labels_itself_with_its_filename() {
6838        let m = map("![](photos/beach.jpg)\n");
6839        let row = &m.rows[m.media[0].rows_span.start];
6840        assert_eq!(
6841            row.glyphs.iter().map(|g| g.ch).collect::<String>(),
6842            "🖼 beach.jpg"
6843        );
6844        assert_eq!(m.media[0].alt, "");
6845    }
6846
6847    #[test]
6848    fn a_block_media_gives_the_caret_a_home_before_and_after_it() {
6849        // `![x](y)` on its own line: the caret can rest in front of the image
6850        // (its start) and just past it (the row end), and nowhere inside the
6851        // markup — the same coarse mapping a thematic break uses.
6852        let src = "![x](y.png)\n";
6853        let m = map(src);
6854        let img = &m.rows[m.media[0].rows_span.start];
6855        let start = 0; // the image opens the document
6856        let end = "![x](y.png)".len();
6857        // Every placeholder glyph maps to the image start and is a stop there.
6858        assert!(img.glyphs.iter().all(|g| g.src == start && g.stop));
6859        assert_eq!(img.end_src, end, "the row ends past the image");
6860        assert_eq!(m.stops.first(), Some(&start));
6861        assert!(m.stops.contains(&end), "a stop sits after the image");
6862        // Nothing inside the markup is a stop.
6863        assert!(!m.stops.iter().any(|&s| s > start && s < end));
6864    }
6865
6866    #[test]
6867    fn an_inline_image_amid_text_is_not_a_block_media() {
6868        // An image sharing its line with prose isn't block-level: it stays in the
6869        // inline path (rendered as its alt text), and publishes no MediaInfo.
6870        let m = map("see ![a cat](cat.png) here\n");
6871        assert!(m.media.is_empty(), "not a block image");
6872        assert!(
6873            rendered(&m).contains("a cat"),
6874            "alt text still renders inline"
6875        );
6876    }
6877
6878    /// The block images `Doc` publishes for `src`, driven through the real
6879    /// production build (`build_visual` → `build_cached`) with `html_elements`
6880    /// on — the path a `<picture>` actually travels. Not the raw `build` the
6881    /// other tests use: the editor's flat whole-arena snapshot tangles the links
6882    /// of inline-promoted HTML (phantom roots, dangling `parent`s), which only
6883    /// the per-block subtree walk `build_cached` does untangles.
6884    fn doc_media(src: &str) -> Vec<MediaInfo> {
6885        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
6886        doc.build_visual(80);
6887        doc.vmap.media.clone()
6888    }
6889
6890    #[test]
6891    fn a_video_block_is_media_with_its_src_poster_and_kind() {
6892        // The load-bearing assumption of video support: twig has no `video` node
6893        // kind, so `html_elements` promotion must land a `<video>` as a generic
6894        // `element` whose tag name and attributes survive onto `FlatNode` — the
6895        // same treatment `<picture>` gets. If that ever stops holding, this is
6896        // the test that says so.
6897        let m = doc_media("<video src=\"clip.mp4\" poster=\"still.png\" controls>\n</video>\n");
6898        assert_eq!(m.len(), 1, "the video is one block media");
6899        assert_eq!(m[0].kind, MediaKind::Video);
6900        assert_eq!(m[0].destination, "clip.mp4");
6901        assert_eq!(m[0].poster, "still.png");
6902    }
6903
6904    #[test]
6905    fn a_single_line_video_is_a_block_too() {
6906        // The spelling everyone actually writes. It used to parse as a paragraph
6907        // of raw inline HTML — CommonMark opens a block on a complete tag only
6908        // when the line ends there, and its fixed tag list predates `<video>` —
6909        // so the tags never reached core as an element at all. twig 2.5.1 widened
6910        // that list under `html_elements`; this is the test that would catch the
6911        // pin sliding back.
6912        let m = doc_media("<video src=\"clip.mp4\" controls></video>\n");
6913        assert_eq!(m.len(), 1, "single-line <video> is a block");
6914        assert_eq!(m[0].kind, MediaKind::Video);
6915        assert_eq!(m[0].destination, "clip.mp4");
6916    }
6917
6918    #[test]
6919    fn a_single_line_picture_is_a_block_with_its_alternatives() {
6920        // `<picture>` had the identical gap and it went unnoticed because the
6921        // conventional spelling breaks the lines. Same twig fix covers it.
6922        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
6923                   <img src=\"l.svg\" alt=\"banner\"></picture>\n";
6924        let m = doc_media(src);
6925        assert_eq!(m.len(), 1);
6926        assert_eq!(m[0].kind, MediaKind::Image);
6927        assert_eq!(m[0].destination, "l.svg");
6928        assert_eq!(m[0].resolve(ColorScheme::Dark), "d.svg");
6929    }
6930
6931    #[test]
6932    fn an_audio_block_is_media_with_no_poster() {
6933        let m = doc_media("<audio src=\"take.mp3\" controls>\n</audio>\n");
6934        assert_eq!(m.len(), 1);
6935        assert_eq!(m[0].kind, MediaKind::Audio);
6936        assert_eq!(m[0].destination, "take.mp3");
6937        assert!(m[0].poster.is_empty(), "audio has no poster frame");
6938    }
6939
6940    #[test]
6941    fn a_videos_source_children_are_its_candidates_typed_by_mime() {
6942        // A `<video>` with no `src` of its own — the common shape, since it's how
6943        // you offer more than one codec. The candidates come from `<source src>`
6944        // (not `srcset`, which is `<picture>`'s spelling) and carry their MIME.
6945        let src = "<video controls>\n\
6946                   <source src=\"a.webm\" type=\"video/webm\">\n\
6947                   <source src=\"a.mp4\" type=\"video/mp4\">\n\
6948                   fallback\n\
6949                   </video>\n";
6950        let m = doc_media(src);
6951        assert_eq!(m.len(), 1);
6952        assert!(
6953            m[0].destination.is_empty(),
6954            "no src attribute on the element"
6955        );
6956        assert_eq!(m[0].sources.len(), 2);
6957        assert_eq!(m[0].sources[0].srcset, "a.webm");
6958        assert_eq!(m[0].sources[0].mime, "video/webm");
6959        assert_eq!(m[0].sources[1].srcset, "a.mp4");
6960        // With an empty destination, `resolve` falls through to the first
6961        // candidate rather than handing the frontend nothing to load.
6962        assert_eq!(m[0].resolve(ColorScheme::Light), "a.webm");
6963    }
6964
6965    #[test]
6966    fn a_video_placeholder_row_carries_its_own_sigil_and_mark() {
6967        // The placeholder contract images already hold, now for a video: the row
6968        // renders as a labelled stand-in a plain surface can paint as-is, and
6969        // carries the mark a capable frontend replaces it from.
6970        let src = "<video src=\"clip.mp4\" controls>\n</video>\n";
6971        let mut doc = crate::Doc::from_source(src.to_string(), Format::Markdown).unwrap();
6972        doc.build_visual(80);
6973        let row = &doc.vmap.rows[doc.vmap.media[0].rows_span.start];
6974        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
6975        assert!(
6976            text.starts_with('🎬'),
6977            "video sigil, not the image one: {text:?}"
6978        );
6979        assert!(row.media.is_some(), "the mark rides the placeholder row");
6980    }
6981
6982    #[test]
6983    fn a_picture_block_carries_its_source_alternatives() {
6984        // A `<picture>` with a dark-mode `<source>`: one block image, whose
6985        // fallback destination is the `<img>` and whose `sources` carry the
6986        // `<source>`'s media + srcset for a theme-aware frontend to pick.
6987        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"banner\"></picture>\n";
6988        let images = doc_media(src);
6989        assert_eq!(images.len(), 1, "the picture is one block image");
6990        let img = &images[0];
6991        assert_eq!(img.destination, "light.svg", "fallback is the <img>");
6992        assert_eq!(img.alt, "banner");
6993        assert_eq!(
6994            img.sources,
6995            vec![MediaSource {
6996                media: "(prefers-color-scheme: dark)".into(),
6997                srcset: "dark.svg".into(),
6998                mime: String::new(),
6999            }],
7000        );
7001    }
7002
7003    #[test]
7004    fn a_picture_inside_a_heading_is_still_a_block_media_with_sources() {
7005        // fig.md's shape: the banner is an `<h1>` wrapping the `<picture>`.
7006        let src = "<h1><picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\"><img src=\"l.svg\" alt=\"fig\"></picture></h1>\n";
7007        let images = doc_media(src);
7008        assert_eq!(images.len(), 1, "heading-wrapped picture is a block image");
7009        assert_eq!(images[0].destination, "l.svg");
7010        assert_eq!(images[0].sources.len(), 1);
7011        assert_eq!(images[0].sources[0].srcset, "d.svg");
7012    }
7013
7014    #[test]
7015    fn a_plain_image_has_no_media_sources() {
7016        // A bare Markdown image carries an empty `sources` — nothing to pick from.
7017        let images = doc_media("![alt](p.png)\n");
7018        assert_eq!(images.len(), 1);
7019        assert!(
7020            images[0].sources.is_empty(),
7021            "no <picture>, no alternatives"
7022        );
7023    }
7024
7025    #[test]
7026    fn resolve_picks_the_source_matching_the_scheme() {
7027        let src = "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"dark.svg\"><img src=\"light.svg\" alt=\"b\"></picture>\n";
7028        let images = doc_media(src);
7029        let img = &images[0];
7030        // Dark theme takes the dark source; light falls through to the <img>.
7031        assert_eq!(img.resolve(ColorScheme::Dark), "dark.svg");
7032        assert_eq!(img.resolve(ColorScheme::Light), "light.svg");
7033    }
7034
7035    #[test]
7036    fn resolve_falls_back_for_a_plain_image_and_unknown_media() {
7037        // A plain image ignores the scheme.
7038        let plain = doc_media("![a](p.png)\n");
7039        assert_eq!(plain[0].resolve(ColorScheme::Dark), "p.png");
7040
7041        // A <source> with an unrecognized media query is skipped; a light source
7042        // is taken under a light theme.
7043        let m = doc_media(
7044            "<picture><source media=\"print\" srcset=\"p.svg\"><source media=\"(prefers-color-scheme: light)\" srcset=\"l.svg\"><img src=\"f.svg\" alt=\"x\"></picture>\n",
7045        );
7046        assert_eq!(m[0].resolve(ColorScheme::Light), "l.svg");
7047        assert_eq!(
7048            m[0].resolve(ColorScheme::Dark),
7049            "f.svg",
7050            "no dark source → <img>"
7051        );
7052    }
7053
7054    #[test]
7055    fn resolve_reads_the_first_srcset_url_ignoring_descriptors() {
7056        // A comma/descriptor srcset resolves to its first URL.
7057        assert_eq!(first_srcset_url("a.png 1x, b.png 2x"), Some("a.png"));
7058        assert_eq!(first_srcset_url("  solo.svg  "), Some("solo.svg"));
7059        assert_eq!(first_srcset_url(""), None);
7060        // An empty (unconditional) media always matches.
7061        assert!(media_matches("", ColorScheme::Light));
7062        assert!(media_matches(
7063            "(prefers-color-scheme:dark)",
7064            ColorScheme::Dark
7065        ));
7066        assert!(!media_matches(
7067            "(prefers-color-scheme: dark)",
7068            ColorScheme::Light
7069        ));
7070    }
7071
7072    #[test]
7073    fn a_block_media_carries_its_list_prefix() {
7074        // An image that is a list item's body opens past the bullet, like every
7075        // other block does.
7076        let m = map("- ![alt](p.png)\n");
7077        let row = &m.rows[m.media[0].rows_span.start];
7078        let text: String = row.glyphs.iter().map(|g| g.ch).collect();
7079        assert!(
7080            text.starts_with("• "),
7081            "the list marker prefixes the image row: {text:?}"
7082        );
7083        assert!(text.contains("🖼 alt"));
7084    }
7085
7086    #[test]
7087    fn the_structural_table_spans_exactly_its_drawn_rows() {
7088        // A frontend drawing its own grid skips `rows_span` and renders from
7089        // `grid`. If the span were short the leftover border rows would be
7090        // painted as text under the real table; if long it would eat a
7091        // neighbouring paragraph. Both are silent, so pin it to the picture.
7092        let m = map(&format!("before\n\n{TABLE}\nafter\n"));
7093        let t = &m.tables[0];
7094        let row_text = |r: usize| -> String { m.rows[r].glyphs.iter().map(|g| g.ch).collect() };
7095        assert!(
7096            row_text(t.rows_span.start).starts_with('┌'),
7097            "opens on the top border"
7098        );
7099        assert!(
7100            row_text(t.rows_span.end - 1).starts_with('└'),
7101            "closes on the bottom border"
7102        );
7103        assert!(
7104            !row_text(t.rows_span.start - 1).contains('┌'),
7105            "the row before the span is not the table's"
7106        );
7107        assert_eq!(
7108            row_text(t.rows_span.end),
7109            "",
7110            "the span ends before the gap row"
7111        );
7112    }
7113
7114    #[test]
7115    fn a_nested_tables_structure_carries_the_block_prefix() {
7116        // The picture puts the quote's gutter on every row of the grid. A
7117        // frontend drawing its own table has to draw that too and start past it,
7118        // so the prefix has to travel with the structure — without it a quoted
7119        // table renders flush at the margin and leaves the quote it's in.
7120        let m = map("> | a | b |\n> |---|---|\n> | c | d |\n");
7121        let t = &m.tables[0];
7122        let prefix: String = t.prefix.iter().map(|g| g.ch).collect();
7123        assert_eq!(prefix, "│ ", "the quote's gutter should ride the structure");
7124        // And it matches what the picture actually drew.
7125        let drawn: String = m.rows[t.rows_span.start]
7126            .glyphs
7127            .iter()
7128            .map(|g| g.ch)
7129            .collect();
7130        assert!(
7131            drawn.starts_with(&prefix),
7132            "picture and structure disagree: {drawn:?}"
7133        );
7134    }
7135
7136    #[test]
7137    fn a_top_level_table_carries_no_prefix() {
7138        assert!(map(TABLE).tables[0].prefix.is_empty());
7139    }
7140
7141    #[test]
7142    fn structural_cells_are_unwrapped_even_when_the_picture_wraps_them() {
7143        // The picture wraps a cell to its column; a frontend laying the grid out
7144        // in pixels needs the text as the document spells it, before that
7145        // decision. Narrow enough that the drawn cell must break.
7146        let src = "| Name |\n|------|\n| alpha beta gamma |\n";
7147        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7148        let m = build_t(&ed.nodes().unwrap(), src, Some(12));
7149        let drawn = rendered(&m);
7150        let cell: String = m.tables[0].grid[1].cells[0]
7151            .glyphs
7152            .iter()
7153            .map(|g| g.ch)
7154            .collect();
7155        assert_eq!(
7156            cell, "alpha beta gamma",
7157            "structure must not carry the wrap"
7158        );
7159        assert!(
7160            drawn.lines().count() > 5,
7161            "the picture should have wrapped, else this proves nothing:\n{drawn}"
7162        );
7163    }
7164
7165    // ── display columns ──────────────────────────────────────────────────────
7166
7167    #[test]
7168    fn a_table_column_is_as_wide_as_its_cells_are_drawn() {
7169        // A column sized by counting characters is drawn narrower than the text
7170        // it has to hold — `你好` is two characters in four cells — and the cell
7171        // spills over the border it is supposed to sit inside, taking the whole
7172        // grid out of square with it. Squareness is the property: every row of a
7173        // grid is drawn to the same column, whatever its cells are spelled with.
7174        for src in [
7175            "| A | B |\n|---|---|\n| 你好 | y |\n",
7176            "| A | B |\n|---|---|\n| a👨‍👩‍👧b | y |\n",
7177            "| A | 漢字 |\n|---|---|\n| x | y |\n",
7178        ] {
7179            let m = map(src);
7180            let widths: Vec<usize> = m.rows.iter().map(|r| r.width()).collect();
7181            assert!(
7182                widths.windows(2).all(|w| w[0] == w[1]),
7183                "ragged grid {widths:?} for {src:?}:\n{}",
7184                rendered(&m)
7185            );
7186        }
7187    }
7188
7189    #[test]
7190    fn a_cell_wrapped_narrow_never_breaks_inside_a_character() {
7191        // A column too narrow for its cell hard-breaks the text, and every line
7192        // of it is given an end stop just past its last glyph. Broken into runs
7193        // of four glyphs, the first line of this cell ends between `👨‍👩` and the
7194        // joiner holding `👧` on — so its end stop lands inside a character,
7195        // where a click or Down can reach it and the next Backspace takes the
7196        // cluster apart from the middle.
7197        let src = "| A |\n|---|\n| 👨‍👩‍👧👨‍👩‍👧 |\n";
7198        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7199        let m = build_t(&ed.nodes().unwrap(), src, Some(8));
7200        let boundaries: Vec<usize> = src
7201            .grapheme_indices(true)
7202            .map(|(i, _)| i)
7203            .chain(std::iter::once(src.len()))
7204            .collect();
7205        for off in (0..=src.len()).filter(|&o| m.is_stop(o)) {
7206            assert!(
7207                boundaries.contains(&off),
7208                "stop at {off} is inside a character:\n{}",
7209                rendered(&m)
7210            );
7211        }
7212    }
7213
7214    #[test]
7215    fn a_wrapped_cell_keeps_every_line_inside_its_column() {
7216        // The width is a promise in a table, where a glyph past the column lands
7217        // on the border or in the next cell — and it is a promise about cells,
7218        // which is not what a count of glyphs measures.
7219        let src = "| A |\n|---|\n| 你好世界漢字 |\n";
7220        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7221        let m = build_t(&ed.nodes().unwrap(), src, Some(14));
7222        for r in &m.rows {
7223            assert_eq!(r.width(), 14, "{:?} is not drawn to the grid", rendered(&m));
7224        }
7225    }
7226
7227    #[test]
7228    fn a_hard_break_falls_between_clusters_and_measures_in_cells() {
7229        let glyphs = |s: &str| {
7230            let mut out = Vec::new();
7231            push_text(&mut out, s, 0, Style::default());
7232            out
7233        };
7234        let piece = |p: &[Glyph]| p.iter().map(|g| g.ch).collect::<String>();
7235
7236        // Six cells of CJK broken at four: two characters, then one — never
7237        // between the two cells of `好`.
7238        let w = glyphs("你好世");
7239        let pieces: Vec<String> = hard_break(&w, 4).iter().map(|p| piece(p)).collect();
7240        assert_eq!(pieces, ["你好", "世"]);
7241
7242        // A character wider than the column has nowhere legal to break, so it
7243        // keeps its cells rather than being cut in half.
7244        let w = glyphs("你好");
7245        let pieces: Vec<String> = hard_break(&w, 1).iter().map(|p| piece(p)).collect();
7246        assert_eq!(pieces, ["你", "好"]);
7247
7248        // An empty word yields no pieces at all — a double space stays a space.
7249        assert!(hard_break(&[], 4).is_empty());
7250    }
7251
7252    #[test]
7253    fn an_empty_list_item_still_gets_a_bulleted_row_with_a_caret_home() {
7254        // Pressing Enter at the end of a list item opens a new, empty item —
7255        // a childless `list_item`. Without a row of its own the new bullet
7256        // wouldn't appear until something was typed into it (the caret would be
7257        // stranded on an offset no row draws). It now renders as one prefixed
7258        // row whose end is a caret stop, so the bullet shows and the caret lands
7259        // just past the marker.
7260        let m = map("- item\n- \n");
7261        assert_eq!(m.num_rows(), 2, "the empty second item needs its own row");
7262        assert_eq!(
7263            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7264            "• ",
7265            "the empty item draws just its bullet",
7266        );
7267        // Its end is the caret home (past the `- ` marker), and it's a real stop.
7268        assert!(
7269            m.is_stop(m.rows[1].end_src),
7270            "the empty item's caret home is not a stop"
7271        );
7272        assert_eq!(
7273            m.pos_of_offset(m.rows[1].end_src),
7274            (1, 2),
7275            "caret sits after '• '"
7276        );
7277    }
7278
7279    #[test]
7280    fn a_notes_row_range_stops_at_the_note_even_when_it_ends_in_a_link() {
7281        // The peek bug: a note whose body ends in a link has its last byte
7282        // inside the hidden destination, so mapping `end - 1` through
7283        // `pos_of_offset` snapped *forward* — past its own row, past the drawn
7284        // gap, and onto the next note's row. The popover then drew both notes.
7285        let src = "A[^1] B[^2].\n\n[^1]: bare text\n\n[^2]: [title](https://example.com/x)\n\n[^3]: last\n";
7286        let m = map(src);
7287        let body = src.find("[title]").unwrap();
7288        let end = src.find("\n\n[^3]").unwrap();
7289
7290        let (first, last) = m.row_range_for(body..end);
7291        assert_eq!(
7292            first, last,
7293            "a one-block note is one row, not a span onto the next"
7294        );
7295
7296        // The old arithmetic, kept here as the thing that must stay wrong: it
7297        // is what this method exists instead of.
7298        assert_ne!(
7299            m.pos_of_offset(end - 1).0,
7300            last,
7301            "the forward snap still leaves the note's row — that is the whole point",
7302        );
7303
7304        // A note ending in *visible* text was never broken, and still isn't:
7305        // both readings agree there, which is why the original test missed it.
7306        let plain = src.find("bare text").unwrap();
7307        let plain_end = src.find("\n\n[^2]").unwrap();
7308        let (pf, pl) = m.row_range_for(plain..plain_end);
7309        assert_eq!(pf, pl);
7310        assert_eq!(m.pos_of_offset(plain_end - 1).0, pl);
7311    }
7312
7313    #[test]
7314    fn a_row_range_covers_every_row_of_a_block_that_spans_several() {
7315        // The range is a span, not a point: a quote of two paragraphs covers its
7316        // gap row and both of its text rows, so a peek draws the whole thing.
7317        let src = "> one\n>\n> two\n\nafter\n";
7318        let m = map(src);
7319        let (first, last) = m.row_range_for(0..src.find("\n\nafter").unwrap());
7320        assert_eq!((first, last), (0, 2));
7321
7322        // And a range with no visible byte at all still covers the row it opened
7323        // on, rather than collapsing to nothing.
7324        let (f, l) = m.row_range_for(0..1);
7325        assert_eq!((f, l), (0, 0));
7326    }
7327
7328    #[test]
7329    fn an_empty_block_quote_still_gets_a_gutter_row_with_a_caret_home() {
7330        // The peer of the empty list item, and the case that made an empty line
7331        // in a quote draw as plain body text: a childless `block_quote` — a bare
7332        // `> `, which is what the toolbar's Quote button leaves on a blank line —
7333        // has no inner block to carry the gutter, so the whole quote used to
7334        // render as *nothing*. It didn't merely lose its bar; the row went away
7335        // and the caret had no home on it.
7336        let m = map("a\n\n> \n\nb\n");
7337        assert_eq!(
7338            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
7339            "│ ",
7340            "the empty quote draws just its gutter",
7341        );
7342        assert!(
7343            m.rows[2]
7344                .glyphs
7345                .iter()
7346                .all(|g| g.style.role == Role::QuoteGutter)
7347        );
7348        assert!(
7349            !m.rows[2].decoration,
7350            "it is a line text can go on, not a drawn gap"
7351        );
7352        assert!(
7353            m.is_stop(m.rows[2].end_src),
7354            "the empty quote's caret home is not a stop"
7355        );
7356        assert_eq!(
7357            m.pos_of_offset(m.rows[2].end_src),
7358            (2, 2),
7359            "caret sits after '│ '"
7360        );
7361
7362        // And a document that is *only* an empty quote still renders a row — it
7363        // used to render none at all, leaving the caret nowhere to stand.
7364        let m = map("> \n");
7365        assert_eq!(m.num_rows(), 1);
7366        assert_eq!(
7367            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7368            "│ "
7369        );
7370    }
7371
7372    #[test]
7373    fn a_quotes_own_trailing_marker_lines_stay_inside_the_quote() {
7374        // Enter at the end of `> a` writes `> a\n>\n> \n`. Those last two lines
7375        // hold no block — a quote's `content_span` stops at its last child — so
7376        // the children walk never reaches them, and they used to fall through to
7377        // the document-level trailing pass, which knows no prefix: the gutter
7378        // stopped and the writer's new line drew as plain prose. Fixable only
7379        // since twig 3.2.0, where the quote's *span* covers its own marker lines
7380        // (`0..3` before, `0..8` now) and there is finally a node saying they
7381        // are the quote's.
7382        let m = map("> a\n>\n> \n");
7383        assert_eq!(m.num_rows(), 3, "one row per line the quote spells");
7384        for (i, row) in m.rows.iter().enumerate() {
7385            let text = row.glyphs.iter().map(|g| g.ch).collect::<String>();
7386            assert!(text.starts_with("│ "), "row {i} lost the gutter: {text:?}");
7387            assert!(
7388                !row.decoration,
7389                "row {i} is a line to type on, not a drawn gap"
7390            );
7391            assert!(m.is_stop(row.end_src), "row {i} has no caret home");
7392        }
7393        assert_eq!(
7394            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7395            "│ a"
7396        );
7397        // Distinct offsets, so ↑/↓ between them moves the caret rather than
7398        // landing twice on the same byte.
7399        assert!(m.rows[0].end_src < m.rows[1].end_src);
7400        assert!(m.rows[1].end_src < m.rows[2].end_src);
7401
7402        // A blank line *after* the quote is not the quote's: it is spelled with
7403        // no marker, so it stays an ordinary boundary and the gutter ends.
7404        let m = map("> a\n\nb\n");
7405        assert_eq!(m.num_rows(), 3);
7406        assert_eq!(
7407            m.rows[2].glyphs.iter().map(|g| g.ch).collect::<String>(),
7408            "b"
7409        );
7410        assert!(
7411            !m.rows[1]
7412                .glyphs
7413                .iter()
7414                .any(|g| g.style.role == Role::QuoteGutter)
7415        );
7416
7417        // Nesting is the case this could get wrong, and the depth has to come
7418        // from which quote's span the line falls in rather than from the row
7419        // above it. A trailing `>` under `> > a` matches only the OUTER quote,
7420        // so it wears one gutter; spell it `> >` and it wears two.
7421        let m = map("> > a\n>\n");
7422        assert_eq!(
7423            m.rows[0].glyphs.iter().map(|g| g.ch).collect::<String>(),
7424            "│ │ a"
7425        );
7426        assert_eq!(
7427            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7428            "│ "
7429        );
7430        let m = map("> > a\n> >\n");
7431        assert_eq!(
7432            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7433            "│ │ "
7434        );
7435
7436        // And a marker line BETWEEN two quoted paragraphs is untouched: that is
7437        // the boundary `emit_separators_before` spells, and it stays a drawn gap
7438        // rather than becoming a line to type on.
7439        let m = map("> a\n>\n> b\n");
7440        assert_eq!(m.num_rows(), 3);
7441        assert!(
7442            m.rows[1].decoration,
7443            "the gap between two quoted blocks is still a gap"
7444        );
7445    }
7446
7447    #[test]
7448    fn an_empty_ordered_item_gets_its_number_and_a_caret_home() {
7449        let m = map("1. item\n2. \n");
7450        assert_eq!(m.num_rows(), 2);
7451        assert_eq!(
7452            m.rows[1].glyphs.iter().map(|g| g.ch).collect::<String>(),
7453            "2. "
7454        );
7455        assert!(m.is_stop(m.rows[1].end_src));
7456        assert_eq!(
7457            m.pos_of_offset(m.rows[1].end_src),
7458            (1, 3),
7459            "caret sits after '2. '"
7460        );
7461    }
7462
7463    #[test]
7464    fn an_empty_headings_caret_home_is_past_its_hidden_marker() {
7465        // The toolbar's H1 on a blank line writes `# ` and nothing else. The row
7466        // it renders is empty (the marker is hidden), so its end *is* its only
7467        // caret stop — and it has to be the offset past the `# `, where typing
7468        // continues the heading. Anchored at the block's start instead, the caret
7469        // drew in front of the hashes and the first character typed there landed
7470        // before them (`x# `), which isn't a heading at all.
7471        let m = map("# \n");
7472        assert_eq!(m.num_rows(), 1);
7473        assert!(m.rows[0].glyphs.is_empty(), "the `# ` marker is hidden");
7474        assert_eq!(m.rows[0].end_src, 2, "the caret home is past the marker");
7475        assert!(m.is_stop(2), "the empty heading's caret home is not a stop");
7476    }
7477
7478    #[test]
7479    fn a_headings_rows_carry_its_level_even_with_nothing_typed_in_it() {
7480        // The row-level fact a proportional frontend sizes a whole line by. An
7481        // empty heading has no glyph to read a `Role::Heading` off, so a renderer
7482        // scanning glyphs drew `# ` (and its caret) at body height until the
7483        // first character landed.
7484        let m = map("# \n");
7485        assert_eq!(
7486            m.rows[0].heading,
7487            Some(1),
7488            "the empty heading knows its level"
7489        );
7490
7491        // Every row of one that wraps, not just the first — and nothing else.
7492        let m = map_at(
7493            "## a heading long enough to wrap over two rows\n\nbody\n",
7494            Some(20),
7495        );
7496        let heads: Vec<Option<u8>> = m.rows.iter().map(|r| r.heading).collect();
7497        assert!(
7498            heads.iter().filter(|h| **h == Some(2)).count() >= 2,
7499            "got {heads:?}"
7500        );
7501        assert_eq!(
7502            m.rows.last().and_then(|r| r.heading),
7503            None,
7504            "the paragraph under it is not a heading",
7505        );
7506    }
7507
7508    #[test]
7509    fn an_empty_heading_leaves_the_rows_under_it_at_their_own_offsets() {
7510        // The row's end is also what the *next* row's separator is measured from,
7511        // so an empty heading that under-reported it shifted every offset below —
7512        // and the blank line under the heading then claimed the same offset as the
7513        // heading's own end. `pos_of_offset` resolves such a tie downstream (a
7514        // soft wrap belongs to the row below), so the caret at the end of the
7515        // heading was drawn two rows lower, on the blank line.
7516        // `text\n\n# \n\n`: the heading's content opens at 8, and the two rows
7517        // under it end at 9 and 10 — the blank line and the document's end.
7518        let m = map("text\n\n# \n\n");
7519        let end = m.rows.last().expect("a trailing blank row").end_src;
7520        assert_eq!(end, 10, "the trailing rows must end at their real offsets");
7521        // The heading's caret home is its own row's, not one shared with a row
7522        // below — the tie that drew the caret two rows down.
7523        assert_eq!(m.pos_of_offset(8), (2, 0), "the empty heading's own row");
7524        assert!(
7525            m.rows[3..].iter().all(|r| r.end_src > 8),
7526            "rows below own later offsets"
7527        );
7528    }
7529
7530    // ── block boundaries ─────────────────────────────────────────────────────
7531
7532    /// Every drawn boundary in `src`, in order, as `(above, below)`.
7533    fn boundaries(m: &VisualMap) -> Vec<(BlockClass, BlockClass)> {
7534        m.rows
7535            .iter()
7536            .filter_map(|r| r.boundary)
7537            .map(|b| (b.above, b.below))
7538            .collect()
7539    }
7540
7541    #[test]
7542    fn a_boundary_says_which_blocks_it_divides() {
7543        use BlockClass::*;
7544        let m = map("one\n\ntwo\n\n# Head\n\ntail\n\n> quoted\n\n```\ncode\n```\n");
7545        assert_eq!(
7546            boundaries(&m),
7547            vec![
7548                (Paragraph, Paragraph),
7549                (Paragraph, Heading),
7550                (Heading, Paragraph),
7551                (Paragraph, Quote),
7552                (Quote, Code),
7553                // The blank the document trails off with is a boundary too — it
7554                // closes the last block above the empty paragraph the caret rests
7555                // on. See `emit_trailing_blank_lines`.
7556                (Code, Paragraph),
7557            ],
7558            "each gap names the pair it falls between, in document order"
7559        );
7560    }
7561
7562    // ── hidden blocks ────────────────────────────────────────────────────────
7563
7564    /// The row texts of `m`, one string per row.
7565    fn row_texts(m: &VisualMap) -> Vec<String> {
7566        m.rows
7567            .iter()
7568            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7569            .collect()
7570    }
7571
7572    #[test]
7573    fn a_comment_between_two_blocks_is_stepped_over_not_drawn_as_a_gap() {
7574        // `<!-- exec -->` is a top-level block that draws no rows. The blocks
7575        // either side of it meet across the one boundary a paragraph and a code
7576        // block always meet across — not that boundary *plus* one blank row per
7577        // line of the comment, which is what counting the separator from the
7578        // paragraph's end used to spell.
7579        let m = map("para one\n\n<!-- exec -->\n```\ncode\n```\n\nafter\n");
7580        assert_eq!(row_texts(&m), ["para one", "", "code", "", "after"]);
7581        assert_eq!(
7582            boundaries(&m),
7583            vec![
7584                (BlockClass::Paragraph, BlockClass::Code),
7585                (BlockClass::Code, BlockClass::Paragraph),
7586            ],
7587            "the boundary names the drawn blocks either side, not the comment"
7588        );
7589        // The gap stands past the comment, so the caret's row lookup never
7590        // resolves inside it.
7591        assert_eq!(
7592            m.rows[1].end_src, 23,
7593            "the gap row ends at the comment's end"
7594        );
7595    }
7596
7597    #[test]
7598    fn a_comment_opening_the_document_draws_no_leading_gap() {
7599        let m = map("<!-- lead -->\n\npara\n");
7600        assert_eq!(row_texts(&m), ["para"]);
7601        assert_eq!(m.content_start, 0, "the comment is still the first block");
7602    }
7603
7604    #[test]
7605    fn a_comment_closing_the_document_is_not_trailing_blank_lines() {
7606        // Its lines are not blank lines the author opened with Enter, so no
7607        // gap-plus-empty-paragraph is fabricated under the last drawn block.
7608        let m = map("para\n\n<!-- trail -->\n");
7609        assert_eq!(row_texts(&m), ["para"]);
7610        // Enter at the end of the document still opens the empty paragraph the
7611        // caret rests on: the newlines *after* the comment count as they would
7612        // after any block.
7613        let m = map("para\n\n<!-- trail -->\n\n");
7614        assert_eq!(row_texts(&m), ["para", "", ""]);
7615    }
7616
7617    #[test]
7618    fn a_comment_in_a_list_item_leaves_the_bullet_to_what_follows_it() {
7619        // The first *drawn* child wears the item's marker; a hidden first child
7620        // would otherwise take it and leave the text without one.
7621        let m = map("- <!-- note -->\n\n  text\n- two\n");
7622        let texts = row_texts(&m);
7623        assert!(
7624            texts.iter().any(|t| t == "• text"),
7625            "the text wears the bullet: {texts:?}"
7626        );
7627        assert!(
7628            !texts.iter().any(|t| t == "• "),
7629            "no empty bullet row for the comment: {texts:?}"
7630        );
7631    }
7632
7633    #[test]
7634    fn the_cached_build_does_not_spell_the_document_out_as_blank_rows_after_a_comment() {
7635        // The bug as seen: a 200-line document with one comment in it rendered
7636        // ~200 blank rows after the comment, one per source line, because the
7637        // comment's per-block builder handed back a `last_off` of 0. Parity with
7638        // `build` alone would not catch a *shared* wrong answer, so the count is
7639        // pinned outright.
7640        let body = (0..200)
7641            .map(|i| format!("line {i}"))
7642            .collect::<Vec<_>>()
7643            .join("\n\n");
7644        let src = format!("intro\n\n<!-- exec -->\n{body}\n");
7645        let mut ed = Editor::new_str(&src, Format::Markdown).unwrap();
7646        let mut cache = BlockCache::default();
7647        let (plain, cached) = render_both(&mut ed, &src, Some(80), &mut cache);
7648        assert_maps_eq(&plain, &cached, "comment then 200 paragraphs");
7649        // intro, then 200 × (gap, paragraph): 401 rows and not a row more.
7650        assert_eq!(cached.rows.len(), 401);
7651    }
7652
7653    #[test]
7654    fn a_link_reference_definition_is_stepped_over_like_a_comment() {
7655        // `[a]: /a` is a root beside `doc` with no rows of its own. Merged into
7656        // the walk it is a hidden block: the blocks either side meet across one
7657        // boundary, and its line is not a blank row.
7658        let m = map("see [a]\n\n[a]: /a\n\nafter\n");
7659        assert_eq!(row_texts(&m), ["see a", "", "after"]);
7660        assert_eq!(
7661            boundaries(&m),
7662            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
7663        );
7664    }
7665
7666    #[test]
7667    fn link_reference_definitions_closing_the_document_are_not_trailing_blank_lines() {
7668        // The README shape: prose, then a `[links]` block nobody reads. Its
7669        // lines used to be counted as blank ones, an empty paragraph per
7670        // definition under the last real block.
7671        let m = map("see [a] and [b]\n\n<!-- links -->\n[a]: /a\n[b]: /b \"bee\"\n");
7672        assert_eq!(row_texts(&m), ["see a and b"]);
7673    }
7674
7675    #[test]
7676    fn a_definition_glued_under_a_paragraph_stays_inside_it() {
7677        // `[a]: /a` at the front of a paragraph's lines is stripped from the
7678        // paragraph's text, but the paragraph's span still starts on its line.
7679        // Both blocks start at the same offset; the definition, sorted first,
7680        // is stepped over, and the paragraph draws as it always did — one gap
7681        // above it, none inside.
7682        let m = map("intro\n\n[a]: /a\ntext [a]\n");
7683        assert_eq!(row_texts(&m), ["intro", "", "text a"]);
7684    }
7685
7686    #[test]
7687    fn a_definition_with_no_span_is_left_out_of_the_walk() {
7688        // twig before 3.3.3 reported `0..0` for every link reference
7689        // definition. One of those has nowhere to be merged: sorted first by
7690        // its zero start it would open the document with a phantom block, and
7691        // the walk would step back to offset 0. It is simply not a block. A
7692        // footnote definition is always placed; it has a body to draw.
7693        assert!(!is_placed_definition(&Kind::Reference, &(0..0)));
7694        assert!(is_placed_definition(&Kind::Reference, &(7..14)));
7695        assert!(is_placed_definition(&Kind::Footnote, &(0..0)));
7696        assert!(!is_placed_definition(&Kind::Str, &(7..14)));
7697    }
7698
7699    #[test]
7700    fn the_trailing_gap_closes_the_last_block() {
7701        // Two Enters at the end of a document: a drawn gap, then the navigable
7702        // empty paragraph. Only the gap is labelled, so a frontend that shrinks
7703        // boundaries shrinks the spacer and leaves the row being typed on alone.
7704        let m = map("# Head\n\n\n");
7705        assert_eq!(
7706            boundaries(&m),
7707            vec![(BlockClass::Heading, BlockClass::Paragraph)]
7708        );
7709    }
7710
7711    #[test]
7712    fn only_the_drawn_gap_rows_carry_a_boundary() {
7713        let m = map("one\n\ntwo\n");
7714        for row in &m.rows {
7715            assert_eq!(
7716                row.boundary.is_some(),
7717                row.decoration,
7718                "a boundary is exactly a drawn gap row: {:?}",
7719                row.glyphs.iter().map(|g| g.ch).collect::<String>()
7720            );
7721        }
7722    }
7723
7724    #[test]
7725    fn preserve_flow_labels_no_boundary() {
7726        // Every blank line is a caret home there — somewhere text can go, not a
7727        // gap between blocks — so nothing is drawn-only and nothing is labelled.
7728        // A frontend keying its spacing off `boundary` can't shrink a row the
7729        // author is about to type on.
7730        let m = map_preserve("one\n\ntwo\n\n# Head\n", Some(80));
7731        assert!(boundaries(&m).is_empty());
7732    }
7733
7734    #[test]
7735    fn a_list_draws_no_boundary_between_its_items() {
7736        // Tight or loose, core puts no gap row between two items of one list —
7737        // so an item↔item boundary is a shape no frontend will ever be handed,
7738        // and spacing one is spacing something that isn't there.
7739        for src in ["- one\n- two\n", "- one\n\n- two\n"] {
7740            let m = map(src);
7741            assert!(
7742                boundaries(&m).is_empty(),
7743                "no gap row inside the list of {src:?}"
7744            );
7745        }
7746        // Leaving the list is an ordinary boundary, and the list is named as
7747        // what sits above it.
7748        let m = map("- one\n- two\n\npara\n");
7749        assert_eq!(
7750            boundaries(&m),
7751            vec![(BlockClass::List, BlockClass::Paragraph)]
7752        );
7753    }
7754
7755    #[test]
7756    fn a_nested_boundary_names_the_blocks_inside_the_container() {
7757        // Two paragraphs inside a blockquote are divided by a Paragraph↔Paragraph
7758        // boundary — the quote is the container they're both in, not what the gap
7759        // separates.
7760        let m = map("> one\n>\n> two\n");
7761        assert_eq!(
7762            boundaries(&m),
7763            vec![(BlockClass::Paragraph, BlockClass::Paragraph)]
7764        );
7765    }
7766
7767    #[test]
7768    fn a_directive_container_draws_one_boundary_like_every_other_block() {
7769        // A container's rows stop at its last *child*, so without anchoring
7770        // `last_off` past the closing `:::` the separator logic counted the fence
7771        // line as a blank row of its own and drew the gap twice — one authored
7772        // blank line, two boundaries, and a frontend spacing each of them put
7773        // double margin under every fenced div. The code-block arm anchors past
7774        // its ``` for exactly this reason; compare the two here.
7775        let fenced = map_directives(":::note\nin\n:::\n\ntwo\n");
7776        assert_eq!(
7777            boundaries(&fenced),
7778            vec![(BlockClass::Directive, BlockClass::Paragraph)],
7779            "one authored gap, one boundary row"
7780        );
7781        let code = map("```\nc\n```\n\ntwo\n");
7782        assert_eq!(
7783            boundaries(&code).len(),
7784            boundaries(&fenced).len(),
7785            "a fenced div spaces like a fenced code block"
7786        );
7787        // Nesting closes several fences at once; still one gap.
7788        let nested = map_directives(":::a\n:::b\nin\n:::\n:::\n\ntwo\n");
7789        assert_eq!(
7790            boundaries(&nested),
7791            vec![(BlockClass::Directive, BlockClass::Paragraph)]
7792        );
7793    }
7794
7795    #[test]
7796    fn a_block_media_names_itself_in_the_boundaries_either_side() {
7797        use BlockClass::*;
7798        // A block image is never a node of its own — `media_only` promotes the
7799        // *paragraph* wrapping it — so classifying the node the walk stands on
7800        // called the picture `Paragraph` and left `BlockClass::Media` unreachable:
7801        // a frontend could not give a photo more air than a line of prose.
7802        // `label_media_boundaries` reads it back off the finished rows instead.
7803        let m = map("one\n\n![alt](p.png)\n\ntwo\n");
7804        assert_eq!(boundaries(&m), vec![(Paragraph, Media), (Media, Paragraph)]);
7805        // At the edges of the document too: the leading gap has no boundary of
7806        // its own, and the trailing one is `emit_trailing_blank_lines`'.
7807        let edges = map("![a](p.png)\n\nmid\n\n![b](q.png)\n");
7808        assert_eq!(
7809            boundaries(&edges),
7810            vec![(Media, Paragraph), (Paragraph, Media)]
7811        );
7812        // One gap spelled with several rows — the row closing the block above and
7813        // the row opening the one below, with the author's spare blank line
7814        // navigable between them — carries the same pair on every drawn row.
7815        let roomy = map("one\n\n\n\n![alt](p.png)\n");
7816        assert_eq!(
7817            boundaries(&roomy),
7818            vec![(Paragraph, Media), (Paragraph, Media)]
7819        );
7820    }
7821
7822    #[test]
7823    fn a_block_video_is_media_at_its_boundaries_not_a_directive_panel() {
7824        // Worse than the image case before `label_media_boundaries`: a `<video>`
7825        // arrives as twig's generic `container`, which classifies `Directive` —
7826        // the one class a frontend reads as "draw a tinted panel here". A movie
7827        // got the chrome of a fenced div.
7828        let mut doc = crate::Doc::from_source(
7829            "one\n\n<video src=\"v.mp4\"></video>\n\ntwo\n".to_string(),
7830            Format::Markdown,
7831        )
7832        .unwrap();
7833        doc.build_visual(80);
7834        assert_eq!(
7835            boundaries(&doc.vmap),
7836            vec![
7837                (BlockClass::Paragraph, BlockClass::Media),
7838                (BlockClass::Media, BlockClass::Paragraph),
7839            ]
7840        );
7841    }
7842
7843    #[test]
7844    fn the_incremental_walk_labels_boundaries_like_the_full_one() {
7845        // `assert_maps_eq` compares boundaries too, so this pins the two doors
7846        // into `BlockClass::from_node_kind` — a `FlatNode`'s kind on the full
7847        // build, a query match's on the cached one — against a document with one
7848        // of every boundary in it.
7849        let src = "one\n\n# Head\n\ntwo\n\n- a\n- b\n\n> q\n\n```\nc\n```\n\npara\n";
7850        let mut ed = Editor::new_str(src, Format::Markdown).unwrap();
7851        let mut cache = BlockCache::default();
7852        let (full, cached) = render_both(&mut ed, src, Some(80), &mut cache);
7853        assert_maps_eq(&full, &cached, "boundary labelling");
7854        assert!(
7855            !boundaries(&full).is_empty(),
7856            "the fixture has boundaries to compare"
7857        );
7858    }
7859
7860    #[test]
7861    fn every_caret_stop_opens_a_cluster_of_its_row() {
7862        // The two ways of finding a cluster have to agree. `push_text` marks the
7863        // stops by segmenting one run of text; the column mapping segments the
7864        // whole row, decoration and all. A stop that came out as the *middle* of
7865        // some row-level cluster would be a caret with no column of its own —
7866        // drawn at the column of whatever swallowed it.
7867        let src = "# 標題\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` 你好\n\n\
7868                   - 項目 one\n- e\u{0301}dge\n\n> 引用 text\n\n\
7869                   | A | 值 |\n|---|---|\n| 你好 | 👩‍🚀 |\n";
7870        let m = map(src);
7871        for (r, row) in m.rows.iter().enumerate() {
7872            let openers: Vec<usize> = clusters(&row.glyphs).iter().map(|c| c.glyph).collect();
7873            for (i, g) in row.glyphs.iter().enumerate() {
7874                assert!(
7875                    !g.stop || openers.contains(&i),
7876                    "row {r}: the stop at glyph {i} ({:?}) is inside a cluster, \
7877                     so it is drawn at another glyph's column",
7878                    g.ch
7879                );
7880            }
7881        }
7882    }
7883}