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