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