Skip to main content

leaf_core/
doc.rs

1//! The document model: a `twig::Editor` plus a byte-offset caret and selection.
2//!
3//! Where bough moves a selection through the *tree*, leaf moves a *caret*
4//! through the *characters* — a normal text editor's model — and expresses
5//! every mutation as one of twig's offset-addressed ops:
6//!
7//!   - typing / delete  → `edit_range(start, end, text)`   (P0)
8//!   - re-anchoring      → the returned `Change`            (P1)
9//!   - cursor context    → `node_at` / `ancestors_at`       (P3)
10//!   - the toolbar       → `wrap_range`/`toggle_inline`/`set_block`,
11//!                         `toggle_block_container`/`insert_link`   (P5)
12//!
13//! twig reparses after every edit and leaves everything outside the splice
14//! byte-for-byte untouched, so the document stays a live, navigable AST while
15//! you type into it.
16
17// `PathBuf` names the `path` field and the untitled marker on every build;
18// `Path` is only touched by the filesystem I/O gated behind the `fs` feature.
19// The docs in this file lay their `- key → meaning` lists out in aligned
20// columns, which puts a continuation line further right than clippy's
21// list-indent rule likes. A lazy continuation renders as the same paragraph
22// either way, and the alignment is what makes those tables readable, so the
23// layout wins over the lint.
24#![allow(clippy::doc_overindented_list_items)]
25
26use std::collections::HashMap;
27use std::ops::Range;
28#[cfg(feature = "fs")]
29use std::path::Path;
30use std::path::PathBuf;
31
32#[cfg(feature = "fs")]
33use anyhow::Context;
34use anyhow::{Result, anyhow};
35use twig::{
36    Alignment, BlockContainerKind, BlockKind, Change, Editor, FlatNode, Format, Gesture,
37    InlineKind, Kind, MarkdownExtensions, NodeId, QueryMatch,
38};
39use unicode_segmentation::GraphemeCursor;
40
41use crate::html;
42use crate::source::{self, SourceMap};
43use crate::style::MarkColor;
44use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
45
46/// Which view the body shows.
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub enum View {
49    /// The raw document with a caret in source bytes.
50    Source,
51    /// Markup resolved to real styles, caret riding the rendered glyphs.
52    Wysiwyg,
53}
54
55/// How much of the source markup the WYSIWYG view exposes — a per-editor
56/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
57/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
58/// terms, and every rung below is about *delimiters*, whatever grammar spells
59/// them. The examples are Markdown only because that is what most documents are.
60///
61/// A single ladder over two underlying axes, because only three of their four
62/// combinations are coherent:
63///
64/// | | authoring off | authoring on |
65/// |---|---|---|
66/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
67/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
68///
69/// The empty quadrant would show delimiters on the caret's line and then escape
70/// the ones you type — a surface that displays a syntax it refuses to accept.
71/// Someone who wants to read raw markup without authoring it has
72/// [`View::Source`], which is the better tool for it.
73///
74/// The two axes are read separately by the code that cares — see
75/// [`reveals_caret_line`](Self::reveals_caret_line) and
76/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
77/// as a ladder.
78#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
79pub enum MarkupMode {
80    /// Delimiters stay hidden even on the caret's line, and typed syntax stays
81    /// literal — twig escapes anything that would open markup, so formatting
82    /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
83    /// reading surface for people who don't write markup by hand; the default,
84    /// and what Diaryx ships.
85    #[default]
86    None,
87    /// Delimiters stay hidden, but typing them authors real markup: `*x*`
88    /// becomes italic and the asterisks disappear into the styling
89    /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
90    /// clean surface back once it has been applied.
91    Shortcuts,
92    /// The caret's line shows its raw markup while every other line renders
93    /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
94    /// — for people fluent in the document's grammar who want to see and edit
95    /// the delimiters they type.
96    Full,
97}
98
99impl MarkupMode {
100    /// Whether the rich view shows raw delimiters on the line holding the caret.
101    /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
102    /// WYSIWYG builder.
103    pub fn reveals_caret_line(self) -> bool {
104        matches!(self, MarkupMode::Full)
105    }
106
107    /// Whether typed markup characters author real formatting. The editing axis
108    /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
109    pub fn authors(self) -> bool {
110        !matches!(self, MarkupMode::None)
111    }
112}
113
114/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
115/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
116/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
117/// either flow. The renderer consults it when it lays a block's inline content
118/// into visual rows.
119#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
120pub enum LineFlow {
121    /// A soft break folds into a space and the paragraph reflows to the
122    /// viewport width — flowing prose, where the source's line wrapping is
123    /// insignificant. The default, and what Diaryx ships.
124    #[default]
125    Fold,
126    /// A soft break renders as a line break exactly where it was written, so
127    /// the author's source line structure shows on screen unchanged — the mode
128    /// for people who lay out their prose deliberately (one sentence or clause
129    /// per line, semantic line breaks). The break is still a soft break in the
130    /// source; only its rendering changes.
131    Preserve,
132}
133
134/// What the file behind a document looks like right now, against the bytes leaf
135/// last read from it or wrote to it — the question a frontend asks before it
136/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
137/// happen) or when its window regains focus. See [`Doc::disk_state`].
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum DiskState {
140    /// The file holds exactly the bytes leaf last read or wrote.
141    Unchanged,
142    /// Someone else wrote the file since. Saving overwrites their work; see
143    /// [`Doc::reload`] for the other direction.
144    Changed,
145    /// The file is gone — deleted or renamed away. A save recreates it.
146    Missing,
147    /// There is a path, but the file couldn't be read (permissions, a directory
148    /// in the way): leaf can't tell, and won't guess.
149    Unreadable,
150    /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
151    /// changed under a document that was never on disk.
152    Untitled,
153}
154
155/// The inline marks in force at a point in the document — what a toolbar
156/// lights up. A `Copy` bitset rather than a `HashSet`, because
157/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
158/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub struct InlineMarks(u8);
161
162impl InlineMarks {
163    /// Every kind, in the order [`InlineMarks::iter`] yields them.
164    const ALL: [InlineKind; 8] = [
165        InlineKind::Strong,
166        InlineKind::Emph,
167        InlineKind::Verbatim,
168        InlineKind::Mark,
169        InlineKind::Superscript,
170        InlineKind::Subscript,
171        InlineKind::Insert,
172        InlineKind::Delete,
173    ];
174
175    pub const fn empty() -> Self {
176        InlineMarks(0)
177    }
178
179    /// Private: the set is an *answer*, and adding a mark to it doesn't mark
180    /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
181    fn insert(&mut self, kind: InlineKind) {
182        self.0 |= Self::bit(kind);
183    }
184
185    /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
186    fn flip(&mut self, kind: InlineKind) {
187        self.0 ^= Self::bit(kind);
188    }
189
190    /// The symmetric difference: which marks differ between the two sets. Used
191    /// to resolve the marks already in force at the caret against the pending
192    /// delta — a bit set in the delta flips the base mark for the next keystroke.
193    fn xor(self, other: InlineMarks) -> InlineMarks {
194        InlineMarks(self.0 ^ other.0)
195    }
196
197    /// Whether `kind` is in force — the toolbar's "is Bold active?".
198    pub fn contains(self, kind: InlineKind) -> bool {
199        self.0 & Self::bit(kind) != 0
200    }
201
202    pub fn is_empty(self) -> bool {
203        self.0 == 0
204    }
205
206    /// The marks in force, for a frontend that renders whatever is on rather
207    /// than asking after a fixed list.
208    pub fn iter(self) -> impl Iterator<Item = InlineKind> {
209        Self::ALL.into_iter().filter(move |&k| self.contains(k))
210    }
211
212    fn bit(kind: InlineKind) -> u8 {
213        1 << match kind {
214            InlineKind::Strong => 0,
215            InlineKind::Emph => 1,
216            InlineKind::Verbatim => 2,
217            InlineKind::Mark => 3,
218            InlineKind::Superscript => 4,
219            InlineKind::Subscript => 5,
220            InlineKind::Insert => 6,
221            InlineKind::Delete => 7,
222        }
223    }
224}
225
226impl FromIterator<InlineKind> for InlineMarks {
227    fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
228        let mut m = InlineMarks::empty();
229        for k in iter {
230            m.insert(k);
231        }
232        m
233    }
234}
235
236/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
237/// into one undo step (a run of typed characters undoes together); `Other` never
238/// coalesces, so a paste, format toggle, or block change is always its own step.
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum EditKind {
241    Insert,
242    Delete,
243    /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
244    /// rather than `Insert`'s because a composition is not typing: each step
245    /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
246    /// though no two steps insert the same bytes, and it must not fold into the
247    /// typed characters on either side of it.
248    Compose,
249    Other,
250}
251
252/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
253/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
254/// the caret), `Forward` is Delete (one starting at it).
255#[derive(Clone, Copy)]
256enum BreakEdge {
257    Backward,
258    Forward,
259}
260
261/// A re-spelling of one inline mark run, held ready in case the edit about to
262/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
263/// Every offset in it is in the coordinates the document will have *after* the
264/// plain edit, since that is when it may be applied.
265struct MarkEdgeFix {
266    /// The run's kind, and an offset inside what was its content: together they
267    /// answer "did the plain edit actually break this mark?" — the question that
268    /// decides whether any of this is applied at all.
269    kind: InlineKind,
270    probe: usize,
271    /// The byte range to re-spell (the run's delimiters included) and its new
272    /// spelling, with the edge whitespace moved outside the delimiters.
273    start: usize,
274    end: usize,
275    text: String,
276    /// Where the caret belongs afterwards — the same place on screen it would
277    /// have had, which is now on the other side of a delimiter.
278    caret: usize,
279    /// The marks in force for text typed at that caret. The caret can land
280    /// outside a run it was inside, and the marks have to survive the move or
281    /// the toolbar goes dark mid-word.
282    want: InlineMarks,
283}
284
285/// The caret and selection at one moment — the part of a history step twig's
286/// `Change` cannot carry, because the caret is leaf's state and twig only knows
287/// about bytes. leaf serializes it into the opaque per-state blob twig now
288/// stores in its own undo history (see `record_caret`), so undo and redo hand
289/// back the caret that matches the source they restore.
290#[derive(Clone, Copy)]
291struct CaretState {
292    caret: usize,
293    anchor: Option<usize>,
294}
295
296impl CaretState {
297    /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
298    /// then an anchor-present flag and the anchor. twig copies these bytes and
299    /// never reads them.
300    fn to_blob(self) -> [u8; 17] {
301        let mut b = [0u8; 17];
302        b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
303        if let Some(a) = self.anchor {
304            b[8] = 1;
305            b[9..].copy_from_slice(&(a as u64).to_le_bytes());
306        }
307        b
308    }
309
310    /// Recover a state from twig's blob, or `None` when it is empty or the wrong
311    /// length — a state twig restored that never had a caret set on it, which
312    /// leaves the caller to fall back to the edit site.
313    fn from_blob(b: &[u8]) -> Option<Self> {
314        let b: &[u8; 17] = b.try_into().ok()?;
315        let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
316        let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
317        Some(CaretState { caret, anchor })
318    }
319}
320
321/// A footnote reference and the note it names — the answer to
322/// [`Doc::footnote_at`].
323///
324/// The two `Option`s move together: a reference whose definition is missing has
325/// neither a body to show nor a place to jump to, and one that resolved has
326/// both.
327#[derive(Clone, PartialEq, Eq, Debug)]
328pub struct FootnoteRef {
329    /// The reference's label — the `1` of `[^1]`, with neither the `^` that
330    /// spells it a footnote nor the brackets around it.
331    pub label: String,
332    /// The note's body as source bytes (see
333    /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
334    /// document defines no `[^label]:` to read one from.
335    pub text: Option<String>,
336    /// Where the note's *body* starts, for a "go to note" that moves the caret
337    /// there. `None` alongside a `None` `text`.
338    ///
339    /// The body rather than the definition, because this is an offset to put a
340    /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
341    /// aiming at the definition's first byte snaps to the nearest real stop,
342    /// which is up in the paragraph above the note. It is also simply where a
343    /// reader following a reference wants to land: at the note's first word,
344    /// ready to read or amend it.
345    pub offset: Option<usize>,
346    /// Where the note's body ends, exclusive — so a frontend can ask which
347    /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
348    ///
349    /// The rows are the note with its markup resolved: `see *later*` reaches a
350    /// frontend as an italic run, not as asterisks. `text` is the source bytes
351    /// and stays the honest answer for anything that wants the note as written
352    /// (a search index, a copy); this pair of offsets is for anything that wants
353    /// it as *read*. `None` alongside a `None` `offset`.
354    pub end: Option<usize>,
355}
356
357/// A footnote definition and the reference that sends a reader to it — the
358/// answer to [`Doc::footnote_definition_at`], and the other half of the round
359/// trip [`FootnoteRef`] starts.
360///
361/// A note is a place a reader *arrives*, so the useful thing to know while
362/// standing in one is the way back. Without this the jump to a note is a
363/// one-way door: the definitions sit at the foot of the document, so returning
364/// by hand means scrolling back up and finding the sentence again.
365#[derive(Clone, PartialEq, Eq, Debug)]
366pub struct FootnoteDef {
367    /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
368    /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
369    pub label: String,
370    /// Where the reference's *label* is, for a "back to reference" that moves
371    /// the caret there. `None` for a note nothing refers to — an orphan, which
372    /// is worth being able to say rather than silently doing nothing.
373    ///
374    /// The label rather than the reference's first byte, for
375    /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
376    /// and its label is the only part of it the caret can rest on.
377    ///
378    /// The *first* reference, when a label is cited more than once: a repeated
379    /// citation has no one true home, and the first is both the one a reader
380    /// most likely came from and the only choice that doesn't depend on how
381    /// they got here.
382    pub offset: Option<usize>,
383}
384
385/// Where a locator lands — the answer to [`Doc::locate`].
386///
387/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
388/// document, and a place is a span rather than a point: a reader following one
389/// wants the caret at its first byte, and a reader merely *peeking* at one wants
390/// the block it covers drawn. Both are served by carrying the whole span, and
391/// only one of the two can be recovered from an offset alone.
392#[derive(Clone, PartialEq, Eq, Debug)]
393pub struct Landing {
394    /// The first byte of the block the locator names — where a caret goes.
395    pub start: usize,
396    /// One past its last byte, so a frontend can map the pair through
397    /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
398    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
399    pub end: usize,
400}
401
402/// A selection cited out of the source: the text itself, up to a requested
403/// number of characters either side, and the byte range it came from. See
404/// [`Doc::selection_quote`].
405///
406/// The prefix and suffix are what make the quote *re-findable*: the same text
407/// can occur twice, and a little of what surrounded it is how a later reader —
408/// or the same document after an edit — tells the occurrences apart. The Web
409/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
410/// the name.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct Quote {
413    /// The selected source, verbatim.
414    pub exact: String,
415    /// What immediately preceded it — possibly empty, at the document's start.
416    pub prefix: String,
417    /// What immediately followed it — possibly empty, at the document's end.
418    pub suffix: String,
419    /// Byte offset in the source where the selection begins.
420    pub start: usize,
421    /// Byte offset where it ends (exclusive).
422    pub end: usize,
423}
424
425/// A host-painted range of the source — an annotation's footprint, a search
426/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
427/// glyphs whose source falls inside it) and hands back the `id` when the
428/// reader activates it; what the range *means* is entirely the host's.
429///
430/// Ranges are source bytes, like the caret and the selection, so a host that
431/// anchors quotes against the source ([`Doc::selection_quote`] is the other
432/// half of that loop) can paint what it found without any coordinate
433/// conversion. A range that drifts off the text it meant is the host's to
434/// re-anchor; leaf draws what it is told.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct Highlight {
437    /// Byte offset in the source where the wash begins.
438    pub start: usize,
439    /// Byte offset where it ends (exclusive).
440    pub end: usize,
441    /// The host's name for it, handed back on activation. Opaque to leaf.
442    pub id: String,
443    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
444    /// nothing for the theme's default wash.
445    pub color: Option<String>,
446    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
447    /// a marker gets a small glyph in the margin beside its first line, and
448    /// the glyph — not the wash — is what activates it: the wash is ink, the
449    /// marker is the control, which is what lets a reader put a caret in (or
450    /// copy from) annotated text without a card leaping at them. The name is
451    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
452    /// as a class.
453    pub marker: Option<String>,
454}
455
456impl Highlight {
457    /// The range covering source `offset` in a list [`Doc::set_highlights`]
458    /// sorted, first by start where several overlap — the one place that
459    /// question is answered, for the frontends that paint by asking it as well
460    /// as for [`Doc::highlight_at`].
461    ///
462    /// The list is sorted by `(start, end)`, so the scan can stop at the first
463    /// range starting past `offset` rather than running to the end. A painter
464    /// asking once per glyph wants [`HighlightCursor`] instead; this is the
465    /// one-shot form, for the host asking what the reader just activated.
466    pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
467        highlights
468            .iter()
469            .take_while(|h| h.start <= offset)
470            .find(|h| offset < h.end)
471    }
472}
473
474/// [`Highlight::covering`] for a caller walking the document in order — which
475/// is every painter, since a frontend draws rows top to bottom and glyphs left
476/// to right.
477///
478/// The one-shot form is a scan from the front of the list per glyph, and a
479/// document with two hundred search hits pays that two hundred times a row. A
480/// range that ends at or before an offset can never cover that offset *or any
481/// later one*, so the cursor retires those permanently and each glyph costs the
482/// ranges that actually reach it. The answer is identical to
483/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
484/// the part that was being redone dropped, not a cheaper approximation.
485///
486/// Offsets are expected to arrive non-decreasing. One that goes backwards is
487/// still answered correctly: the cursor re-seats to the front, since a painter
488/// that revisits a row is asking a question the retired ranges may own again.
489pub struct HighlightCursor<'a> {
490    highlights: &'a [Highlight],
491    /// The first range not yet retired.
492    at: usize,
493    /// The last offset asked about, to notice a caller going backwards.
494    last: usize,
495}
496
497impl<'a> HighlightCursor<'a> {
498    pub fn new(highlights: &'a [Highlight]) -> Self {
499        HighlightCursor {
500            highlights,
501            at: 0,
502            last: 0,
503        }
504    }
505
506    /// The range covering `offset`, advancing the cursor past every range that
507    /// can no longer cover anything.
508    pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
509        if offset < self.last {
510            self.at = 0;
511        }
512        self.last = offset;
513        while self
514            .highlights
515            .get(self.at)
516            .is_some_and(|h| h.end <= offset)
517        {
518            self.at += 1;
519        }
520        Highlight::covering(&self.highlights[self.at..], offset)
521    }
522}
523
524/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
525/// purpose: the only useful question is whether two of them are the same map,
526/// and what is behind it — which `Doc` built it, and the (revision, wrap,
527/// reveal line) it was built from — is core's business.
528///
529/// The document is part of it because the rest is not unique to one: two
530/// documents opened at the same width are both at revision zero with no reveal
531/// line, and a frontend holding one copy of a map across the two would take
532/// the second's key for the first's and paint the wrong document.
533#[derive(Clone, PartialEq, Eq, Debug)]
534pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Range<usize>>)>);
535
536pub struct Doc {
537    editor: Editor,
538    pub format: Format,
539    pub path: PathBuf,
540    /// Current source, refreshed from the editor after every successful edit.
541    pub source: String,
542    /// The caret, as a byte offset into `source` (always on a char boundary).
543    pub caret: usize,
544    /// The selection's fixed end, if a selection is active; the moving end is
545    /// the caret. `None` means no selection.
546    pub anchor: Option<usize>,
547    pub dirty: bool,
548    pub status: Option<String>,
549    pub view: View,
550    /// Whether the document refuses to change — a *reading* surface over the
551    /// same rendering, selection, and navigation the editor has.
552    ///
553    /// Enforced here rather than by each frontend hiding its input paths,
554    /// because every mutation funnels through a few doors —
555    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
556    /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
557    /// verbs directly rather than through the splice (a typed literal, a link,
558    /// an image, a rule, a footnote, a cell's line break) — and guarded doors
559    /// are a guarantee where a frontend's suppressed keyboard is a hope. A
560    /// gated door reports exactly like a rolled-back splice, a path every
561    /// caller already handles. `a_read_only_document_refuses_every_door` is
562    /// the list; a new `self.editor.insert_*` call belongs on it.
563    read_only: bool,
564    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
565    /// State like the selection rather than like the text: no edit history,
566    /// no dirty bit, redrawn from whatever the host last set.
567    highlights: Vec<Highlight>,
568    /// How much of the source markup the rich view exposes — a frontend preference (see
569    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
570    /// [`reveal_line`](Self::reveal_line), the editing one by
571    /// [`insert`](Self::insert).
572    markup_mode: MarkupMode,
573    /// Whether soft breaks fold into the reflowed paragraph or render where
574    /// they were written (see [`LineFlow`]) — an independent frontend
575    /// preference the WYSIWYG builder consults when it lays out a block.
576    line_flow: LineFlow,
577    /// The kind of the last edit, for coalescing: twig owns the undo *history*
578    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
579    /// call, so leaf decides when a run continues and tells twig to coalesce.
580    last_edit_kind: Option<EditKind>,
581    /// The inline marks the user has toggled *at a collapsed caret* with no
582    /// selection — "start typing bold here". Held as the XOR delta from the marks
583    /// already in force at [`pending_at`](Self::pending_at): a set bit means
584    /// "flip this kind for the next typed text", so it both turns a mark on where
585    /// none is (type into bold) and off where one already covers the caret (type
586    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
587    /// freshly typed text and then clears it — a mark once realised is carried by
588    /// the caret sitting inside the run, not by this delta.
589    pending_marks: InlineMarks,
590    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
591    /// delta is live only while the caret still stands here with no selection;
592    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
593    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
594    pending_at: Option<usize>,
595    /// The source as of the last open/save — `dirty` is `source != clean_source`,
596    /// so undoing back to the saved state correctly clears the modified flag.
597    clean_source: String,
598    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
599    /// while the document has no file behind it. [`Doc::disk_state`] compares
600    /// the file against this to catch an edit made *outside* leaf before a save
601    /// silently overwrites it — `clean_source` only knows what leaf itself did.
602    ///
603    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
604    /// writes inside one filesystem timestamp tick are indistinguishable, a
605    /// clock that steps backwards (or a writer that restores an mtime) hides a
606    /// real change, and a `touch` invents one. The whole point of the watermark
607    /// is to not clobber someone's work, so it reads the bytes and compares what
608    /// is actually there. That costs a file read per question, which is why the
609    /// question is asked on a user event (focus, save) and not every frame.
610    disk_hash: Option<u64>,
611    /// The "sticky" display column vertical motion aims for, in the active
612    /// view's grid. Set on the first `move_up`/`move_down` of a run and
613    /// reused by every subsequent one in that run, so passing through a
614    /// shorter line doesn't permanently forget the original column. Any
615    /// horizontal motion or edit clears it.
616    ///
617    /// A column, not a character index: dropping down a line of `你好` onto one
618    /// of ASCII has to land under the glyph the caret was drawn beneath, which
619    /// is the only thing the user can see to aim by. Where the goal falls inside
620    /// a wide character on the target line, the mapping resolves it to that
621    /// character — the caret lands on it rather than between its cells.
622    goal_col: Option<usize>,
623    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
624    /// and clicks read it to stay in visible space.
625    pub vmap: VisualMap,
626    /// The syntax map for the source view; empty in the WYSIWYG view, which
627    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
628    /// frontend that never calls it paints raw source unstyled, which is what
629    /// every frontend did before this map existed.
630    pub smap: SourceMap,
631    /// The revision `smap` was built from, or `None` before the first build.
632    /// The map is a pure function of the text alone — no width, no caret, no
633    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
634    /// whole key.
635    smap_key: Option<u64>,
636    /// Everything the map is built from, as one number: bumped whenever the
637    /// document's text changes, and never by a motion, a selection, or a save.
638    /// A frontend can hold work against it — see [`Doc::revision`].
639    revision: u64,
640    /// How many history steps stand behind the caret, and how many ahead of
641    /// it — the answer to a native Edit menu's "may Undo be enabled?", which
642    /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
643    /// the funnel every edit comes through, and moved back and forth by
644    /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
645    /// an exact depth: a coalesced run of typing is one of twig's steps but
646    /// several of these, and twig's own cap on history is not mirrored here.
647    /// Neither error can make `can_undo` false while a step remains, which is
648    /// the only property a menu needs; the one place the bound can be wrong the
649    /// other way — the cap has retired every step — is reconciled the moment
650    /// twig reports nothing to undo.
651    undo_steps: usize,
652    redo_steps: usize,
653    /// What `vmap` was built from, or `None` before the first build. The map is
654    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
655    /// moved, rebuilding it produces the identical map — see
656    /// [`Doc::build_visual`].
657    ///
658    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
659    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
660    /// text and width alone, and a caret motion still rebuilds nothing.
661    vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
662    /// Which `Doc` this is, distinct from every other one built in this
663    /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
664    /// can never be mistaken for another document's — see
665    /// [`Doc::visual_key`]. Nothing else reads it.
666    identity: u64,
667    /// Per-block row cache backing the incremental rebuild: when the text
668    /// changes, only the top-level blocks whose bytes moved are re-rendered and
669    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
670    /// builds; a pure accelerator, so it's never read for correctness.
671    block_cache: wysiwyg::BlockCache,
672    /// How many visual rows each block image reserves, keyed by its destination —
673    /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
674    /// measured the pictures. Core does no image I/O, so this is the only way it
675    /// learns a picture's height; a destination not in the map reserves the bare
676    /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
677    /// sizes each placeholder, and folded into `vmap_key` so a height change
678    /// rebuilds the map.
679    media_rows: HashMap<String, usize>,
680
681    // View geometry the renderer stamps each frame, so mouse events can map a
682    // screen cell back to a byte offset.
683    pub scroll: usize,
684    pub body_origin: (u16, u16),
685    /// Width of the body rectangle last painted by the frontend. Zero means
686    /// unknown (used by tests or a frontend that has not drawn yet).
687    pub body_width: u16,
688    pub body_height: u16,
689    /// The caret as of the last frame drawn, or `None` before the first.
690    ///
691    /// Scrolling is the viewport's business, not the caret's: the view follows
692    /// the caret when the caret *moves*, but a wheel that doesn't touch the
693    /// caret has to be free to scroll away from it — otherwise the view is
694    /// pinned to the caret and stops dead at the edge of the document you can
695    /// see. Comparing against this is what tells the two apart, and it catches a
696    /// caret set by any route, including a frontend assigning the field itself.
697    pub drawn_caret: Option<usize>,
698}
699
700/// The Markdown extensions every leaf document is parsed with — four of them,
701/// each departing from twig's defaults for a reason leaf can state.
702///
703/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
704/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
705/// node the frontends can frame and rasterize instead of opaque `raw_block`
706/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
707/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
708/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
709/// plain tinted container, agnostic of `name`.
710///
711/// `highlight` and `highlight_colors` are the pair that makes Markdown read
712/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
713/// `data-color`. leaf already had somewhere to put both: the
714/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
715/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
716/// from a Djot one it was converted from — the button wrote `==…==` and the
717/// reparse read it straight back as text.
718/// They are on together because a colour is inert without the highlight itself,
719/// and a document that writes `==🔴 x==` means the colour by it.
720///
721/// Every flag is inert for non-Markdown formats, so it's safe to pass them
722/// unconditionally. Threading this through every constructor (not just `open`)
723/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
724/// way — twig reparses with these same flags after each edit.
725pub(crate) fn parse_extensions() -> MarkdownExtensions {
726    MarkdownExtensions {
727        html_elements: true,
728        directives: true,
729        highlight: true,
730        highlight_colors: true,
731        ..Default::default()
732    }
733}
734
735/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
736/// mapping twig's error into the `anyhow` context every constructor shares.
737fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
738    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
739}
740
741/// Does `format` spell a table as a **pipe table** — the one grid twig's table
742/// editor knows how to emit?
743///
744/// This is the single capability leaf still has to answer for itself, and the
745/// only hand-maintained format list left in this file. Every other gesture is
746/// [`Format::supports`], which is twig's own answer read across the C ABI — but
747/// twig deliberately leaves the table ops out of that query, because they read
748/// no `Syntax` table at all. They rewrite a grid that is already in the source
749/// and refuse on *position*, never on format. Handed a caret inside an HTML
750/// `<table>`, `table_insert_row` therefore re-emits the whole element as
751/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
752/// `dirty` flag, and nothing downstream able to tell it from a good edit.
753///
754/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
755/// wildcard answers "no" for a format leaf has never heard of: a new twig
756/// language that *does* spell pipe tables loses its grid controls until this
757/// line is updated, which shows up as a missing button. The other default hands
758/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
759fn spells_pipe_tables(format: Format) -> bool {
760    matches!(format, Format::Markdown | Format::Djot)
761}
762
763/// Which of leaf's authoring controls this document's format can actually
764/// spell — one flag per toolbar button, resolved once so a frontend can build
765/// its chrome instead of discovering each refusal on a click.
766///
767/// Every field but [`table`](Self::table) is `Format::supports_with` on the
768/// gesture the matching [`Doc`] method calls, so this record cannot drift from
769/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
770/// doesn't export.
771///
772/// `supports_with` rather than `supports` because two of these are facts about
773/// the *parse options* as much as about the format. `Format::supports` answers
774/// for twig's defaults, and leaf never parses with those — it parses with
775/// [`parse_extensions`], and a document's toolbar has to describe the document
776/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
777/// without it would mint bytes its own reparse hands back as plain text, which
778/// is why twig asks before it writes.
779///
780/// **The formats are ragged, and that is the point.** A single per-document
781/// boolean was enough while the two authorable formats were Markdown and djot
782/// and everything else spelled nothing. HTML is neither: it writes seven of the
783/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
784/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
785/// pair, and since 3.5 a quote, a list, a code block, a link and an image
786/// printed as fresh nodes; it spells no task box (a form control there) and
787/// no footnote, and its `<table>` is one twig reads but will not write. So
788/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
789/// table buttons do not, and no one flag can say that. Markdown and djot
790/// differ from each other too:
791/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
792#[derive(Clone, Copy, Debug, Eq, PartialEq)]
793pub struct Capabilities {
794    /// ⌘B — `InlineKind::Strong`.
795    pub bold: bool,
796    /// ⌘I — `InlineKind::Emph`.
797    pub italic: bool,
798    /// Inline code — `InlineKind::Verbatim`.
799    pub code: bool,
800    /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
801    /// under the `highlight` extension [`parse_extensions`] turns on: the
802    /// button writes `==text==`, which is what the reparse reads back.
803    pub mark: bool,
804    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
805    pub underline: bool,
806    /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
807    /// out of the box, since twig parses it out of the box.
808    pub strike: bool,
809    /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
810    /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
811    /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
812    /// so a toolbar offering the swatches wherever the button lights would offer
813    /// them in a document that cannot write one. Pair with
814    /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
815    /// a highlight to colour as much as a format that spells one.
816    pub mark_color: bool,
817    pub superscript: bool,
818    pub subscript: bool,
819    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
820    pub heading: bool,
821    pub blockquote: bool,
822    pub bullet_list: bool,
823    pub ordered_list: bool,
824    /// The checkbox controls: giving an item a box, and ticking one.
825    pub task: bool,
826    pub link: bool,
827    /// Covers [`Doc::insert_media`] too — see the note there on why the three
828    /// media kinds stand or fall together.
829    pub image: bool,
830    /// The horizontal-rule button. HTML spells this one (`<hr>`).
831    pub thematic_break: bool,
832    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
833    /// the pair; HTML has no footnote of its own, so the button goes away rather
834    /// than writing brackets that would render as brackets.
835    pub footnote: bool,
836    /// Setting a fenced block's language — a control only ever offered with the
837    /// caret already in a fence.
838    pub code_language: bool,
839    /// The grid controls: insert/delete/move a row or column, set a column's
840    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
841    /// question — an HTML `<table>` holds the caret and still can't be edited.
842    pub table: bool,
843    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
844    /// idiomatic in-cell break.
845    pub cell_line_break: bool,
846}
847
848impl Capabilities {
849    /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
850    /// twig computes each from a static table — but a frontend that wants to
851    /// hold them can.
852    ///
853    /// The extensions are not a parameter because they are not a choice a
854    /// caller makes: every leaf document is parsed with [`parse_extensions`],
855    /// so the format is the whole of what varies.
856    pub fn of(format: Format) -> Self {
857        let exts = parse_extensions();
858        let supports = |g| format.supports_with(exts, g);
859        let inline = |k| supports(Gesture::ToggleInline(k));
860        let container = |k| supports(Gesture::ToggleBlockContainer(k));
861        Self {
862            bold: inline(InlineKind::Strong),
863            italic: inline(InlineKind::Emph),
864            code: inline(InlineKind::Verbatim),
865            mark: inline(InlineKind::Mark),
866            underline: inline(InlineKind::Insert),
867            strike: inline(InlineKind::Delete),
868            mark_color: supports(Gesture::SetMarkColor),
869            superscript: inline(InlineKind::Superscript),
870            subscript: inline(InlineKind::Subscript),
871            heading: supports(Gesture::SetBlock),
872            blockquote: container(BlockContainerKind::BlockQuote),
873            bullet_list: container(BlockContainerKind::BulletList),
874            ordered_list: container(BlockContainerKind::OrderedList),
875            // Both halves of the checkbox story, and leaf offers no control that
876            // needs only one: the item gesture mints the box, the checked one
877            // ticks it, and a format spelling a `task_marker` spells both.
878            task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
879            link: supports(Gesture::InsertLink),
880            image: supports(Gesture::InsertImage),
881            thematic_break: supports(Gesture::InsertThematicBreak),
882            footnote: supports(Gesture::InsertFootnote),
883            code_language: supports(Gesture::SetCodeLanguage),
884            table: spells_pipe_tables(format),
885            cell_line_break: supports(Gesture::InsertLineBreak),
886        }
887    }
888}
889
890/// The source of [`Doc::identity`], one per document ever built.
891static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
892
893impl Doc {
894    #[cfg(feature = "fs")]
895    pub fn open(path: PathBuf) -> Result<Self> {
896        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
897        Self::from_disk_bytes(path, bytes)
898    }
899
900    /// An empty document *named* `path`, for a file that isn't there yet — what
901    /// every other terminal editor gives you when you name a file that doesn't
902    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
903    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
904    /// the header shows the name the user asked for.
905    ///
906    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
907    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
908    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
909    /// parse is still an error: a mistyped flag or a stray argument should say
910    /// so, not open a buffer promising to save somewhere.
911    ///
912    /// The watermark is the hash of *no bytes*, not `None`, and that is the
913    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
914    /// answering [`DiskState::Untitled`] for a document that has a path and
915    /// intends to write to it. Hashing `""` instead makes the answers the true
916    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
917    /// recreates it, which is exactly what this is for), and
918    /// [`DiskState::Changed`] if somebody creates it underneath us between
919    /// launch and save, so the frontend's overwrite prompt guards a new file as
920    /// it guards an opened one.
921    ///
922    /// Nothing is written here. A buffer that is never typed into never touches
923    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
924    /// open — the write is where that fails, and it says so then.
925    #[cfg(feature = "fs")]
926    pub fn create(path: PathBuf) -> Result<Self> {
927        Self::from_disk_bytes(path, Vec::new())
928    }
929
930    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
931    /// the call a CLI frontend wants for its path argument.
932    ///
933    /// The decision is made from the failed read itself rather than a `exists()`
934    /// check first, so there is no window between the two for the file to appear
935    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
936    /// a directory in the way is still an error, because pretending those are
937    /// "no file yet" would offer to save over something leaf couldn't read.
938    #[cfg(feature = "fs")]
939    pub fn open_or_create(path: PathBuf) -> Result<Self> {
940        match std::fs::read(&path) {
941            Ok(bytes) => Self::from_disk_bytes(path, bytes),
942            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
943            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
944        }
945    }
946
947    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
948    /// stand in for) the file at `path`, parsed as the format its extension
949    /// names. Keeping the two on one path is what makes a new file's document
950    /// identical in every respect to an opened one but its contents.
951    #[cfg(feature = "fs")]
952    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
953        let format = detect_format(&path)?;
954        let editor = new_editor(&bytes, format)?;
955        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
956        let disk_hash = Some(hash_bytes(source.as_bytes()));
957        // Store the document's *absolute* path. A relative one (`leaf README.md`)
958        // has an empty parent, so a frontend can't resolve a relative image
959        // destination (`![](pic.png)`) against the document's directory and the
960        // picture silently falls back to its text placeholder. `absolute` is
961        // purely lexical — it prefixes the current directory and normalizes, but
962        // reads nothing and resolves no symlinks — so `file_name` and save are
963        // unchanged; it only gives `path.parent()` something to join against.
964        let path = std::path::absolute(&path).unwrap_or(path);
965        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
966    }
967
968    /// Build a document from an in-memory string, the format named explicitly —
969    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
970    /// path and sniffs the format from its extension). A wasm or FFI host, which
971    /// has no path to read, uses this: it hands over bytes it fetched however it
972    /// could, and later persists [`Doc::source`] however it can (a browser
973    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
974    ///
975    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
976    /// true) exactly like a [`Doc::blank`] that has been given content.
977    pub fn from_source(source: String, format: Format) -> Result<Self> {
978        let editor = new_editor(source.as_bytes(), format)?;
979        Ok(Doc::from_parts(
980            editor,
981            format,
982            PathBuf::new(),
983            source,
984            None,
985        ))
986    }
987
988    /// An untitled, empty document — the `+` button and a `leaf` launched with
989    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
990    ///
991    /// It is Markdown, because a format has to be chosen before a name exists to
992    /// read one from: `detect_format` reads the extension and an untitled
993    /// document has neither. Markdown is what leaf's own files are, what its
994    /// block markers are already written for (`insert_block_prefix`), and the
995    /// extension a Save As will overwhelmingly pick — a wrong guess here would
996    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
997    /// *doesn't* revisit this: see [`Doc::save_as`].
998    pub fn blank() -> Result<Self> {
999        let format = Format::Markdown;
1000        let editor = new_editor(b"", format)?;
1001        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1002        // field two frontends already read; making it an `Option` to say this
1003        // would break both). `is_untitled` is the question to ask, not the
1004        // representation to copy.
1005        Ok(Doc::from_parts(
1006            editor,
1007            format,
1008            PathBuf::new(),
1009            String::new(),
1010            None,
1011        ))
1012    }
1013
1014    /// The fields every constructor agrees on, so `open` and `blank` can't drift
1015    /// apart in the ones neither of them has an opinion about.
1016    // `identity` is taken from a counter rather than from the `Doc`'s address,
1017    // which moves — a session that holds one is moved into and out of
1018    // containers freely, and an identity that changed with it would defeat the
1019    // one comparison it exists for.
1020    fn from_parts(
1021        editor: Editor,
1022        format: Format,
1023        path: PathBuf,
1024        source: String,
1025        disk_hash: Option<u64>,
1026    ) -> Self {
1027        Doc {
1028            editor,
1029            format,
1030            path,
1031            disk_hash,
1032            clean_source: source.clone(),
1033            source,
1034            caret: 0,
1035            anchor: None,
1036            dirty: false,
1037            status: None,
1038            read_only: false,
1039            highlights: Vec::new(),
1040            // leaf opens in the rich-text (WYSIWYG) view by default — the
1041            // markup-resolved surface is leaf's differentiator. Frontends can
1042            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1043            // toggles at runtime.
1044            view: View::Wysiwyg,
1045            // `None` by default — the clean surface Diaryx ships, with typed
1046            // syntax kept literal; a markup-fluent frontend can climb the
1047            // ladder to `Shortcuts` or `Full`.
1048            markup_mode: MarkupMode::default(),
1049            // Fold by default — flowing prose that reflows to the viewport, the
1050            // behaviour every frontend had before this preference existed.
1051            line_flow: LineFlow::default(),
1052            last_edit_kind: None,
1053            pending_marks: InlineMarks::empty(),
1054            pending_at: None,
1055            goal_col: None,
1056            vmap: VisualMap::default(),
1057            smap: SourceMap::default(),
1058            // No map yet — the first `build_source` always builds.
1059            smap_key: None,
1060            revision: 0,
1061            undo_steps: 0,
1062            redo_steps: 0,
1063            // No map yet — the first `build_visual` always builds.
1064            vmap_key: None,
1065            identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1066            block_cache: wysiwyg::BlockCache::default(),
1067            media_rows: HashMap::new(),
1068            scroll: 0,
1069            body_origin: (0, 0),
1070            body_width: 0,
1071            body_height: 0,
1072            drawn_caret: None,
1073        }
1074    }
1075
1076    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1077    /// has never been saved. The question a ⌘S handler asks to know it should
1078    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1079    /// header asks to know the name it shows is a placeholder.
1080    pub fn is_untitled(&self) -> bool {
1081        self.path.as_os_str().is_empty()
1082    }
1083
1084    pub fn toggle_view(&mut self) {
1085        self.view = match self.view {
1086            View::Source => View::Wysiwyg,
1087            View::Wysiwyg => View::Source,
1088        };
1089        self.scroll = 0;
1090        self.status = None;
1091        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1092        // lift it to the first rendered offset.
1093        self.clamp_caret();
1094    }
1095
1096    /// The current markup-exposure preference (see [`MarkupMode`]).
1097    pub fn markup_mode(&self) -> MarkupMode {
1098        self.markup_mode
1099    }
1100
1101    /// Set the markup-exposure preference. Both of its axes take effect at
1102    /// once: the editing one on the next [`insert`](Self::insert), and the
1103    /// rendering one on the next build — which is why this drops the cached
1104    /// visual map and the per-block render cache, exactly as
1105    /// [`set_line_flow`](Self::set_line_flow) does.
1106    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1107        if self.markup_mode == mode {
1108            return;
1109        }
1110        self.markup_mode = mode;
1111        // Neither cache is keyed on the mode, and moving between `Full` and the
1112        // hidden modes changes every row the caret's line renders to — so
1113        // invalidate both explicitly.
1114        self.vmap_key = None;
1115        self.block_cache = wysiwyg::BlockCache::default();
1116    }
1117
1118    /// The source byte range of the line the caret sits on, when that line
1119    /// should render its raw delimiters — `None` in every mode and view that
1120    /// hides them, which is what the builder reads as "reveal nothing".
1121    ///
1122    /// A *source* line (newline to newline), not a visual row: a wrapped
1123    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1124    /// line across several rows, and revealing half a delimiter pair because the
1125    /// other half wrapped would be worse than revealing neither. The range
1126    /// excludes the terminating newline and is empty-but-present on a blank
1127    /// line, which reveals nothing but still keys the caches correctly.
1128    ///
1129    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1130    /// there is nothing there to reveal.
1131    pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1132        if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1133            return None;
1134        }
1135        Some(source_line_range(&self.source, self.caret))
1136    }
1137
1138    /// The current soft-break flow preference (see [`LineFlow`]).
1139    pub fn line_flow(&self) -> LineFlow {
1140        self.line_flow
1141    }
1142
1143    /// Set the soft-break flow preference. The mode changes how every block lays
1144    /// out, so a change drops the cached visual map and the per-block render
1145    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1146    ///
1147    /// [`build_visual`]: Self::build_visual
1148    pub fn set_line_flow(&mut self, mode: LineFlow) {
1149        if self.line_flow == mode {
1150            return;
1151        }
1152        self.line_flow = mode;
1153        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1154        // so invalidate them explicitly, or the next build would reuse rows laid
1155        // out under the old flow.
1156        self.vmap_key = None;
1157        self.block_cache = wysiwyg::BlockCache::default();
1158    }
1159
1160    pub fn view_name(&self) -> &'static str {
1161        match self.view {
1162            View::Source => "source",
1163            View::Wysiwyg => "wysiwyg",
1164        }
1165    }
1166
1167    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1168    /// (called by the renderer each frame it's in the WYSIWYG view).
1169    /// Build the WYSIWYG map, wrapped at `width` display columns.
1170    ///
1171    /// Cheap to call every frame, which is what both frontends do: the map is a
1172    /// pure function of the document and the wrap width, so a call that would
1173    /// rebuild the same map returns the one already built. Only an edit (or a
1174    /// resize) pays.
1175    ///
1176    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1177    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1178    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1179    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1180    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1181    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1182    pub fn build_visual(&mut self, width: usize) {
1183        self.build_map(Some(width));
1184    }
1185
1186    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1187    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1188    /// than a fixed character column.
1189    pub fn build_visual_unwrapped(&mut self) {
1190        self.build_map(None);
1191    }
1192
1193    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1194    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1195    /// styling for [`View::Wysiwyg`].
1196    ///
1197    /// A frontend calls this before painting raw source. One that doesn't gets
1198    /// an empty map and paints unstyled text, so this is additive: nothing
1199    /// breaks by not calling it.
1200    ///
1201    /// Built at most once per revision, and the revision is the whole key — the
1202    /// map has no width and no caret in it, so it survives every resize, every
1203    /// motion, and every selection change.
1204    ///
1205    /// The builds it does do cost a whole-arena marshal, which is precisely what
1206    /// the WYSIWYG path works to avoid, so this has no incremental path where
1207    /// that one has two. From `cargo run --release -p leaf-core --example
1208    /// bench`, per keystroke, against the WYSIWYG build the source view is
1209    /// *not* doing:
1210    ///
1211    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1212    /// |------:|-------:|--------:|----------------:|-------------------:|
1213    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1214    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1215    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1216    ///
1217    /// Linear, two thirds of it the marshal, and the build itself five to seven
1218    /// times cheaper than the one it stands in for at every size. Comfortable
1219    /// well past any document a person edits in a terminal — a megabyte is where
1220    /// it would want [`Editor::dirty_range`] and the same splice treatment
1221    /// `build_spliced` gives the other map. The door is open; nothing has needed
1222    /// it yet.
1223    pub fn build_source(&mut self) {
1224        if self.smap_key == Some(self.revision) {
1225            return;
1226        }
1227        let nodes = self.nodes();
1228        self.smap = source::build(&nodes, &self.source);
1229        self.smap_key = Some(self.revision);
1230    }
1231
1232    /// Tell the model how many visual rows each block image should reserve, keyed
1233    /// by the image's destination. A terminal frontend calls this once it has
1234    /// decoded and measured its pictures — core does no image I/O, so this is the
1235    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1236    /// placeholder out that tall (the label row plus blank filler rows the
1237    /// frontend paints the raster over). A destination left out of the map falls
1238    /// back to the bare one-row placeholder, which is also what a frontend that
1239    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1240    /// by never calling this.
1241    ///
1242    /// Cheap to call every frame with the same map: only a *change* invalidates
1243    /// the built map (and the block-row cache, since a height isn't part of a
1244    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1245    /// no-op, so a frontend can just hand over its current measurements each frame.
1246    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1247        if self.media_rows == rows {
1248            return;
1249        }
1250        self.media_rows = rows;
1251        // A height lives outside the block's source bytes, so the content-keyed
1252        // block cache would hand back the old-height rows on a hit. Drop it (and
1253        // the splice layout it carries) so the next build re-renders every block
1254        // at the new heights, and force that build by clearing the map key.
1255        self.block_cache = wysiwyg::BlockCache::default();
1256        self.vmap_key = None;
1257    }
1258
1259    /// The revision the document's text is at — bumped by every edit, undo,
1260    /// redo, and reload, and by nothing else. A frontend caches against this to
1261    /// tell a repaint that needs new work from one that doesn't.
1262    ///
1263    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1264    /// lands on the same text two revisions later. Work is only ever rebuilt
1265    /// needlessly, never wrongly reused.
1266    pub fn revision(&self) -> u64 {
1267        self.revision
1268    }
1269
1270    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1271    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1272    /// unbuilt map before the first one.
1273    ///
1274    /// This is *not* [`revision`](Self::revision). The revision says where the
1275    /// text is; this says where the map is, and the two part company the moment
1276    /// an edit lands, until something rebuilds. A frontend that keeps its own
1277    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1278    /// under an oversized heading — compares this against the value it held when
1279    /// it took the copy, and learns whether `vmap` is still the map it stashed
1280    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1281    /// map would paint a stale document; restoring nothing hands core's
1282    /// incremental rebuild a map it never built.
1283    ///
1284    /// "Somebody else" includes another document. The key names the `Doc`
1285    /// as well as the build, so a frontend that draws two documents through
1286    /// one stash — a host with several buffers, or one that opens the next
1287    /// document where the last one stood — never has the copy it took of one
1288    /// accepted by the other, however alike their builds are.
1289    pub fn visual_key(&self) -> VisualKey {
1290        VisualKey(self.identity, self.vmap_key.clone())
1291    }
1292
1293    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1294    /// runs on every call: the caret moves without the document changing, and
1295    /// keeping it on a legal stop is this function's job either way.
1296    fn build_map(&mut self, wrap: Option<usize>) {
1297        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1298        // as well as the text, so the line joins the key: moving within a line
1299        // still reuses the map, and crossing into another one rebuilds it. In
1300        // every other mode `reveal_line` is `None` and the key is what it was,
1301        // so caret motion goes on costing nothing.
1302        let reveal = self.reveal_line();
1303        let key = (self.revision, wrap, reveal.clone());
1304        if self.vmap_key.as_ref() != Some(&key) {
1305            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1306            // A subtree is pulled only for the block(s) that actually changed, so
1307            // the FFI marshal shrinks from O(document) to O(edited block).
1308            let top = self.top_blocks();
1309
1310            // Fast path: when twig reports a dirty byte range, try to patch the
1311            // previous map in place — a single-block edit moves the prefix,
1312            // shifts the suffix, and re-renders only one block. `build_spliced`
1313            // returns `None` (and we fall back to the always-correct full rebuild)
1314            // whenever the edit reshaped the block structure, hit a table, or
1315            // there's no previous map to patch.
1316            // Preserve soft breaks as written when the flow preference asks for
1317            // it — the builder renders each as its own visual row instead of
1318            // folding it into the reflowed paragraph.
1319            let preserve_soft = self.line_flow == LineFlow::Preserve;
1320            let spliced = match self.editor.dirty_range() {
1321                Some(dirty) => {
1322                    let prev = std::mem::take(&mut self.vmap);
1323                    let source = &self.source;
1324                    let cache = &mut self.block_cache;
1325                    let media_rows = &self.media_rows;
1326                    let editor = &mut self.editor;
1327                    wysiwyg::build_spliced(
1328                        prev,
1329                        source,
1330                        wrap,
1331                        preserve_soft,
1332                        &top,
1333                        dirty,
1334                        media_rows,
1335                        reveal.clone(),
1336                        cache,
1337                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1338                    )
1339                }
1340                None => None,
1341            };
1342            self.vmap = spliced.unwrap_or_else(|| {
1343                let source = &self.source;
1344                let cache = &mut self.block_cache;
1345                let media_rows = &self.media_rows;
1346                let editor = &mut self.editor;
1347                wysiwyg::build_cached(
1348                    &top,
1349                    source,
1350                    wrap,
1351                    preserve_soft,
1352                    media_rows,
1353                    reveal,
1354                    cache,
1355                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1356                )
1357            });
1358            // Acknowledge the dirty range so the next edit's range starts fresh.
1359            self.editor.clear_dirty();
1360            self.vmap_key = Some(key);
1361        }
1362        self.clamp_caret();
1363    }
1364
1365    fn nodes(&mut self) -> Vec<FlatNode> {
1366        self.editor.nodes().unwrap_or_default()
1367    }
1368
1369    /// The document's top-level blocks for the incremental render. See
1370    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1371    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1372        wysiwyg::top_blocks(&mut self.editor)
1373    }
1374
1375    pub fn format_name(&self) -> &'static str {
1376        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1377        // required. It also covers `Asciidoc`, which twig parses but cannot
1378        // serialize — leaf never opens a document in it (see `Doc::open`).
1379        match self.format {
1380            Format::Djot => "djot",
1381            Format::Markdown => "markdown",
1382            Format::Xml => "xml",
1383            Format::Html => "html",
1384            _ => "unknown",
1385        }
1386    }
1387
1388    /// Whether this document's format offers *any* door in — `false` only for a
1389    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1390    /// a frontend may as well open the file read-only.
1391    ///
1392    /// This is a much weaker claim than the name suggests, and driving per-button
1393    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1394    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1395    /// while a heading, a quote, a list, a task box, a link and a code fence all
1396    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1397    /// [`supports`](Self::supports) — per control.
1398    pub fn authorable(&self) -> bool {
1399        self.format.is_authorable()
1400    }
1401
1402    /// Whether this document can spell `gesture`, which is twig's own answer
1403    /// rather than a copy of it: `Format::supports_with` reads the same
1404    /// `Syntax` table the `Editor` method consults before refusing, chosen by
1405    /// the very [`parse_extensions`] this document's editor reparses with — so
1406    /// what the toolbar offers and what the splice will accept are one table.
1407    ///
1408    /// It is a fact about the *document*, not about the caret. `true` does not
1409    /// promise the gesture succeeds where it is standing — a link over a table
1410    /// border still fails — only that it will not fail with
1411    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1412    /// will work here".
1413    pub fn supports(&self, gesture: Gesture) -> bool {
1414        self.format.supports_with(parse_extensions(), gesture)
1415    }
1416
1417    /// Every control's enabled state in one read — what a toolbar builds itself
1418    /// from when a document opens or its format changes. See [`Capabilities`].
1419    pub fn capabilities(&self) -> Capabilities {
1420        Capabilities::of(self.format)
1421    }
1422
1423    /// Refuse a gesture this document's format cannot spell, saying so in the
1424    /// status line. `true` means the caller must return without calling twig.
1425    ///
1426    /// Most of these refusals duplicate one twig would make anyway, and they are
1427    /// made here regardless because a message naming the *document's* format
1428    /// reads better than one naming twig's internals. Two of them are not
1429    /// duplicates and are the reason this is a guard rather than an error
1430    /// translation:
1431    ///
1432    /// - The table family (see [`table_op`](Self::table_op)) consults no
1433    ///   `Syntax` table, so twig does not refuse it at all.
1434    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1435    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1436    ///   could not keep.
1437    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1438        self.refuse_unless(what, self.supports(gesture))
1439    }
1440
1441    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1442    /// answers itself — today only [`spells_pipe_tables`].
1443    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1444        if supported {
1445            return false;
1446        }
1447        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1448        true
1449    }
1450
1451    /// The name to show for this document. An untitled one has no file to name
1452    /// it, and both frontends put this straight on screen — an empty path
1453    /// renders as an empty header, so it says so instead.
1454    pub fn file_name(&self) -> String {
1455        if self.is_untitled() {
1456            return "untitled".into();
1457        }
1458        self.path
1459            .file_name()
1460            .map(|s| s.to_string_lossy().into_owned())
1461            .unwrap_or_else(|| self.path.display().to_string())
1462    }
1463
1464    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1465    /// caret and anchor coincide (an empty selection is no selection).
1466    pub fn selection(&self) -> Option<(usize, usize)> {
1467        self.anchor
1468            .map(|a| (a.min(self.caret), a.max(self.caret)))
1469            .filter(|(s, e)| s != e)
1470    }
1471
1472    /// The selected text, or `None` when there's no selection — the source
1473    /// slice a copy/cut hands to the system clipboard.
1474    pub fn selected_text(&self) -> Option<&str> {
1475        self.selection().map(|(s, e)| &self.source[s..e])
1476    }
1477
1478    /// The selection as a quote with a little of what surrounds it — the shape
1479    /// a host that cites, annotates, or searches for a passage wants, cut from
1480    /// the **source** rather than from anything rendered, so the quote is
1481    /// findable in the document again by plain string search.
1482    ///
1483    /// `context` is a count of characters (not bytes) on each side, clipped at
1484    /// the document's edges; the slices land on char boundaries by
1485    /// construction. `None` when nothing is selected.
1486    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1487        let (start, end) = self.selection()?;
1488        let mut before = start;
1489        for _ in 0..context {
1490            match self.source[..before].chars().next_back() {
1491                Some(c) => before -= c.len_utf8(),
1492                None => break,
1493            }
1494        }
1495        let mut after = end;
1496        for _ in 0..context {
1497            match self.source[after..].chars().next() {
1498                Some(c) => after += c.len_utf8(),
1499                None => break,
1500            }
1501        }
1502        Some(Quote {
1503            exact: self.source[start..end].to_string(),
1504            prefix: self.source[before..start].to_string(),
1505            suffix: self.source[end..after].to_string(),
1506            start,
1507            end,
1508        })
1509    }
1510
1511    /// Whether the document refuses to change — see the field.
1512    pub fn read_only(&self) -> bool {
1513        self.read_only
1514    }
1515
1516    /// Turn the read-only gate on or off. A frontend preference like
1517    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1518    /// itself changes, only what may be done to it from here on.
1519    pub fn set_read_only(&mut self, on: bool) {
1520        self.read_only = on;
1521    }
1522
1523    /// The host-painted ranges, sorted by start — see [`Highlight`].
1524    pub fn highlights(&self) -> &[Highlight] {
1525        &self.highlights
1526    }
1527
1528    /// Replace the host-painted ranges wholesale. The whole set each time,
1529    /// rather than add/remove verbs: the host owns the list (it derives it
1530    /// from its own state — annotations, search hits), and a replace can
1531    /// never leave the two disagreeing about what should be on screen.
1532    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1533        highlights.retain(|h| h.start < h.end);
1534        highlights.sort_by_key(|h| (h.start, h.end));
1535        self.highlights = highlights;
1536    }
1537
1538    /// The highlight covering source `offset`, if one does — first by start
1539    /// when several overlap, which makes overlapping washes resolvable rather
1540    /// than undefined. What a frontend asks when the reader activates a spot.
1541    ///
1542    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1543    /// asking the same question per glyph, against a slice they were handed
1544    /// rather than against a `Doc`, and one answer for both is what keeps a
1545    /// wash and an activation agreeing about which range a spot is in.
1546    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1547        Highlight::covering(&self.highlights, offset)
1548    }
1549
1550    /// The AST breadcrumb at the caret (root → deepest), e.g.
1551    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1552    pub fn breadcrumb(&mut self) -> String {
1553        match self.editor.ancestors_at(self.caret) {
1554            Ok(chain) => chain
1555                .iter()
1556                .map(|m| m.kind.as_str())
1557                .collect::<Vec<_>>()
1558                .join(" › "),
1559            Err(_) => String::new(),
1560        }
1561    }
1562
1563    // ── editing ──────────────────────────────────────────────────────────────
1564
1565    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1566    /// after it. The public form of the internal splice — a pixel frontend that
1567    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1568    /// edits through this, the same twig `edit_range` the caret ops use.
1569    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1570        self.splice(start, end, text, EditKind::Other);
1571    }
1572
1573    /// Insert typed `text` at the caret, replacing the selection if there is one.
1574    /// A single typed character coalesces with the run of typing before it; a
1575    /// newline or a multi-character insert is its own undo step.
1576    ///
1577    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1578    pub fn insert(&mut self, text: &str) {
1579        // The read-only gate, up front: the paths below reach twig by several
1580        // verbs, not all of them through the splice — see the field.
1581        if self.read_only {
1582            return;
1583        }
1584        // Typing against a block picture would dissolve it — see
1585        // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1586        // what the caret was standing beside stays a picture.
1587        self.open_paragraph_at_block_media(text);
1588        // Armed sticky marks (⌘b with no selection) turn the next typed text
1589        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1590        // the exception: it takes no mark of its own and keeps the delta armed
1591        // for the character behind it — see `insert_space_with_marks`.
1592        let pending = self.pending_here();
1593        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1594            if text.trim().is_empty() {
1595                self.insert_space_with_marks(self.caret, text, pending);
1596            } else {
1597                self.insert_with_marks(self.caret, text, pending);
1598            }
1599            return;
1600        }
1601        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1602        // anything that would open markup, so a Diaryx user never mints
1603        // formatting by keyboard (it comes from commands instead). The other two
1604        // rungs of the ladder author markup from what you type, which is the
1605        // whole difference between them and this one. Only in the rendered view
1606        // (source view is for typing raw markup) and only where the format has a
1607        // literal spelling at all: escaping is a backslash before a byte from the
1608        // format's own alphabet, and a format with no such alphabet (HTML escapes
1609        // with entities, XML spells nothing) would have `\&` written into it,
1610        // which is two literal characters and not an escape. Marks (⌘b) still
1611        // format — that path returned above; and leaf's own structural inserts go
1612        // through `insert_raw`, never here, so a list marker or quote gutter is
1613        // written as the markup it is.
1614        if !self.markup_mode.authors()
1615            && self.view == View::Wysiwyg
1616            && !text.is_empty()
1617            && self.supports(Gesture::InsertLiteral)
1618        {
1619            self.insert_literal_typed(text);
1620            return;
1621        }
1622        self.insert_raw(text);
1623    }
1624
1625    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1626    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1627    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1628    /// markup by design and must not be escaped.
1629    fn insert_raw(&mut self, text: &str) {
1630        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1631        self.splice(s, e, text, typed_edit_kind(text));
1632    }
1633
1634    /// Open a paragraph for text about to be inserted at one of a block media's
1635    /// two caret stops, and leave the caret standing in it.
1636    ///
1637    /// A block image is a paragraph whose entire content is the picture, and the
1638    /// caret's only homes on it are in front of it and just past it (see
1639    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1640    /// *that* paragraph — and a paragraph holding anything besides the image is
1641    /// no longer a block image but a line of text with an inline one in it. The
1642    /// frontend that was painting a photo there paints a text run instead; the
1643    /// picture is still in the file, and nothing said a word. Those two offsets
1644    /// are also exactly where a click on the picture lands, so the whole accident
1645    /// is one tap and one keystroke.
1646    ///
1647    /// So the break goes in first and the text lands in the new empty paragraph —
1648    /// what pressing Return before typing would have done, which is a habit no
1649    /// one should have to learn from losing a photo. A no-op everywhere else, and
1650    /// over a selection (which is replaced, not joined into).
1651    ///
1652    /// A picture inside a quote or a list leaves its container, because `\n\n`
1653    /// ends the block. The alternative is worse: the `\n> ` / next-item
1654    /// continuation [`newline`](Self::newline) writes stays in the same
1655    /// *paragraph*, which is the thing being prevented.
1656    ///
1657    /// Only in the rendered view. Source view is for typing raw markup, where
1658    /// putting a character against an image is exactly what it looks like.
1659    fn open_paragraph_at_block_media(&mut self, text: &str) {
1660        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1661            return;
1662        }
1663        if self.selection().is_some() {
1664            return;
1665        }
1666        // The map may be a revision behind (nothing has drawn since the last
1667        // edit), and this asks it about offsets — a stale answer would splice a
1668        // break into the wrong place. Free when it is already current, which it
1669        // is whenever a frontend drew a frame between keystrokes.
1670        self.rebuild_map();
1671        let at = self.caret;
1672        let Some((side, _)) = self.vmap.block_media_stop(at) else {
1673            return;
1674        };
1675        if !self.splice(at, at, "\n\n", EditKind::Other) {
1676            return;
1677        }
1678        // The break is part of the keystroke, not an edit of its own: leave the
1679        // run marked as typing so the character about to arrive folds into it and
1680        // one undo puts the document back the way it was found. (A paste, or a
1681        // multi-character insert, is `EditKind::Other` and stays its own step —
1682        // as it would have been anywhere else in the document.)
1683        self.last_edit_kind = Some(EditKind::Insert);
1684        if side == MediaStop::Before {
1685            // The break went in above the picture and the caret rode to the end
1686            // of it — which is still hard against the picture. Step back onto the
1687            // blank line it opened, so the text lands above rather than in front.
1688            self.caret = at;
1689        }
1690    }
1691
1692    /// A delete key pressed at one of a block picture's two caret stops, handled
1693    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1694    /// the key was consumed.
1695    ///
1696    /// The caret rests in front of a block image and just past it, never inside
1697    /// its markup — which the rendered view doesn't show. So the byte a delete
1698    /// key nominally takes there is one the writer cannot see, and taking it
1699    /// leaves the picture as broken markup rather than as anything anyone asked
1700    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1701    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1702    /// missing from a document with nobody having touched it — the same
1703    /// dissolution [`open_paragraph_at_block_media`](Self::open_paragraph_at_block_media)
1704    /// prevents from the typing side, and it cost this repository's own test vault
1705    /// a photo before it was found.
1706    ///
1707    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1708    /// when it is behind the caret, Delete when it is in front — which is what
1709    /// every editor does with an embed, and one undo away. The key aimed *away*
1710    /// from it would otherwise delete the paragraph break and merge a neighbour
1711    /// into the picture's own paragraph, which dissolves it just as surely; it
1712    /// steps the caret over the boundary instead and leaves the
1713    /// next press to delete in the block it has reached — the same "first press
1714    /// steps out of the atom, second press deletes" every delete key here gets,
1715    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1716    /// above, and reaches it on the second press rather than taking the break and
1717    /// the picture with it on the first).
1718    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1719        // The map answers about offsets, so it has to be this revision's — see
1720        // the same call in `open_paragraph_at_block_media`.
1721        self.rebuild_map();
1722        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1723            return false;
1724        };
1725        let aimed_at_it = side
1726            == if forward {
1727                MediaStop::Before
1728            } else {
1729                MediaStop::After
1730            };
1731        if !aimed_at_it {
1732            let over = if forward {
1733                self.vmap.stop_after(self.caret)
1734            } else {
1735                self.vmap.stop_before(self.caret)
1736            };
1737            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1738                self.caret = off;
1739                self.anchor = None;
1740                self.goal_col = None;
1741            }
1742            return true;
1743        }
1744        // Take the break that held the picture apart from its neighbour with it,
1745        // so the delete doesn't leave a blank paragraph standing where the
1746        // picture was. The last arm is a picture that is the whole document.
1747        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1748            (span.start - 2, span.end)
1749        } else if self.source[span.end..].starts_with("\n\n") {
1750            (span.start, span.end + 2)
1751        } else {
1752            (span.start, span.end)
1753        };
1754        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1755        true
1756    }
1757
1758    /// The Hidden-mode typing path: replace any selection, then insert `text`
1759    /// escaped so it stays literal. When it replaces a selection the two edits
1760    /// fold into one undo step, so an overwrite undoes atomically (and restores
1761    /// the selection) exactly as a plain one does.
1762    fn insert_literal_typed(&mut self, text: &str) {
1763        let kind = typed_edit_kind(text);
1764        match self.selection() {
1765            Some((s, e)) => {
1766                if !self.splice(s, e, "", EditKind::Other) {
1767                    return;
1768                }
1769                // Typing over a whole marked run takes its delimiters with it
1770                // (the empty content couldn't hold them — see
1771                // `repair_mark_edges`) and leaves its marks armed at the caret.
1772                // The text taking the run's place inherits them, exactly as it
1773                // would have by landing inside a run that survived.
1774                let pending = self.pending_here();
1775                if !pending.is_empty() && !text.trim().is_empty() {
1776                    self.insert_with_marks(self.caret, text, pending);
1777                    return;
1778                }
1779                self.insert_literal_at(self.caret, text, kind, true);
1780            }
1781            None => {
1782                self.insert_literal_at(self.caret, text, kind, false);
1783            }
1784        }
1785    }
1786
1787    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1788    /// at a collapsed caret, but only while the caret still stands where they
1789    /// were armed and nothing is selected. Empty otherwise, so a stale delta
1790    /// never styles text it wasn't meant for.
1791    fn pending_here(&self) -> InlineMarks {
1792        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1793            self.pending_marks
1794        } else {
1795            InlineMarks::empty()
1796        }
1797    }
1798
1799    /// Drop the armed sticky marks — any caret motion, selection, or edit does
1800    /// this, so "start bold here" only ever applies at the exact spot it was
1801    /// asked for.
1802    fn clear_pending(&mut self) {
1803        self.pending_marks = InlineMarks::empty();
1804        self.pending_at = None;
1805    }
1806
1807    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1808    /// force is wrapped around the freshly typed text; a mark the caret already
1809    /// stands inside is *shed* — the text is inserted past the run's end so it
1810    /// lands unmarked ("type normally again"). The caret comes to rest inside any
1811    /// added runs, so continued typing inherits the marks with no re-wrapping,
1812    /// and the delta is cleared: the marks now live in the document, not here.
1813    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1814        let base = self.mark_spans_at(at);
1815        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1816        // Nothing to shed, and a run of exactly these marks standing just behind
1817        // the caret: carry on writing *that* run rather than opening a second
1818        // one beside it.
1819        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1820            return;
1821        }
1822        // Shed the marks we're turning off: step the insertion point past the
1823        // end of each run the caret sits in, so the new text falls outside it.
1824        let mut ins_at = at;
1825        for (kind, span) in &base {
1826            if marks.contains(*kind) {
1827                ins_at = ins_at.max(span.end);
1828            }
1829        }
1830        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1831            return;
1832        }
1833        // The plain splice inserted exactly `text` at `ins_at`; that byte range
1834        // is the content every added mark wraps.
1835        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1836        for kind in marks.iter() {
1837            if !base_set.contains(kind) {
1838                let (ncs, nce) = self.wrap_span(cs, ce, kind);
1839                cs = ncs;
1840                ce = nce;
1841            }
1842        }
1843        self.caret = ce.min(self.source.len());
1844        self.anchor = None;
1845        self.last_edit_kind = None;
1846        // Realised: the marks are in the document now, and the caret sits inside
1847        // them, so there is no delta left to carry. Arm nothing, but remember the
1848        // spot so a *further* toggle before typing starts a clean delta here.
1849        self.pending_marks = InlineMarks::empty();
1850        self.pending_at = Some(self.caret);
1851        self.clamp_caret();
1852        self.record_caret();
1853    }
1854
1855    /// Carry on the marked run just behind `at` — moving its closing delimiters
1856    /// out past the new text — instead of opening a second run of the same marks
1857    /// beside it. Returns whether it did.
1858    ///
1859    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1860    /// A space typed after a bold word steps the caret out of the run, because
1861    /// `**bold **` is not bold; the next character has to step back *in*, or the
1862    /// writer who typed one bold phrase is left with `**bold** **and**` — two
1863    /// runs that read the same to a reader but spell the file in a way nobody
1864    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1865    /// words it isn't marking), and the marks behind it must be exactly the ones
1866    /// armed — a run of *some* other kind is a neighbour, not this phrase.
1867    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1868        if text.is_empty() || text.trim() != text {
1869            return false;
1870        }
1871        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1872        // Walk in through the delimiters stacked at that point, innermost last:
1873        // `***both*** ` closes two runs with one `***`, and rejoining means
1874        // getting behind all of them.
1875        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1876        while let Some((kind, content_end)) = self
1877            .editor
1878            .ancestors_at(prev_boundary(&self.source, cut))
1879            .unwrap_or_default()
1880            .into_iter()
1881            .filter(|m| m.span.end == cut)
1882            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1883        {
1884            if content_end >= cut {
1885                break; // a mark with no closing delimiter to step behind
1886            }
1887            kinds.insert(kind);
1888            cut = content_end;
1889        }
1890        if cut == gap_at || kinds != marks {
1891            return false;
1892        }
1893        // Re-spell the tail: the gap, then the new text, then the delimiters that
1894        // used to close in front of them — read out of the document rather than
1895        // written from a table, so whatever twig spells them with is what moves.
1896        let tail = format!(
1897            "{}{text}{}",
1898            &self.source[gap_at..at],
1899            &self.source[cut..gap_at]
1900        );
1901        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1902            return false;
1903        }
1904        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1905        self.anchor = None;
1906        self.last_edit_kind = None;
1907        self.pending_marks = InlineMarks::empty();
1908        self.pending_at = Some(self.caret);
1909        self.clamp_caret();
1910        self.record_caret();
1911        true
1912    }
1913
1914    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1915    /// never itself wrapped: a mark around a space draws nothing a reader can
1916    /// see, and in Markdown and Djot it draws its own delimiters instead
1917    /// (`** **`). So the space goes in unmarked — outside any run the armed
1918    /// marks are shedding — and the marks stay armed for the character after it,
1919    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1920    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1921        let base = self.mark_spans_at(at);
1922        // What the *next* character carries: the armed delta resolved against the
1923        // marks in force here, which the space must not quietly drop.
1924        let want = base
1925            .iter()
1926            .map(|(k, _)| *k)
1927            .collect::<InlineMarks>()
1928            .xor(marks);
1929        let mut ins_at = at;
1930        for (kind, span) in &base {
1931            if marks.contains(*kind) {
1932                ins_at = ins_at.max(span.end);
1933            }
1934        }
1935        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1936            return;
1937        }
1938        self.rearm(want);
1939        self.record_caret();
1940    }
1941
1942    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1943    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1944    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1945    /// added split evenly around the content — half the growth on each side.
1946    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1947        // The read-only gate — this door reaches twig without the splice.
1948        if self.read_only {
1949            return (s, e);
1950        }
1951        match self.editor.toggle_inline(s, e, kind) {
1952            Ok(change) => {
1953                self.last_edit_kind = None;
1954                self.refresh();
1955                self.dirty = self.source != self.clean_source;
1956                let added = (change.new.end - change.new.start).saturating_sub(e - s);
1957                let half = added / 2;
1958                (change.new.start + half, change.new.end - half)
1959            }
1960            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1961            // rather than lose the keystroke.
1962            Err(e2) => {
1963                self.status = Some(format!("{kind:?}: {e2}"));
1964                (s, e)
1965            }
1966        }
1967    }
1968
1969    /// The safe offset to splice a block-level break at, given a caret that may
1970    /// sit exactly between an inline mark's content and its own closing
1971    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1972    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1973    /// with nothing following it on the line: the closing `**` renders no
1974    /// glyph of its own, so the caret's "end of line" offset lands right
1975    /// before it). Splicing a paragraph/list/quote break at `off` itself would
1976    /// sever the delimiter from its content, stranding it alone on the new
1977    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1978    /// nested marks closing at the same point (`**_x_**`) all clear together.
1979    /// A no-op everywhere else — mid-run, or past real trailing content, no
1980    /// mark's `content_span` ends exactly at `off`.
1981    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1982        let off = off.min(self.source.len());
1983        self.editor
1984            .ancestors_at(off)
1985            .unwrap_or_default()
1986            .into_iter()
1987            .filter(|m| inline_kind(&m.kind).is_some())
1988            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1989            .map(|m| m.span.end)
1990            .max()
1991            .unwrap_or(off)
1992    }
1993
1994    /// The offset a *delete* aimed at the character before `off` should stop at,
1995    /// when `off` is the start of a run's text and the bytes behind it are that
1996    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1997    /// byte behind the caret at the start of a bold word is not a character the
1998    /// writer can see, let alone one they aimed Backspace at: taking it leaves
1999    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2000    /// delete steps over the whole delimiter to the visible character in front of
2001    /// it instead. Walks out to the *outermost* mark opening there, so
2002    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2003    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2004        let off = off.min(self.source.len());
2005        self.editor
2006            .ancestors_at(off)
2007            .unwrap_or_default()
2008            .into_iter()
2009            .filter(|m| inline_kind(&m.kind).is_some())
2010            .filter(|m| {
2011                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2012            })
2013            .map(|m| m.span.start)
2014            .min()
2015            .unwrap_or(off)
2016    }
2017
2018    /// `off` moved *inside* the run whose closing delimiters end there — the
2019    /// other offset the rich view draws in the same place, since a `**` renders
2020    /// no glyph of its own. `**bold**` has a caret home on each side of its
2021    /// closing delimiter, one column apart on screen and eight bytes and a whole
2022    /// run apart in the file, and a plain ← lands on the outer one whenever a
2023    /// space follows the phrase. The inner one is what the writer is pointing at
2024    /// there: the end of their bold word. Walks in through every mark closing at
2025    /// that point, innermost last, so `***both***` lands inside both. A no-op
2026    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2027    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2028        let mut off = off.min(self.source.len());
2029        loop {
2030            let inner = self
2031                .editor
2032                .ancestors_at(prev_boundary(&self.source, off))
2033                .unwrap_or_default()
2034                .into_iter()
2035                .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
2036                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2037                .filter(|&end| end < off)
2038                .max();
2039            match inner {
2040                Some(end) => off = end,
2041                None => return off,
2042            }
2043        }
2044    }
2045
2046    /// The mirror at the opening edge: `off` moved inside the run whose
2047    /// delimiters *start* there, onto the first character of its text. See
2048    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2049    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2050        let mut off = off.min(self.source.len());
2051        loop {
2052            let inner = self
2053                .editor
2054                .ancestors_at(off)
2055                .unwrap_or_default()
2056                .into_iter()
2057                .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
2058                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2059                .filter(|&start| start > off)
2060                .min();
2061            match inner {
2062                Some(start) => off = start,
2063                None => return off,
2064            }
2065        }
2066    }
2067
2068    /// The inline mark kinds whose span covers `off`, each with that span — the
2069    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2070    /// ids instead. Used to shed a mark by stepping past the end of its run.
2071    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2072        let off = off.min(self.source.len());
2073        self.editor
2074            .ancestors_at(off)
2075            .unwrap_or_default()
2076            .into_iter()
2077            .filter(|m| off < m.span.end)
2078            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2079            .collect()
2080    }
2081
2082    /// Insert clipboard `text` at the caret, replacing the selection if there is
2083    /// one — always its own undo step, whatever its length.
2084    ///
2085    /// Provenance is the whole point, and only the caller has it. `insert` reads
2086    /// a lone character as a keystroke and folds it into the run around it,
2087    /// which is right for typing and wrong for a one-character paste: that paste
2088    /// would vanish mid-run on an undo it was never part of, and the characters
2089    /// the user actually typed would go with it. Length can't tell the two
2090    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2091    /// caller comes through is what says which happened.
2092    pub fn paste(&mut self, text: &str) {
2093        // Pasting against a block picture dissolves it exactly as typing does,
2094        // and for the same reason — see `open_paragraph_at_block_media`.
2095        self.open_paragraph_at_block_media(text);
2096        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2097        self.splice(s, e, text, EditKind::Other);
2098    }
2099
2100    /// Replace `[start, end)` with `text` as one step of an IME composition —
2101    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2102    /// folds into a single undo.
2103    ///
2104    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2105    /// dozen calls here, each replacing the last one's provisional bytes, and an
2106    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2107    /// unspools backwards through kana — the intermediate states were never text
2108    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2109    /// look like any other edit), so the door the caller comes through is what
2110    /// says so, exactly as it is for [`paste`](Self::paste) versus
2111    /// [`insert`](Self::insert).
2112    ///
2113    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2114    /// composition folds into this one.
2115    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2116        self.splice(start, end, text, EditKind::Compose);
2117    }
2118
2119    /// Close the open composition run, so the next one is its own undo step.
2120    /// Call when the IME commits or withdraws a composition.
2121    ///
2122    /// Only clears a *composition* run: a frontend that reports an end it never
2123    /// began (some IMEs unmark unprompted) would otherwise split the run of
2124    /// typing around it into two undo steps for no reason the user can see.
2125    pub fn end_composition(&mut self) {
2126        if self.last_edit_kind == Some(EditKind::Compose) {
2127            self.last_edit_kind = None;
2128        }
2129    }
2130
2131    // ── the clipboard's rich flavor ──────────────────────────────────────────
2132
2133    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2134    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2135    /// nothing is selected, or when the selection doesn't render (the caller
2136    /// still has [`selected_text`](Self::selected_text), which is what to publish
2137    /// as `text/plain` either way).
2138    ///
2139    /// **The fragment is a source substring, and that is the honest limit here.**
2140    /// It's parsed standalone, so a selection whose meaning depends on its
2141    /// surroundings converts as what it literally says rather than what it looks
2142    /// like on screen: half a list item is a paragraph, a row torn out of a table
2143    /// is the text of a row, the `**` of a bold run selected without its closing
2144    /// `**` is two asterisks. Every one of those still *renders* — there's no
2145    /// error to report — it just renders as the fragment and not as the document.
2146    /// Widening the range to whole blocks would publish text the user didn't
2147    /// select, which is a worse lie than a fragment being a fragment; the plain
2148    /// flavor has the same substring, so the two flavors at least agree.
2149    pub fn selection_html(&mut self) -> Option<String> {
2150        let (start, end) = self.selection()?;
2151        let inline = self.selection_is_inline(start, end);
2152        let html = html::render_fragment(&self.source[start..end], self.format)?;
2153        Some(match inline {
2154            true => html::strip_sole_paragraph(html),
2155            false => html,
2156        })
2157    }
2158
2159    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2160    /// format first. Its own undo step, like any [`paste`](Self::paste).
2161    ///
2162    /// Returns whether it landed. `false` means the HTML didn't convert to
2163    /// anything worth pasting — the caller should fall back to the plain flavor
2164    /// rather than treat it as an error. The `html` module has the full list of
2165    /// what that covers: a table twig won't build, markup it doesn't recognise,
2166    /// an empty result.
2167    pub fn paste_html(&mut self, html: &str) -> bool {
2168        match html::parse_fragment(html, self.format) {
2169            Some(source) => {
2170                self.paste(&source);
2171                true
2172            }
2173            None => false,
2174        }
2175    }
2176
2177    /// Does the selection live *inside* a single top-level block?
2178    ///
2179    /// The question [`selection_html`](Self::selection_html) needs and the
2180    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2181    /// whether the user selected one word of a sentence or a whole paragraph, and
2182    /// only the document knows which. Selecting a word and pasting into Docs
2183    /// should extend the line you paste into; selecting the paragraph should make
2184    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2185    /// is an artifact of standalone parsing), and one that covers a whole block —
2186    /// or spans two — keeps its structure.
2187    ///
2188    /// Reads the block from twig rather than guessing from the bytes:
2189    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2190    /// block containing an offset, and two ends inside the same one cannot have
2191    /// crossed a block boundary.
2192    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2193        // The last *character*, not `end - 1`: the selection's end is exclusive
2194        // and may sit mid-codepoint's-worth of bytes past the last char.
2195        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2196            return false;
2197        };
2198        let (Some(head), Some(tail)) =
2199            (self.top_block_span(start), self.top_block_span(start + off))
2200        else {
2201            return false;
2202        };
2203        head == tail && !(start <= head.start && end >= head.end)
2204    }
2205
2206    /// The byte span of the top-level block containing `offset`, or `None` at an
2207    /// offset that belongs to no block (the blank line between two of them).
2208    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2209        self.editor
2210            .ancestors_at(offset)
2211            .ok()?
2212            .get(1)
2213            .map(|m| m.span.clone())
2214    }
2215
2216    // ── indentation ──────────────────────────────────────────────────────────
2217
2218    /// One indent level.
2219    ///
2220    /// Two spaces, not the four both frontends type for Tab today, because in a
2221    /// markdown document four columns isn't a width — it's a *meaning*. Four
2222    /// spaces at the head of a line is markdown's indented-code-block marker, so
2223    /// one Tab on a paragraph would reparse it into code and style it as such;
2224    /// two cannot, and the line stays the prose it was. Two is also exactly
2225    /// where a `- ` bullet's content starts, so an indented line lands under its
2226    /// parent item's text instead of beside it — the column a list-aware indent
2227    /// has to hit anyway, which keeps this width from being relitigated later.
2228    const INDENT: &'static str = "  ";
2229
2230    /// Indent the selected lines — or the caret's line, with no selection — by
2231    /// one level (Tab).
2232    pub fn indent(&mut self) {
2233        self.reindent(true);
2234        // Nesting changes an ordered list's numbering (the nested item restarts,
2235        // its old siblings resume) — keep the source markers in step.
2236        self.renumber_here();
2237        // Nesting an empty `-` item under a text line reparses that text as a
2238        // setext heading; swap the dash for a `*` before it can (a no-op unless
2239        // the collapse actually happened).
2240        self.avoid_setext_collapse();
2241    }
2242
2243    /// Take one indent level back off the selected lines, or the caret's line
2244    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2245    ///
2246    /// A line with *less* than a full level gives back what it has rather than
2247    /// refusing: outdent's job is to walk a line left, and real documents — hand
2248    /// written, or reflowed by some other editor — are full of indentation that
2249    /// was never a clean multiple of anything. Refusing there would strand the
2250    /// line at a depth Shift+Tab couldn't undo.
2251    pub fn outdent(&mut self) {
2252        self.reindent(false);
2253        self.renumber_here();
2254    }
2255
2256    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2257    ///
2258    /// One splice across the whole line range, never one per line: a Tab is one
2259    /// thing the user did, so it has to be one undo step and one reparse. Per
2260    /// line, twig would reparse the document once per line and leave a stack of
2261    /// steps that Shift+⌘Z walks back one line at a time.
2262    fn reindent(&mut self, add: bool) {
2263        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2264        let start = source_line_range(&self.source, sel_start).start;
2265        let end = source_line_range(&self.source, sel_end).end;
2266        let region = self.source[start..end].to_string();
2267        let lines: Vec<&str> = region.split('\n').collect();
2268        // A blank line has no text to move, and padding it would leave nothing
2269        // but trailing whitespace — but Tab on a blank line *is* a request for
2270        // indentation to type into, so the skip only applies where the op has
2271        // other lines to do real work on.
2272        let skip_blank = add && lines.len() > 1;
2273
2274        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2275        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2276        let mut line_off = start;
2277        for (i, full) in lines.iter().enumerate() {
2278            if i > 0 {
2279                out.push('\n');
2280            }
2281            // A list item moves by having its whole leading prefix *replaced*,
2282            // never by having spaces pushed in front of the line. twig spells
2283            // both prefixes, so the quote markers, the parent's indent and an
2284            // ordered marker's extra column all come out right without leaf
2285            // measuring any of them — and a line that only looks like an item
2286            // (a Djot continuation) reports no marker and is left to the plain
2287            // path, where a Tab is just a Tab.
2288            let marker = self.list_marker_on_line(line_off);
2289            let own = marker
2290                .as_ref()
2291                .map(|m| m.marker_start - m.line_start)
2292                .unwrap_or(0);
2293            let delta = if add {
2294                if skip_blank && full.trim().is_empty() {
2295                    out.push_str(full);
2296                    0
2297                } else if marker.is_some() && self.first_item_of_list(line_off) {
2298                    // The first item of a list has no preceding sibling to nest
2299                    // under, so a Tab here can't spell a sub-list — twig would
2300                    // reparse the shoved-over marker as the same list, only
2301                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2302                    // item where it is, the way every list editor refuses to
2303                    // over-indent a list's first line.
2304                    out.push_str(full);
2305                    0
2306                } else if marker.is_some() {
2307                    // Nesting means standing where a *continuation* of this line
2308                    // would stand: past the parent's marker, inside its content
2309                    // column. That is `continuation_prefix`, less a checkbox.
2310                    let new = self.nesting_prefix_at(line_off);
2311                    let delta = new.len() as isize - own as isize;
2312                    out.push_str(&new);
2313                    out.push_str(&full[own..]);
2314                    delta
2315                } else {
2316                    out.push_str(Self::INDENT);
2317                    out.push_str(full);
2318                    Self::INDENT.len() as isize
2319                }
2320            } else if marker.is_some() {
2321                // Unnesting is the mirror: stand where the parent item's own
2322                // line starts, which drops exactly the level it contributed.
2323                let new = self.outdent_prefix_at(line_off);
2324                let delta = new.len() as isize - own as isize;
2325                out.push_str(&new);
2326                out.push_str(&full[own..]);
2327                delta
2328            } else {
2329                // A plain line gives back the ordinary step.
2330                let strip = outdent_width(full, Self::INDENT.len());
2331                out.push_str(&full[strip..]);
2332                -(strip as isize)
2333            };
2334            deltas.push(delta);
2335            line_off += full.len() + 1;
2336        }
2337        // Nothing to give back. Returning before the splice keeps an outdent at
2338        // column zero from spending an undo step on a document it never changed.
2339        if deltas.iter().all(|d| *d == 0) {
2340            return;
2341        }
2342
2343        // Every line's text keeps its offset *within the line*, so the caret is
2344        // remapped by its column, not by its byte offset — which the prefixes on
2345        // the lines above it have already invalidated.
2346        let remap = |off: usize| -> usize {
2347            let (mut old_ls, mut new_ls) = (start, start);
2348            for (line, delta) in lines.iter().zip(&deltas) {
2349                let old_le = old_ls + line.len();
2350                let new_len = (line.len() as isize + delta) as usize;
2351                if off <= old_le {
2352                    let col = (off - old_ls) as isize;
2353                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2354                }
2355                old_ls = old_le + 1;
2356                new_ls += new_len + 1;
2357            }
2358            start + out.len()
2359        };
2360        let placed = match self.selection() {
2361            // Keep the rewritten region selected, the way a container toggle
2362            // keeps its own: it leaves a second Tab aimed at the same lines
2363            // rather than at whatever the shifted offsets now happen to cover.
2364            Some(_) => (start + out.len(), Some(start)),
2365            None => (remap(self.caret), None),
2366        };
2367
2368        // A rolled-back splice leaves the old source in place, where every offset
2369        // computed above addresses text that was never written.
2370        if !self.splice(start, end, &out, EditKind::Other) {
2371            return;
2372        }
2373        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2374        // rewrite is the last line's end — nowhere the caret was. Place it, then
2375        // re-record the caret so this is the state redo restores, not the one
2376        // `splice` left behind from the `Change`.
2377        self.caret = placed.0.min(self.source.len());
2378        self.anchor = placed.1;
2379        self.clamp_caret();
2380        self.record_caret();
2381    }
2382
2383    /// The Enter key.
2384    ///
2385    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2386    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2387    /// caret is in decides what actually gets written.
2388    ///
2389    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2390    ///                            block at the caret and reopens its container
2391    ///   - list item            → likewise: the next item, its indent, quote
2392    ///                            prefix and `[ ]` box all reproduced by twig —
2393    ///                            except an *empty* item, which exits the list
2394    ///   - block quote          → likewise: a new paragraph inside the quote
2395    ///   - heading              → a new *paragraph*, not another heading
2396    ///   - code block           → a literal newline (stay in the block)
2397    ///   - blank line           → a literal newline (one Backspace undoes it)
2398    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2399    ///                            visible line
2400    ///
2401    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2402    /// and it is better at it: it drops the whitespace the caret was sitting in
2403    /// front of instead of stranding it at the head of the second half, and it
2404    /// knows continuations leaf's marker scan never covered — a checklist item
2405    /// continues as an *unchecked* checklist item rather than a plain bullet.
2406    ///
2407    /// The exceptions above are exceptions because `split_block` is either wrong
2408    /// there or refuses: parting a fence yields two fences with the code split
2409    /// between them, parting a heading yields a second heading where every editor
2410    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2411    /// table all report an error rather than a split.
2412    pub fn newline(&mut self) {
2413        if self.view == View::Source {
2414            self.insert_raw("\n");
2415            return;
2416        }
2417        // Enter over a selection replaces it with a paragraph break.
2418        if let Some((s, e)) = self.selection() {
2419            self.splice(s, e, "\n\n", EditKind::Other);
2420            return;
2421        }
2422        // A caret resting exactly between an inline mark's content and its own
2423        // closing delimiter (`**bold**` with nothing after it on the line —
2424        // the WYSIWYG caret's natural end-of-line position) must not splice a
2425        // block break there: every path below eventually does via
2426        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2427        // delimiter would strand it alone on the new line.
2428        self.caret = self.skip_trailing_close_delims(self.caret);
2429        // The block the caret is in. `block_offset_for_caret` nudges off a line
2430        // end (where the caret sits at the doc level); on a bare line (e.g. an
2431        // empty list item) fall back to the caret so the enclosing list/quote is
2432        // still visible in the ancestors.
2433        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2434        let kinds: Vec<Kind> = self
2435            .editor
2436            .ancestors_at(off)
2437            .map(|c| c.into_iter().map(|m| m.kind).collect())
2438            .unwrap_or_default();
2439        let has = |k: Kind| kinds.contains(&k);
2440
2441        if has(Kind::CodeBlock) {
2442            self.insert_raw("\n");
2443            return;
2444        }
2445        // An *empty* list item exits the list — the standard double-Enter — which
2446        // `split_block` reports as an error rather than a split (there is no
2447        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2448        // the AST gate — it answers from the tree, so a `- ` that reads as a
2449        // marker byte-for-byte but opens no item (a setext underline, a Djot
2450        // continuation line) never reaches here.
2451        if let Some(marker) = self.list_marker_on_line(self.caret)
2452            && self.item_is_empty(&marker)
2453        {
2454            self.exit_list(&marker);
2455            return;
2456        }
2457        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2458        // not another full paragraph break — so it moves down one line and one
2459        // Backspace undoes it, not two. (`split_block` errors here too.)
2460        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2461        let line_end = self.source[self.caret..]
2462            .find('\n')
2463            .map_or(self.source.len(), |i| self.caret + i);
2464        if self.source[line_start..line_end].trim().is_empty() {
2465            self.insert_raw("\n");
2466            return;
2467        }
2468        // In `Preserve` flow a soft break is a *visible* line the author means to
2469        // make, so Enter writes a single `\n` and typing continues the same
2470        // paragraph on the next line — the behaviour of an ordinary text editor.
2471        // A second Enter then lands on the blank line above and takes the
2472        // empty-line branch, so double-Enter still promotes to a full paragraph
2473        // break; and Backspace, which deletes a lone `\n` over a soft break,
2474        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2475        // render as an invisible space, so Enter keeps making the paragraph break
2476        // that actually shows.
2477        //
2478        // Only in running prose. A list or a quote has a continuation of its own
2479        // to write, and a `\n` there is not a soft line but a lost container.
2480        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2481        if self.line_flow == LineFlow::Preserve && !in_container {
2482            self.insert_raw("\n");
2483            return;
2484        }
2485        // A heading gets a *paragraph*, never a second heading: Enter at the end
2486        // of a title is how every editor is asked for the body under it, and
2487        // `split_block` would repeat the `#` instead. Whitespace at the split
2488        // point goes with the break rather than opening the new paragraph, which
2489        // is what `split_block` does everywhere else.
2490        if has(Kind::Heading) {
2491            let mut end = self.caret;
2492            while self.source.as_bytes().get(end) == Some(&b' ') {
2493                end += 1;
2494            }
2495            self.splice(self.caret, end, "\n\n", EditKind::Other);
2496            return;
2497        }
2498        self.split_block_here();
2499    }
2500
2501    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2502    /// the caret in the second half.
2503    ///
2504    /// twig reopens whatever the first half was inside of — the bullet with its
2505    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2506    /// reason this replaced the markup leaf used to spell from the line's bytes.
2507    /// It renumbers nothing, though: a new item mid-list is written with its
2508    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2509    /// behind it, folded into the same undo step.
2510    ///
2511    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2512    /// shape still moves the caret down rather than swallowing the keystroke.
2513    fn split_block_here(&mut self) {
2514        // The read-only gate — this door reaches twig without the splice.
2515        if self.read_only {
2516            return;
2517        }
2518        match self.editor.split_block(self.caret) {
2519            Ok(change) => {
2520                self.last_edit_kind = None;
2521                self.refresh();
2522                self.anchor = None;
2523                self.caret = change.new.end;
2524                self.dirty = self.source != self.clean_source;
2525                self.status = None;
2526                self.clamp_caret();
2527                self.record_caret();
2528                // Aimed at the new block's *start*: the caret twig leaves is one
2529                // past the marker it wrote, where there is no list in reach.
2530                self.renumber_at(change.new.start);
2531            }
2532            Err(_) => self.insert_raw("\n\n"),
2533        }
2534    }
2535
2536    /// Whether the item on the marker's line carries no content — the shape
2537    /// double-Enter reads as "I'm done with this list."
2538    fn item_is_empty(&self, line: &ListMarker) -> bool {
2539        let content_start = line.content_start().min(self.source.len());
2540        let line_end = self.source[self.caret..]
2541            .find('\n')
2542            .map(|i| self.caret + i)
2543            .unwrap_or(self.source.len());
2544        self.source[content_start..line_end.max(content_start)]
2545            .trim()
2546            .is_empty()
2547    }
2548
2549    /// Leave the list: replace the empty item's marker with a blank line, so the
2550    /// caret lands in a fresh paragraph below it.
2551    ///
2552    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2553    /// quote), and the caret's new line keeps the `> ` it was already behind —
2554    /// leaving the list without also leaving the quote.
2555    fn exit_list(&mut self, line: &ListMarker) {
2556        let prefix = self.quote_prefix_at(line.marker_start);
2557        let blank = prefix.trim_end();
2558        self.splice(
2559            line.line_start,
2560            self.caret,
2561            &format!("{blank}\n{prefix}"),
2562            EditKind::Other,
2563        );
2564    }
2565
2566    /// What a line continuing the containers at `off` has to open with — the
2567    /// quote markers reproduced, each enclosing item's marker as its width in
2568    /// spaces. Also the column a nested item's marker stands in, which is what
2569    /// makes it Tab's answer.
2570    fn continuation_prefix_at(&mut self, off: usize) -> String {
2571        self.editor
2572            .document()
2573            .and_then(|mut d| d.continuation_prefix(off))
2574            .map(|p| p.text)
2575            .unwrap_or_default()
2576    }
2577
2578    /// The column a *nested list* may open at inside the item at `off` — which
2579    /// is not always where the item's own text continues.
2580    ///
2581    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2582    /// it is markup a rich view hides, and the item's own wrapped text does
2583    /// stand past it. But a nested list may only open at the *list* marker's
2584    /// column, and four columns further in is an indented continuation of the
2585    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2586    /// So the box's own width goes back.
2587    ///
2588    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2589    /// reports the list marker's column apart from the box; `checked` is what
2590    /// says a box is there at all, so only its width is being measured here.
2591    fn nesting_prefix_at(&mut self, off: usize) -> String {
2592        let cont = self.continuation_prefix_at(off);
2593        let Some(item) = self.innermost_list_item(off) else {
2594            return cont;
2595        };
2596        if item.checked.is_none() {
2597            return cont;
2598        }
2599        let box_width = item
2600            .marker_span
2601            .and_then(|m| self.source.get(m))
2602            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2603            .unwrap_or(0);
2604        // The trailing columns are the ones the item's own marker contributed,
2605        // so trimming from the end leaves any quote prefix standing.
2606        cont[..cont.len().saturating_sub(box_width)].to_string()
2607    }
2608
2609    /// Where the line of the item *containing* the item at `off` begins — the
2610    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2611    /// parent contributed. The quote prefix alone for a top-level item, which
2612    /// has no level left to give.
2613    fn outdent_prefix_at(&mut self, off: usize) -> String {
2614        let items: Vec<usize> = self
2615            .editor
2616            .document()
2617            .and_then(|mut d| d.ancestors_at_caret(off))
2618            .map(|c| {
2619                c.into_iter()
2620                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2621                    .map(|m| m.span.start)
2622                    .collect()
2623            })
2624            .unwrap_or_default();
2625        // The second-innermost item is the parent; its own line's indent is the
2626        // target. `list_marker_on_line` gives that line's prefix directly.
2627        let parent = items.len().checked_sub(2).map(|i| items[i]);
2628        match parent.and_then(|p| self.list_marker_on_line(p)) {
2629            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2630            None => self.quote_prefix_at(off),
2631        }
2632    }
2633
2634    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2635    /// inside one, `"> > "` inside two.
2636    ///
2637    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2638    /// the `>` and the space after it are twig's spelling rather than leaf's.
2639    /// The whole line prefix can't answer this: it also carries the indent of
2640    /// whatever the quote holds, which a blank separator line must *not* repeat.
2641    fn quote_prefix_at(&mut self, off: usize) -> String {
2642        let Ok(chain) = self
2643            .editor
2644            .document()
2645            .and_then(|mut d| d.ancestors_at_caret(off))
2646        else {
2647            return String::new();
2648        };
2649        let quotes: Vec<usize> = chain
2650            .iter()
2651            .filter(|m| m.kind == Kind::BlockQuote)
2652            .map(|m| m.node_id as usize)
2653            .collect();
2654        let Ok(nodes) = self.editor.nodes() else {
2655            return String::new();
2656        };
2657        quotes
2658            .iter()
2659            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2660            .filter_map(|s| self.source.get(s))
2661            .collect()
2662    }
2663
2664    /// Whether the item at `off` sits inside another one — the test Backspace
2665    /// uses to choose between outdenting and dropping the marker.
2666    ///
2667    /// Counted from the AST rather than from the line's leading whitespace,
2668    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2669    fn item_is_nested(&mut self, off: usize) -> bool {
2670        self.editor
2671            .document()
2672            .and_then(|mut d| d.ancestors_at_caret(off))
2673            .map(|c| {
2674                c.into_iter()
2675                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2676                    .count()
2677                    > 1
2678            })
2679            .unwrap_or(false)
2680    }
2681
2682    /// The innermost list item containing `probe`, under twig's **caret**
2683    /// containment rule — a block's end is inside it.
2684    ///
2685    /// Half-open containment can't answer this. An empty item's span is exactly
2686    /// its marker, so the caret sitting after `- ` is one past the end and the
2687    /// item it is plainly in tests as out of reach; that is the shape
2688    /// double-Enter has to recognise to leave the list.
2689    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2690        let chain = self
2691            .editor
2692            .document()
2693            .and_then(|mut d| d.ancestors_at_caret(probe))
2694            .ok()?;
2695        let id = chain
2696            .iter()
2697            .rev()
2698            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2699            .node_id as usize;
2700        self.editor.nodes().ok()?.get(id).cloned()
2701    }
2702
2703    /// The list marker opening `off`'s line, per twig — `None` when that line
2704    /// opens no list item.
2705    ///
2706    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2707    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2708    /// and it is `None` on a *continuation* line, which opens nothing. That last
2709    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2710    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2711    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2712    /// parser knows which document it is looking at.
2713    ///
2714    /// The item's own marker is separated out via its
2715    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2716    /// the containers around it contribute and what the item does.
2717    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2718        let off = off.min(self.source.len());
2719        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2720        // The prefix belongs to a list only when an item's marker closes it —
2721        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2722        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2723        let marker = item.marker_span.clone()?;
2724        if marker.end != prefix.end {
2725            return None;
2726        }
2727        Some(ListMarker {
2728            line_start: prefix.start,
2729            marker_start: marker.start,
2730            text: self.source.get(prefix)?.to_string(),
2731        })
2732    }
2733
2734    /// Whether the list item on `line_start`'s line is the **first item** of its
2735    /// list — the one Tab must not nest, because nesting needs a preceding
2736    /// sibling to become the new parent and a first item has none. `false` for a
2737    /// line that isn't a list item, and for an item with a sibling above it (the
2738    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2739    /// the same in a setext underline that opens no list at all.
2740    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2741        let Some(marker) = self.list_marker_on_line(line_start) else {
2742            return false;
2743        };
2744        // Probe just inside the marker, where the item's own node is in reach —
2745        // the marker offset itself can resolve to the enclosing list, not the
2746        // `list_item`, whose span starts at the marker.
2747        let probe = marker.content_start().min(self.source.len());
2748        let Some(item) = self.innermost_list_item(probe) else {
2749            return false;
2750        };
2751        let Ok(nodes) = self.editor.nodes() else {
2752            return false;
2753        };
2754        match item.parent {
2755            // First when the parent list opens with this very item.
2756            Some(pid) => nodes
2757                .get(pid.0 as usize)
2758                .is_some_and(|p| p.first_child == Some(item.id)),
2759            // A parentless item is trivially the first (and only) one.
2760            None => true,
2761        }
2762    }
2763
2764    pub fn backspace(&mut self) {
2765        if let Some((s, e)) = self.selection() {
2766            self.splice(s, e, "", EditKind::Other);
2767            return;
2768        }
2769        // WYSIWYG: Backspace at the very start of a list item's content is a
2770        // structural key, not a character delete — it walks the "un-indent, then
2771        // un-list" ladder every list editor gives that keystroke (outdent a
2772        // nested item, strip a top-level one's marker to a paragraph). In source
2773        // view the `- ` is visible text the user is deleting a byte of, so it
2774        // keeps its literal meaning there, like Enter does.
2775        if self.view != View::Source && self.backspace_list_start() {
2776            return;
2777        }
2778        // WYSIWYG: and the same at the start of a heading's content — the `# `
2779        // there is markup the rich view hides, not text the user typed.
2780        if self.view != View::Source && self.backspace_heading_start() {
2781            return;
2782        }
2783        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2784        // the markup apart under a caret that cannot see it — see
2785        // `delete_around_block_media`.
2786        if self.view != View::Source && self.delete_around_block_media(false) {
2787            return;
2788        }
2789        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2790        // stop, not a single newline. On a line with no text of its own, the byte
2791        // before the caret is a `\n` that spells part of a block boundary — the gap
2792        // between two blocks, drawn but never a caret home. Removing just it strands
2793        // the caret in that gap and leaves an odd blank line the eye reads as one
2794        // separator but the caret can't land on: the "extra newline" left behind
2795        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2796        // Deleting to the previous stop instead collapses the whole break at once,
2797        // landing the caret at the end of the block above. Two blank lines in a row
2798        // are one stop apart, so this still removes exactly one — the lone-Enter /
2799        // lone-Backspace symmetry the empty-line case is built on is untouched.
2800        if self.view != View::Source
2801            && self.caret > self.caret_floor()
2802            && self.caret_on_blank_line()
2803            && let Some(stop) = self.vmap.stop_before(self.caret)
2804        {
2805            let stop = stop.max(self.caret_floor());
2806            if stop < self.caret {
2807                self.splice(stop, self.caret, "", EditKind::Delete);
2808                return;
2809            }
2810        }
2811        if self.caret > self.caret_floor() {
2812            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2813            // takes the whole tag — a single-byte step would leave a broken `<br`
2814            // showing in the cell. Rich view only (source view edits the literal).
2815            if self.view != View::Source
2816                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2817            {
2818                let start = start.max(self.caret_floor());
2819                if start < end {
2820                    self.splice(start, end, "", EditKind::Delete);
2821                    return;
2822                }
2823            }
2824            // Aim the delete at the character the writer can *see* behind the
2825            // caret, never at a delimiter the rich view drew nothing for. Two
2826            // steps, and either can apply: from the far side of a run's closing
2827            // `**` step back into the run (the caret is drawn at the end of its
2828            // word), and at the start of a run's text step out past its opening
2829            // `**` to the character in front of it, leaving the run standing.
2830            // Without them a plain Backspace unspells the phrase it is editing
2831            // and leaves a literal asterisk on screen.
2832            let end = if self.view == View::Source {
2833                self.caret
2834            } else {
2835                let inside = self.step_inside_close_delims(self.caret);
2836                self.skip_leading_open_delims(inside)
2837                    .max(self.caret_floor())
2838            };
2839            // Never delete back across the floor — that would eat hidden
2840            // frontmatter the WYSIWYG caret can't even see.
2841            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2842            // Take a hidden escape backslash with the char it escapes: the rich
2843            // view draws `\*` as a single `*`, so Backspace over it must delete
2844            // both bytes, never strand the `\` as a lone visible backslash (the
2845            // mirror of the Hidden-mode typing that wrote the escape). Source view
2846            // shows the `\`, so there it is an ordinary character.
2847            if self.view != View::Source
2848                && prev > self.caret_floor()
2849                && self.is_hidden_escape(prev - 1)
2850            {
2851                prev -= 1;
2852            }
2853            if prev < end {
2854                self.splice(prev, end, "", EditKind::Delete);
2855            }
2856        }
2857    }
2858
2859    /// Whether the caret's own source line holds nothing but whitespace — an
2860    /// empty paragraph, or the blank line a block boundary is spelled with. The
2861    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2862    /// no text of its own, so the newline before the caret belongs to the gap
2863    /// between blocks rather than to any word the caret is editing.
2864    fn caret_on_blank_line(&self) -> bool {
2865        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2866        let line_end = self.source[self.caret..]
2867            .find('\n')
2868            .map_or(self.source.len(), |i| self.caret + i);
2869        self.source[line_start..line_end].trim().is_empty()
2870    }
2871
2872    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2873    /// `edge` side — the byte range to delete whole. A table row is one source
2874    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2875    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2876    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2877    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2878    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2879    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
2880    /// apart — no ancestor walk needed. Rich view only; source view shows the
2881    /// literal tag and deletes it a byte at a time.
2882    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2883        let caret = self.caret;
2884        let nodes = self.nodes();
2885        let src = self.source.as_bytes();
2886        nodes
2887            .iter()
2888            .find(|n| {
2889                n.kind == Kind::HardBreak
2890                    && n.span.start < n.span.end
2891                    && src.get(n.span.start) == Some(&b'<')
2892                    && match edge {
2893                        BreakEdge::Backward => n.span.end == caret,
2894                        BreakEdge::Forward => n.span.start == caret,
2895                    }
2896            })
2897            .map(|n| (n.span.start, n.span.end))
2898    }
2899
2900    /// Whether the source byte at `off` is a backslash twig consumed as an escape
2901    /// (hidden in the rich view), as against a literal backslash (drawn). A
2902    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2903    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2904    /// round-trip needed.
2905    fn is_hidden_escape(&self, off: usize) -> bool {
2906        let b = self.source.as_bytes();
2907        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2908    }
2909
2910    /// Backspace's list behaviour: when the caret sits exactly at the start of a
2911    /// list item's content (right after its marker), outdent the item if it's
2912    /// nested, else strip the marker so it becomes a paragraph. Returns whether
2913    /// it acted — `false` leaves Backspace its ordinary character delete.
2914    fn backspace_list_start(&mut self) -> bool {
2915        let Some(marker) = self.list_marker_on_line(self.caret) else {
2916            return false;
2917        };
2918        // Only right after the marker. That the line opens a real item is
2919        // already settled: `list_marker_on_line` answers from the tree.
2920        if self.caret != marker.content_start() {
2921            return false;
2922        }
2923        if self.item_is_nested(marker.marker_start) {
2924            // Nested: give back one level, keeping the marker and carrying the
2925            // caret with it.
2926            self.outdent();
2927        } else {
2928            // Top level: drop the marker, leaving a paragraph, then renumber the
2929            // siblings the removed item was counted among. Only the marker goes —
2930            // a quote prefix in front of it still has a quote to hold up.
2931            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2932            self.renumber_here();
2933        }
2934        true
2935    }
2936
2937    /// Backspace's heading behaviour: with the caret exactly at the start of an
2938    /// ATX heading's content — right after the `#` marker the rich view hides —
2939    /// strip the marker so the line becomes a paragraph. The peer of
2940    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2941    /// reasoning: hidden block markup is structure, so the keystroke over it is
2942    /// structural.
2943    ///
2944    /// Without this the ordinary delete takes the space out of `# Title` and
2945    /// leaves `#Title`, which is no longer a heading at all — the hash the view
2946    /// had been hiding surfaces as literal text the user has to delete a second
2947    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2948    /// other end) goes with the marker for the same reason.
2949    ///
2950    /// Returns whether it acted; `false` leaves Backspace its character delete.
2951    fn backspace_heading_start(&mut self) -> bool {
2952        let caret = self.caret;
2953        // The heading whose content opens exactly at the caret. A bare `#` has no
2954        // content span at all — its content starts (and ends) where the line does.
2955        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2956            let (start, end) = match &n.content_span {
2957                Some(c) => (c.start, c.end),
2958                None => (n.span.end, n.span.end),
2959            };
2960            (n.kind == Kind::Heading && start == caret)
2961                .then(|| (n.span.clone(), end, n.marker_span.clone()))
2962        }) else {
2963            return false;
2964        };
2965        // twig reports the marker's own extent, so there is nothing to walk back
2966        // over and no `#` in this file. A setext heading has no marker — its
2967        // content opens the line — so it falls through to the ordinary delete,
2968        // as does anything else sitting at a content start.
2969        // `m.end == caret` is what excludes a setext heading, whose marker is the
2970        // underline *after* the content rather than a prefix before it.
2971        let Some(marker) = marker.filter(|m| m.end == caret) else {
2972            return false;
2973        };
2974        let start = marker.start;
2975        // A closing `#` sequence is hidden too, so it can't be left behind. Only
2976        // when the tail really is one: trailing spaces alone are nothing to strip.
2977        let tail = &self.source[content_end..span.end];
2978        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2979            let kept = self.source[caret..content_end].to_string();
2980            self.splice(start, span.end, &kept, EditKind::Other);
2981            // The splice leaves the caret past the text it re-wrote; the caret
2982            // belongs where the content now starts, which is where it already was.
2983            self.caret = start;
2984            self.record_caret();
2985        } else {
2986            self.splice(start, caret, "", EditKind::Other);
2987        }
2988        true
2989    }
2990
2991    pub fn delete_forward(&mut self) {
2992        if let Some((s, e)) = self.selection() {
2993            self.splice(s, e, "", EditKind::Other);
2994        } else if self.caret < self.source.len() {
2995            // The mirror of Backspace's: forward-delete in front of a picture
2996            // would eat the `!` off its markup and leave a link where a photo was.
2997            if self.view != View::Source && self.delete_around_block_media(true) {
2998                return;
2999            }
3000            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3001            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3002            // strands a broken `<br` in the cell.
3003            if self.view != View::Source
3004                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3005            {
3006                self.splice(start, end, "", EditKind::Delete);
3007                return;
3008            }
3009            // The mirror of Backspace's two steps: from in front of a run's
3010            // opening `**` step into it, onto the first letter of its text, and
3011            // at the end of a run's text step out past its closing `**` to the
3012            // character beyond. Either way Delete takes the character it looks
3013            // like it is pointing at, and never a delimiter drawn as nothing.
3014            // The caret then settles back inside the run it was standing in —
3015            // see `settle_inside_close_delims`.
3016            let from = if self.view == View::Source {
3017                self.caret
3018            } else {
3019                let inside = self.step_inside_open_delims(self.caret);
3020                self.skip_trailing_close_delims(inside)
3021            };
3022            let next = next_boundary(&self.source, from);
3023            if from < next {
3024                self.splice(from, next, "", EditKind::Delete);
3025            }
3026        }
3027    }
3028
3029    /// Delete from the caret back to the start of the previous word (⌥⌫ /
3030    /// Ctrl+⌫). Deletes the selection instead when one is active.
3031    pub fn delete_word_back(&mut self) {
3032        if let Some((s, e)) = self.selection() {
3033            self.splice(s, e, "", EditKind::Other);
3034        } else {
3035            // A word back from just past a picture is a word *of its markup*, and
3036            // a word back from in front of one runs through the paragraph break
3037            // into the prose above — dissolving the picture either way. See
3038            // `delete_around_block_media`.
3039            if self.view != View::Source && self.delete_around_block_media(false) {
3040                return;
3041            }
3042            let start = self.word_left_from(self.caret).max(self.caret_floor());
3043            if start < self.caret {
3044                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3045                self.splice(s, e, "", EditKind::Delete);
3046            }
3047        }
3048    }
3049
3050    /// Delete from the caret forward to the end of the next word (⌥⌦ /
3051    /// Ctrl+Del). Deletes the selection instead when one is active.
3052    pub fn delete_word_forward(&mut self) {
3053        if let Some((s, e)) = self.selection() {
3054            self.splice(s, e, "", EditKind::Other);
3055        } else {
3056            // The mirror: a word forward from in front of a picture is its markup.
3057            if self.view != View::Source && self.delete_around_block_media(true) {
3058                return;
3059            }
3060            let end = self.word_right_from(self.caret);
3061            if end > self.caret {
3062                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3063                self.splice(s, e, "", EditKind::Delete);
3064            }
3065        }
3066    }
3067
3068    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3069    /// selection instead when one is active, as every other delete here does.
3070    ///
3071    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3072    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3073    /// stops at the first character and this takes the indentation with it, the
3074    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3075    /// leave an indent behind that nothing can then ask to delete, where a caret
3076    /// left at column 0 is one press of Home away from either.
3077    pub fn delete_to_line_start(&mut self) {
3078        if let Some((s, e)) = self.selection() {
3079            self.splice(s, e, "", EditKind::Other);
3080            return;
3081        }
3082        // Never back across the floor: hidden frontmatter isn't on this line, or
3083        // on any line the WYSIWYG caret can see.
3084        let (start, _) = self.line_span();
3085        let start = start.max(self.caret_floor());
3086        if start < self.caret {
3087            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3088            self.splice(s, e, "", EditKind::Delete);
3089        }
3090    }
3091
3092    /// Kill from the caret to the end of its line (^K). Deletes the selection
3093    /// instead when one is active.
3094    ///
3095    /// At the end of the line it does nothing, rather than pulling the line
3096    /// below up into this one. Joining has no meaning to give it in both views
3097    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3098    /// there is nothing there to delete, while the newline a *source* line ends
3099    /// with is only half of the blank line that separates two paragraphs —
3100    /// deleting one leaves a soft break, which is not the join it looks like.
3101    /// The views agreeing is worth more than emacs' second press, and Delete is
3102    /// already the key that joins.
3103    pub fn delete_to_line_end(&mut self) {
3104        if let Some((s, e)) = self.selection() {
3105            self.splice(s, e, "", EditKind::Other);
3106            return;
3107        }
3108        let (_, end) = self.line_span();
3109        if end > self.caret {
3110            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3111            self.splice(s, e, "", EditKind::Delete);
3112        }
3113    }
3114
3115    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3116    ///
3117    /// A glyph-space range covers what the user can see, which for `**bold**` is
3118    /// the word and never the delimiters around it — so deleting the word on its
3119    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3120    /// word, and the styling was the word's; the two go together. Only the
3121    /// node's delimiters are taken, and those are hidden here anyway, so nothing
3122    /// visible outside the range is lost.
3123    ///
3124    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3125    /// the strong, and only then is the strong empty too.
3126    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3127        if self.view == View::Source {
3128            return (start, end);
3129        }
3130        let nodes = self.nodes();
3131        let (mut s, mut e) = (start, end);
3132        loop {
3133            let mut grew = false;
3134            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3135                let Some(text) = inline_content_span(n, &self.source) else {
3136                    continue;
3137                };
3138                // Some of its text survives, so the node still has a job.
3139                if text.start < s || text.end > e {
3140                    continue;
3141                }
3142                if n.span.start < s || n.span.end > e {
3143                    s = s.min(n.span.start);
3144                    e = e.max(n.span.end);
3145                    grew = true;
3146                }
3147            }
3148            if !grew {
3149                return (s, e);
3150            }
3151        }
3152    }
3153
3154    /// One splice of document text, keeping the **mark-edge rule**: an inline
3155    /// mark's content never begins or ends with whitespace. In Markdown and Djot
3156    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3157    /// is four literal asterisks around a word, and a rich view drawing the
3158    /// document faithfully has no choice but to show them. That is correct
3159    /// rendering of what the file says, and nobody typing a space after a bold
3160    /// word meant to say it.
3161    ///
3162    /// So the space goes *outside* the run instead — `**bold** ` — which is the
3163    /// same document to a reader and a live one to a parser. The caret follows it
3164    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3165    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3166    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3167    ///
3168    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3169    /// through here, so the rule holds however the whitespace arrives at the
3170    /// edge. The repair is decided *after* the plain edit, by asking whether the
3171    /// mark actually died: a code span's backticks aren't whitespace-sensitive
3172    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3173    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3174        let fix = self.mark_edge_fix(start, end, text);
3175        if !self.splice_exact(start, end, text, kind) {
3176            return false;
3177        }
3178        if let Some(fix) = fix {
3179            self.repair_mark_edges(fix);
3180        }
3181        if text.is_empty() && end > start {
3182            self.settle_inside_close_delims();
3183        }
3184        true
3185    }
3186
3187    /// After a delete, take a caret left standing past a run's closing delimiters
3188    /// back inside the run.
3189    ///
3190    /// A delete leaves the caret where the deleted bytes began, and when those
3191    /// bytes were the last thing after a marked phrase — the space the mark-edge
3192    /// rule pushed out of `**bold** `, say — that spot is the far side of the
3193    /// closing `**`. The rich view has nothing to draw there: the delimiters are
3194    /// hidden, so the caret shows at the end of the word either way, and the two
3195    /// offsets are one place on screen with two different meanings. Typing at the
3196    /// outer one lands past the run, so the writer who backspaced a space out of
3197    /// their bold phrase watches the next character come out plain, and the
3198    /// toolbar button go dark, with the caret never appearing to move.
3199    ///
3200    /// The end of the run's text is the caret's home there — a delete that took
3201    /// away everything after a phrase leaves the caret at the end of that phrase,
3202    /// which is inside it — so it settles onto that
3203    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3204    /// walk, through every mark closing at the point): the word stays bold, the
3205    /// button stays lit, and the next character carries on the phrase.
3206    ///
3207    /// Rich view only, and only where a mark really closes at the caret — mid-run
3208    /// or in plain prose no span ends there and the caret stays put. The opening
3209    /// edge is left alone on purpose: a caret in front of a run inherits from the
3210    /// text on its left, which is the plain text outside.
3211    fn settle_inside_close_delims(&mut self) {
3212        if self.view != View::Wysiwyg {
3213            return;
3214        }
3215        let at = self.step_inside_close_delims(self.caret);
3216        if at != self.caret {
3217            self.caret = at;
3218            self.clear_pending();
3219            self.record_caret();
3220        }
3221    }
3222
3223    /// The splice exactly as asked, with no mark-edge repair — for the callers
3224    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3225    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3226    /// the bytes they inserted.
3227    ///
3228    /// One `edit_range` through twig, then re-anchor the caret from the returned
3229    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3230    /// Markdown/Djot) leaves the document untouched and reports.
3231    ///
3232    /// Returns whether the edit landed — for a caller that has offsets of its
3233    /// own to place afterwards, which a rolled-back splice would leave pointing
3234    /// into text that never came to exist.
3235    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3236        // The read-only gate, for every edit at once — see the field.
3237        if self.read_only {
3238            return false;
3239        }
3240        // twig records an undo step for every edit; when this one continues a
3241        // run of the same kind (typing, deleting), tell twig to fold it into the
3242        // step before it so the whole run undoes at once.
3243        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3244        // Hand twig the pre-edit caret before the splice, so the undo step it
3245        // retires carries where the caret was standing.
3246        self.record_caret();
3247        match self.editor.edit_range(start, end, text) {
3248            Ok(change) => {
3249                if coalesce {
3250                    let _ = self.editor.coalesce_last_undo();
3251                }
3252                self.last_edit_kind = Some(kind);
3253                self.refresh();
3254                self.caret = change.new.end;
3255                self.anchor = None;
3256                self.goal_col = None;
3257                self.clear_pending();
3258                self.dirty = self.source != self.clean_source;
3259                self.status = None;
3260                // And the post-edit caret, so a later redo restores it.
3261                self.record_caret();
3262                true
3263            }
3264            // The edit was rolled back, so twig's history did not move and
3265            // neither may ours: pushing here would leave a step with no edit
3266            // under it and shift every later undo onto the wrong caret.
3267            Err(e) => {
3268                self.status = Some(format!("edit: {e}"));
3269                false
3270            }
3271        }
3272    }
3273
3274    /// The re-spelling that would keep the mark-edge rule for the edit
3275    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3276    /// against a delimiter and the plain splice is already right. Computed
3277    /// *before* the edit, while the run's spans and delimiters can still be read
3278    /// off the document; applied afterwards, and only if the mark really died —
3279    /// see [`repair_mark_edges`](Self::repair_mark_edges).
3280    ///
3281    /// Rich view only. Source view is for typing raw markup, where a space put
3282    /// against a `**` is exactly the character it looks like.
3283    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3284        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3285            return None;
3286        }
3287        // Every inline mark standing over the edit, outermost first, with the
3288        // content span that says where its delimiters are.
3289        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3290            .editor
3291            .ancestors_at(start)
3292            .unwrap_or_default()
3293            .into_iter()
3294            .filter_map(|m| {
3295                let kind = inline_kind(&m.kind)?;
3296                let content = m.content_span.clone()?;
3297                Some((kind, m.span.clone(), content))
3298            })
3299            .collect();
3300        // The innermost run whose *content* holds the whole edit: the one whose
3301        // text is being changed, rather than one the edit merely sits under.
3302        let (kind, span, content) = chain
3303            .iter()
3304            .rev()
3305            .find(|(_, _, c)| c.start <= start && end <= c.end)?
3306            .clone();
3307        // What that content becomes. Whitespace at either end of it is what
3308        // would put out the mark.
3309        let body = format!(
3310            "{}{text}{}",
3311            &self.source[content.start..start],
3312            &self.source[end..content.end]
3313        );
3314        let (lead, trail) = if body.trim().is_empty() {
3315            // Nothing but whitespace left: there is no content to mark at all,
3316            // and the delimiters go with it rather than closing on a space.
3317            (body.len(), 0)
3318        } else {
3319            (
3320                body.len() - body.trim_start().len(),
3321                body.len() - body.trim_end().len(),
3322            )
3323        };
3324        // Nothing against a delimiter, and something still between them: the
3325        // plain edit stands. An emptied run is broken just as surely (`**b**`
3326        // with the `b` deleted is the literal `****`) and is re-spelt as the
3327        // nothing it now says.
3328        if lead == 0 && trail == 0 && !body.is_empty() {
3329            return None;
3330        }
3331        // Marks that open or close exactly where this one does — `***both***` is
3332        // two runs sharing an edge — spell their delimiters as one run of bytes,
3333        // so the whitespace has to clear all of them together.
3334        let (mut open_at, mut close_at) = (span.start, span.end);
3335        for _ in 0..chain.len() {
3336            match chain.iter().find(|(_, _, c)| c.start == open_at) {
3337                Some((_, s, _)) => open_at = s.start,
3338                None => break,
3339            }
3340        }
3341        for _ in 0..chain.len() {
3342            match chain.iter().find(|(_, _, c)| c.end == close_at) {
3343                Some((_, s, _)) => close_at = s.end,
3344                None => break,
3345            }
3346        }
3347        let open = &self.source[open_at..content.start];
3348        let close = &self.source[content.end..close_at];
3349        let core = &body[lead..body.len() - trail];
3350        let respelt = if core.is_empty() {
3351            body.clone()
3352        } else {
3353            format!(
3354                "{}{open}{core}{close}{}",
3355                &body[..lead],
3356                &body[body.len() - trail..]
3357            )
3358        };
3359        // The caret sits just past the inserted text within the new content —
3360        // which, when that lands in the whitespace, is now outside the delimiters.
3361        let pos = (start - content.start) + text.len();
3362        let caret = if core.is_empty() || pos <= lead {
3363            open_at + pos
3364        } else if pos >= lead + core.len() {
3365            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3366        } else {
3367            open_at + lead + open.len() + (pos - lead)
3368        };
3369        Some(MarkEdgeFix {
3370            kind,
3371            probe: content.start,
3372            start: open_at,
3373            end: close_at + text.len() - (end - start),
3374            text: respelt,
3375            caret,
3376            // The marks in force here, resolved against any armed sticky delta —
3377            // what the writer is typing in, and so what has to still be true on
3378            // the far side of the delimiter the caret just stepped over.
3379            want: chain
3380                .iter()
3381                .filter(|(_, s, _)| start < s.end)
3382                .map(|(k, _, _)| *k)
3383                .collect::<InlineMarks>()
3384                .xor(self.pending_here()),
3385        })
3386    }
3387
3388    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3389    /// did break the mark. Whether whitespace at a delimiter is fatal is the
3390    /// format's business, not leaf's: `**bold **` is no longer strong, while
3391    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3392    /// don't care either. Asking the parser afterwards settles it for every kind
3393    /// and format at once, and costs a re-spelling only where one is due.
3394    ///
3395    /// The repair rides along with the edit that caused it — one undo step puts
3396    /// back what the writer typed, not a delimiter shuffle they never saw.
3397    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3398        if fix.end > self.source.len() {
3399            return;
3400        }
3401        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3402            return; // still a mark: these delimiters don't mind the whitespace
3403        }
3404        let resumed = self.last_edit_kind;
3405        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3406            return;
3407        }
3408        let _ = self.editor.coalesce_last_undo();
3409        // The keystroke owns the undo step, so the run of typing it belongs to
3410        // keeps coalescing over the repair rather than breaking in two here.
3411        self.last_edit_kind = resumed;
3412        self.caret = fix.caret.min(self.source.len());
3413        self.anchor = None;
3414        self.goal_col = None;
3415        self.rearm(fix.want);
3416        self.clamp_caret();
3417        self.record_caret();
3418    }
3419
3420    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3421    /// writer is typing in, carried across an edit that moved the caret out of
3422    /// the run holding them. Arms nothing when the caret already stands in
3423    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3424    /// a clean delta here (see [`toggle`](Self::toggle)).
3425    fn rearm(&mut self, want: InlineMarks) {
3426        let here: InlineMarks = self
3427            .marks_at(self.caret)
3428            .into_iter()
3429            .map(|(k, _)| k)
3430            .collect();
3431        self.pending_marks = want.xor(here);
3432        self.pending_at = Some(self.caret);
3433    }
3434
3435    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3436    /// which backslash-escapes any character that would otherwise open markup in
3437    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3438    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3439    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3440    /// a collapsed point — a selection is deleted by the caller first, since
3441    /// `insert_literal` inserts rather than replaces.
3442    fn insert_literal_at(
3443        &mut self,
3444        at: usize,
3445        text: &str,
3446        kind: EditKind,
3447        force_coalesce: bool,
3448    ) -> bool {
3449        // The read-only gate: this door goes to twig directly, not through
3450        // `splice_exact`, so it guards itself — see the field.
3451        if self.read_only {
3452            return false;
3453        }
3454        // `force_coalesce` folds this into the immediately preceding edit (the
3455        // selection-delete of an overwrite) so the pair is one undo step; else it
3456        // coalesces only when it continues a run of the same-kind typing.
3457        let coalesce =
3458            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3459        // The mark-edge rule holds for typed text however it is spelled — see
3460        // `splice`. Only an insert twig passed through unchanged can use it,
3461        // since a fix is measured in the bytes that actually land, and an escape
3462        // adds bytes this couldn't have counted.
3463        let fix = self.mark_edge_fix(at, at, text);
3464        self.record_caret();
3465        match self.editor.insert_literal(at, text) {
3466            Ok(change) => {
3467                if coalesce {
3468                    let _ = self.editor.coalesce_last_undo();
3469                }
3470                self.last_edit_kind = Some(kind);
3471                self.refresh();
3472                self.caret = change.new.end;
3473                self.anchor = None;
3474                self.goal_col = None;
3475                self.clear_pending();
3476                self.dirty = self.source != self.clean_source;
3477                self.status = None;
3478                self.record_caret();
3479                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3480                    self.repair_mark_edges(fix);
3481                }
3482                true
3483            }
3484            Err(e) => {
3485                self.status = Some(format!("edit: {e}"));
3486                false
3487            }
3488        }
3489    }
3490
3491    /// After a structural list edit (a new item, a nest/unnest), renumber the
3492    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3493    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3494    /// renumber as its own edit; fold it into the edit that triggered it so the
3495    /// two undo as one, and only when it actually changed the source (a no-op or
3496    /// a caret outside any ordered list must not coalesce the real edit into the
3497    /// step before it).
3498    fn renumber_here(&mut self) {
3499        self.renumber_at(self.caret);
3500    }
3501
3502    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3503    /// caret — for an edit that leaves the caret one past the item it just wrote,
3504    /// where twig resolves no list to renumber.
3505    fn renumber_at(&mut self, off: usize) {
3506        // The read-only gate — this door reaches twig without the splice.
3507        if self.read_only {
3508            return;
3509        }
3510        let before = self.source.clone();
3511        if self.editor.renumber_ordered_lists(off).is_err() {
3512            return; // not inside an ordered list — nothing to renumber
3513        }
3514        self.refresh();
3515        if self.source != before {
3516            let _ = self.editor.coalesce_last_undo();
3517            self.dirty = self.source != self.clean_source;
3518            self.clamp_caret();
3519            self.record_caret();
3520        }
3521    }
3522
3523    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3524    /// sub-item written directly beneath a text line reparses that text as a
3525    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
3526    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3527    /// bullets can't underline anything, so swap the dash for a `*`: the item
3528    /// stays an empty nested bullet, the parent stays prose, and the source
3529    /// round-trips instead of hiding a heading the user never asked for. Folded
3530    /// into the triggering edit's undo step, the way renumbering is.
3531    ///
3532    /// Gated on the collapse having actually happened (the swapped dash was
3533    /// swallowed into a `heading`), so a real setext heading the author wrote —
3534    /// or a `- x` with content, which can't underline anything — is never
3535    /// touched. This has to live in the *edit*, not the renderer: leaving the
3536    /// hazardous bytes on disk and only painting over them would ship a file
3537    /// every other CommonMark tool reads as a heading.
3538    ///
3539    /// This one keeps its own byte scan, and has to: the hazard is precisely
3540    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3541    /// which asks twig which lines open an item — reports nothing here. There is
3542    /// no node to ask about. It is also the last Markdown spelling leaf writes on
3543    /// purpose rather than for want of an answer; once twig spells continuations
3544    /// itself, avoiding the trap becomes twig's, and this goes.
3545    ///
3546    /// [`list_marker_on_line`]: Self::list_marker_on_line
3547    fn avoid_setext_collapse(&mut self) {
3548        let caret = self.caret.min(self.source.len());
3549        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3550        let bytes = self.source.as_bytes();
3551        let mut dash = line_start;
3552        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3553            dash += 1;
3554        }
3555        // A dash bullet is the only marker that doubles as a setext underline.
3556        if bytes.get(dash) != Some(&b'-') {
3557            return;
3558        }
3559        // Only an *empty* item is a bare underline; `- x` carries content and
3560        // can't fold the line above into a heading.
3561        let line_end = self.source[dash..]
3562            .find('\n')
3563            .map_or(self.source.len(), |i| dash + i);
3564        if !self.source[dash + 1..line_end].trim().is_empty() {
3565            return;
3566        }
3567        // The tell: that dash was swallowed into a `heading`. A properly nested
3568        // empty item sits under a `list_item`, with no heading in reach. Probe
3569        // the dash byte itself (well inside the heading), not the caret, whose
3570        // end-of-line offset can fall on the half-open span boundary.
3571        let collapsed = self
3572            .editor
3573            .ancestors_at(dash)
3574            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3575            .unwrap_or(false);
3576        if !collapsed {
3577            return;
3578        }
3579        let caret = self.caret;
3580        if self.splice(dash, dash + 1, "*", EditKind::Other) {
3581            // Same width, so the caret keeps its column; fold into the edit that
3582            // triggered this so Tab stays one undo step.
3583            let _ = self.editor.coalesce_last_undo();
3584            self.caret = caret.min(self.source.len());
3585            self.clamp_caret();
3586            self.record_caret();
3587        }
3588    }
3589
3590    fn snapshot(&self) -> CaretState {
3591        CaretState {
3592            caret: self.caret,
3593            anchor: self.anchor,
3594        }
3595    }
3596
3597    /// Hand twig the current caret and selection as the blob for the live
3598    /// document state. Called before an edit — so the step twig retires records
3599    /// where the caret was, and undo can restore it — and again once the op has
3600    /// placed the caret, so redo restores where the edit left it.
3601    ///
3602    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3603    /// caret through its own history, so coalescing falls out for free (folding
3604    /// two twig steps into one drops the intermediate blob, keeping the run's
3605    /// first) and the parallel stacks that had to march in lockstep — and could
3606    /// silently drift out of it — are gone.
3607    fn record_caret(&mut self) {
3608        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3609    }
3610
3611    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3612    /// the toggled region selected so a second press cleanly reverses it.
3613    pub fn toggle(&mut self, kind: InlineKind) {
3614        // The read-only gate — this door reaches twig without the splice.
3615        if self.read_only {
3616            return;
3617        }
3618        // Ahead of the no-selection branch below: arming a mark for text not yet
3619        // typed is a promise `insert` cannot keep in a format with no delimiters
3620        // to spell it with. Per *kind*, not per format — Markdown spells five
3621        // of the eight marks (highlight among them, under the `highlight`
3622        // extension leaf parses with), djot all eight, HTML seven.
3623        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3624            return;
3625        }
3626        let Some((s, e)) = self.selection() else {
3627            // No selection: arm the mark for the next text typed here, the way a
3628            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3629            // in the flow of typing without ever selecting anything — the delta
3630            // is realised onto the freshly typed text by `insert`. A fresh caret
3631            // position starts the delta over from the marks actually in force.
3632            if self.pending_at != Some(self.caret) {
3633                self.pending_marks = InlineMarks::empty();
3634                self.pending_at = Some(self.caret);
3635            }
3636            self.pending_marks.flip(kind);
3637            self.status = None;
3638            return;
3639        };
3640        // Whitespace at the edge of a selection is not part of what was chosen —
3641        // a double-click takes the space after the word with it — and a mark
3642        // cannot close against one anyway: `**word **` is four literal asterisks
3643        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3644        let picked = &self.source[s..e];
3645        let (s, e) = (
3646            s + (picked.len() - picked.trim_start().len()),
3647            e - (picked.len() - picked.trim_end().len()),
3648        );
3649        if s >= e {
3650            self.status = Some(format!("{kind:?}: nothing selected to mark"));
3651            return;
3652        }
3653        // Styling a selection is a one-shot act, not a sticky mode.
3654        self.clear_pending();
3655        self.record_caret();
3656        match self.editor.toggle_inline(s, e, kind) {
3657            Ok(change) => {
3658                self.last_edit_kind = None; // structural edit is its own undo step
3659                self.refresh();
3660                self.anchor = Some(change.new.start);
3661                self.caret = change.new.end;
3662                self.dirty = self.source != self.clean_source;
3663                self.status = None;
3664                self.record_caret();
3665            }
3666            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3667        }
3668    }
3669
3670    /// Whether the caret stands in a highlight — what a frontend asks to enable
3671    /// or disable its highlight-colour controls, the way
3672    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
3673    ///
3674    /// A fact about the *caret*, and the other half of
3675    /// [`Capabilities::mark_color`], which is the fact about the format. A
3676    /// frontend needs both: djot spells a highlight and no colour for it, so a
3677    /// caret standing in `{=word=}` answers `true` here and still has no palette
3678    /// to offer.
3679    ///
3680    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
3681    /// the palette appears exactly where the Highlight button is lit — with one
3682    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
3683    /// into lights the button and answers `false` here, because there is no node
3684    /// to colour until the text exists.
3685    pub fn caret_in_mark(&mut self) -> bool {
3686        self.mark_offset().is_some()
3687    }
3688
3689    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
3690    /// standing in the highlight the gesture means — or `None` when neither end
3691    /// of what is selected is in one.
3692    ///
3693    /// The caret first, and the selection's *start* after it, because of what
3694    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
3695    /// whole, with the caret at its far edge, one past the closing `==` and so
3696    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
3697    /// and colour it — the two presses a coloured highlight is made of — would
3698    /// otherwise refuse on the second, having just written the highlight the
3699    /// author is pointing at.
3700    fn mark_offset(&mut self) -> Option<usize> {
3701        let in_mark = |d: &mut Self, off: usize| {
3702            d.marks_at(off)
3703                .into_iter()
3704                .any(|(k, _)| k == InlineKind::Mark)
3705                .then_some(off)
3706        };
3707        let caret = self.caret.min(self.source.len());
3708        in_mark(self, caret).or_else(|| {
3709            let start = self.selection()?.0;
3710            in_mark(self, start)
3711        })
3712    }
3713
3714    /// The colour of the highlight at the caret — `None` both when the caret is
3715    /// in no highlight and when the highlight it is in names no colour, which
3716    /// are the same answer to "which swatch is lit".
3717    ///
3718    /// The innermost mark, by span, for the same reason
3719    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
3720    /// what the caret is *in* is the deepest node containing it. A `data-color`
3721    /// naming a colour this build has no variant for reads as `None` — the
3722    /// renderer already draws that as a plain highlight rather than guessing,
3723    /// and the toolbar agrees with the renderer.
3724    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
3725        let at = self.mark_offset()?;
3726        self.mark_color_at(at)
3727    }
3728
3729    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
3730    /// the innermost `mark` covering it, and the colour it names.
3731    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
3732        self.nodes()
3733            .into_iter()
3734            .filter(|n| n.kind == Kind::Mark)
3735            .filter(|n| n.span.start <= off && off < n.span.end)
3736            .min_by_key(|n| n.span.end - n.span.start)
3737            .and_then(|n| MarkColor::from_attrs(&n.attrs))
3738    }
3739
3740    /// Colour the highlight at the caret, or clear its colour with `None` — the
3741    /// palette behind a toolbar's Highlight button.
3742    ///
3743    /// Markdown only, and the one gesture whose availability is a fact about the
3744    /// *parse extensions* rather than about the format alone: the colour is
3745    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
3746    /// records as the mark's `data-color`, and only an editor parsing with
3747    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
3748    /// document) reads it back that way. Djot spells the highlight and no colour
3749    /// for it, so this refuses there — see [`Capabilities::mark_color`].
3750    ///
3751    /// **A colour is a property of a highlight that already exists.** There is
3752    /// no "highlight this in red" here, because that is two splices and would be
3753    /// two undo steps under one press; a frontend that wants it calls
3754    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
3755    /// order the two buttons already sit in. With no highlight at the caret this
3756    /// says so in the status line and writes nothing.
3757    ///
3758    /// The caret keeps its place in the *text*: the splice is entirely in the
3759    /// prefix between the opening `==` and the first word, so an offset past it
3760    /// rides the emoji's width, and one standing on the prefix itself lands
3761    /// where the prefix now ends.
3762    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
3763        // The read-only gate — this door reaches twig without the splice.
3764        if self.read_only {
3765            return;
3766        }
3767        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
3768            return;
3769        }
3770        let Some(at) = self.mark_offset() else {
3771            self.status = Some("highlight colour: no highlight at the caret".into());
3772            return;
3773        };
3774        // Clearing a colour a highlight hasn't got is twig's one *successful*
3775        // no-op, and the `Change` it hands back then describes whatever edit came
3776        // before it — a stale span that would drag the caret somewhere it never
3777        // was. Answer it here, where the question is cheap, rather than trusting
3778        // a change that isn't one.
3779        if color.is_none() && self.mark_color_at(at).is_none() {
3780            self.status = None;
3781            return;
3782        }
3783        self.record_caret();
3784        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
3785            Ok(change) => {
3786                // Re-anchored from the offsets as they were, *before* `refresh`
3787                // sees the new bytes: the caret it clamps is one standing inside
3788                // a prefix that didn't exist a moment ago, and walking it back to
3789                // a char boundary of the emoji loses the place this is restoring.
3790                let caret = reanchor(self.caret, &change);
3791                let anchor = self.anchor.map(|a| reanchor(a, &change));
3792                self.last_edit_kind = None; // structural edit is its own undo step
3793                self.refresh();
3794                self.caret = caret;
3795                self.anchor = anchor;
3796                self.dirty = self.source != self.clean_source;
3797                self.status = None;
3798                self.clamp_caret();
3799                self.record_caret();
3800            }
3801            Err(e) => self.status = Some(format!("highlight colour: {e}")),
3802        }
3803    }
3804
3805    /// One press of a colour swatch: colour the highlight at the caret, or —
3806    /// over a selection that isn't highlighted yet — highlight it and colour it,
3807    /// as **one** undo step.
3808    ///
3809    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
3810    /// one splice; this is the compound every toolbar actually presses, and it
3811    /// lives here rather than in each frontend because the rule it encodes —
3812    /// what a swatch means when there is no highlight under it yet — is one
3813    /// answer, not one per frontend. The two splices are folded into a single
3814    /// history step, so the press that made a red highlight is taken back by a
3815    /// single undo rather than leaving an uncoloured one behind.
3816    ///
3817    /// `None` clears the colour, and over an unhighlighted selection means
3818    /// simply "highlight this" — the same thing the Highlight button does.
3819    /// A bare caret in no highlight is left alone with a status line, because
3820    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
3821    /// colour cannot be armed with it.
3822    pub fn highlight(&mut self, color: Option<MarkColor>) {
3823        if self.caret_in_mark() || self.selection().is_none() {
3824            self.set_mark_color(color);
3825            return;
3826        }
3827        self.toggle(InlineKind::Mark);
3828        // The format may not spell a highlight at all (`toggle` said so), and
3829        // there is nothing to colour if it doesn't.
3830        if self.status.is_some() {
3831            return;
3832        }
3833        let before = self.revision;
3834        self.set_mark_color(color);
3835        // Only fold when the colour really spliced. `highlight(None)` over a
3836        // fresh highlight is a no-op by design, and coalescing there would eat
3837        // the *previous* edit into the toggle instead.
3838        if self.revision != before {
3839            let _ = self.editor.coalesce_last_undo();
3840        }
3841    }
3842
3843    /// Convert the block at the caret to a heading level or paragraph.
3844    pub fn set_block(&mut self, kind: BlockKind) {
3845        // The read-only gate — this door reaches twig without the splice.
3846        if self.read_only {
3847            return;
3848        }
3849        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3850            return;
3851        }
3852        self.record_caret();
3853        // A blank line has no node to convert, and twig opens a block there
3854        // rather than declining — so the caret's own offset is the right thing
3855        // to hand it when `block_offset_for_caret` finds nothing.
3856        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3857        match self.editor.set_block(offset, kind) {
3858            Ok(change) => {
3859                self.last_edit_kind = None;
3860                self.refresh();
3861                // Opening a block on a blank line writes a marker the caret
3862                // belongs *after*; converting an existing one moves nothing.
3863                self.caret = self.caret.max(change.new.end);
3864                self.clamp_caret();
3865                self.anchor = None;
3866                self.dirty = self.source != self.clean_source;
3867                self.status = None;
3868                self.record_caret();
3869            }
3870            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3871        }
3872    }
3873
3874    /// Whether `off` is inside a text block (paragraph, heading, code block…).
3875    fn has_block_at(&mut self, off: usize) -> bool {
3876        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3877            chain
3878                .iter()
3879                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3880        })
3881    }
3882
3883    /// The offset to hand twig's `set_block`: the caret when it is already inside
3884    /// a block, otherwise nudged onto the previous character (a caret at a line
3885    /// end sits at the doc level, outside the block). `None` when the caret is on
3886    /// a blank line — a new paragraph with no block node to convert.
3887    fn block_offset_for_caret(&mut self) -> Option<usize> {
3888        let caret = self.caret.min(self.source.len());
3889        if self.has_block_at(caret) {
3890            return Some(caret);
3891        }
3892        // Nudge to the previous character — but never across a newline: that would
3893        // target the previous block, and a blank line genuinely has no block.
3894        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3895            && ch != '\n'
3896            && self.has_block_at(i)
3897        {
3898            return Some(i);
3899        }
3900        None
3901    }
3902
3903    /// The heading level of the text block at the caret, or `None` when that
3904    /// block is not a heading.
3905    pub fn current_heading_level(&mut self) -> Option<u32> {
3906        let caret = self.caret;
3907        self.nodes()
3908            .into_iter()
3909            .filter(|n| n.kind == Kind::Heading)
3910            .find(|n| n.span.start <= caret && caret <= n.span.end)
3911            .and_then(|n| n.level)
3912    }
3913
3914    /// The inline marks in force at the caret (or over the selection) — what a
3915    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3916    /// inline counterpart. Cheap enough to call every frame: one twig
3917    /// `ancestors_at` query per caret (two with a selection), each walking root
3918    /// → deepest node at one offset. It never snapshots the tree the way
3919    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3920    /// the only allocation is twig's own small ancestor `Vec`.
3921    ///
3922    /// **A selection reports a mark only when the mark covers *all* of it.**
3923    /// That's what every real toolbar means by an active button — Bold lit over
3924    /// a half-bold selection would claim a press turns bold *off*, when
3925    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3926    /// Whole-coverage is asked as "is the same mark node standing over both the
3927    /// first and the last character?": inline nodes are contiguous, so one node
3928    /// covering both ends covers every byte between them. Two touching runs
3929    /// (`**a****b**`) are two nodes, and correctly light nothing.
3930    ///
3931    /// At a bare caret a mark is active when the caret stands inside the mark's
3932    /// span — `span.start <= caret < span.end`, delimiters included, which is
3933    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3934    /// opening `*` (2) through the last byte of the closing `**` (9) are all
3935    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3936    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3937    /// typing would actually land inside the marked run. The offset one past the
3938    /// mark (10) is the text after it and reports nothing, at the end of the
3939    /// buffer exactly as in the middle.
3940    pub fn active_inline_marks(&mut self) -> InlineMarks {
3941        let Some((start, end)) = self.selection() else {
3942            // The marks actually in force at the caret, flipped by any armed
3943            // sticky delta — so `⌘b` at a bare caret lights the Bold button
3944            // immediately, before a single character is typed.
3945            let base: InlineMarks = self
3946                .marks_at(self.caret)
3947                .into_iter()
3948                .map(|(k, _)| k)
3949                .collect();
3950            return base.xor(self.pending_here());
3951        };
3952        // The selection's *last character*, not its exclusive end: `end` is the
3953        // offset one past the selection, which for a selection ending exactly at
3954        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3955        // entirely bold, but offset 10 is the space after).
3956        let last = prev_boundary(&self.source, end);
3957        let head = self.marks_at(start);
3958        let tail = self.marks_at(last);
3959        head.into_iter()
3960            .filter(|m| tail.contains(m))
3961            .map(|(k, _)| k)
3962            .collect()
3963    }
3964
3965    /// The inline marks whose span covers `off`, each with the id of the node
3966    /// carrying it — the id is what lets a selection tell one mark node from
3967    /// another of the same kind.
3968    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3969        let off = off.min(self.source.len());
3970        self.editor
3971            .ancestors_at(off)
3972            .unwrap_or_default()
3973            .into_iter()
3974            // `span.end` is the offset one *past* the mark, so it isn't in it.
3975            // twig already resolves a boundary to whatever starts there — in
3976            // `**bold** x` offset 8 is the following text, not the strong — but
3977            // when nothing follows, the tie has nobody to break for and the
3978            // chain still ends at the mark. That would make the answer at the
3979            // last offset of the document depend on whether the file happens to
3980            // end in a newline; the rule is `span.start <= off < span.end`, and
3981            // it's the same rule at the end of a buffer as in the middle.
3982            .filter(|m| off < m.span.end)
3983            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3984            .collect()
3985    }
3986
3987    /// Toggle a heading at the caret: if the block is already this heading level,
3988    /// revert it to a paragraph; otherwise convert it to this heading level.
3989    /// This gives the heading commands the same toggle feel as bold/italic/code —
3990    /// re-applying a heading a line already has turns it back into body text.
3991    pub fn toggle_heading(&mut self, level: u32) {
3992        if self.current_heading_level() == Some(level) {
3993            self.set_block(BlockKind::Paragraph);
3994        } else {
3995            self.set_block(BlockKind::Heading(level));
3996        }
3997    }
3998
3999    /// Toggle a block quote around the selection, or around the block at the
4000    /// caret — the toolbar's Quote button.
4001    pub fn toggle_blockquote(&mut self) {
4002        self.toggle_container(BlockContainerKind::BlockQuote);
4003    }
4004
4005    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
4006    /// over the block at the caret — one op with the kind as a flag, the way
4007    /// `toggle_heading` takes its level, so a frontend needs no twig type to
4008    /// name the two buttons.
4009    ///
4010    /// Pressing the *other* list's button while in a list converts in place
4011    /// rather than nesting, so the pair reads as one three-state control
4012    /// (bulleted / numbered / neither) rather than two independent wrappers.
4013    pub fn toggle_list(&mut self, ordered: bool) {
4014        self.toggle_container(if ordered {
4015            BlockContainerKind::OrderedList
4016        } else {
4017            BlockContainerKind::BulletList
4018        });
4019    }
4020
4021    // ── Task list items ──────────────────────────────────────────────────────
4022    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
4023    // inline content of the item's first paragraph rather than part of its
4024    // marker, so adding or removing one must leave the item's continuation
4025    // indentation alone, and an item inside a quote is found past the quote
4026    // markers. leaf names the gesture and the offset; the spelling is twig's.
4027
4028    /// Whether the list item at the caret carries a checkbox, and which way it
4029    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
4030    /// item or no item at all. What a toolbar reads to light its checkbox button.
4031    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
4032        self.task_checked_at(self.caret)
4033    }
4034
4035    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
4036    /// offset — what a frontend asks before deciding a click landed on a box.
4037    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
4038        self.innermost_list_item(offset.min(self.source.len()))?
4039            .checked
4040    }
4041
4042    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
4043    /// A no-op with a reported reason when the caret is in no task item — minting
4044    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
4045    pub fn toggle_task_checked(&mut self) {
4046        self.toggle_task_at(self.caret);
4047    }
4048
4049    /// Tick or untick the task item covering `offset` — what a *click* on a
4050    /// rendered checkbox is. Separate from the caret form because a click carries
4051    /// its own offset and must not first move the caret there: ticking a box
4052    /// three paragraphs away should not take the cursor with it.
4053    pub fn toggle_task_at(&mut self, offset: usize) {
4054        // The read-only gate — this door reaches twig without the splice.
4055        if self.read_only {
4056            return;
4057        }
4058        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
4059            return;
4060        }
4061        let offset = offset.min(self.source.len());
4062        self.record_caret();
4063        match self.editor.toggle_task_checked(offset) {
4064            Ok(_) => self.after_task_edit(),
4065            Err(e) => self.status = Some(format!("task: {e}")),
4066        }
4067    }
4068
4069    /// Give the list item at the caret a checkbox, or take its checkbox away —
4070    /// the gesture that converts between a plain bullet and a task. A new box
4071    /// arrives unticked.
4072    pub fn toggle_task_item(&mut self) {
4073        // The read-only gate — this door reaches twig without the splice.
4074        if self.read_only {
4075            return;
4076        }
4077        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
4078            return;
4079        }
4080        let caret = self.caret.min(self.source.len());
4081        self.record_caret();
4082        match self.editor.toggle_task_item(caret) {
4083            Ok(_) => self.after_task_edit(),
4084            Err(e) => self.status = Some(format!("task: {e}")),
4085        }
4086    }
4087
4088    /// Settle after a task gesture. The caret rides its old byte offset and is
4089    /// clamped back in: a box is three or four bytes on the item's first line, so
4090    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
4091    /// real stop either way.
4092    fn after_task_edit(&mut self) {
4093        self.last_edit_kind = None;
4094        self.refresh();
4095        self.anchor = None;
4096        self.dirty = self.source != self.clean_source;
4097        self.status = None;
4098        self.clamp_caret();
4099        self.record_caret();
4100    }
4101
4102    // ── Tables ───────────────────────────────────────────────────────────────
4103    // A table is a grid, and twig edits it as one — add/remove/move a row or
4104    // column, set a column's alignment — re-spelling the whole table in a single
4105    // splice. Every gesture is anchored at the caret's cell. leaf just names the
4106    // gesture and re-reads the result; the whole table's numbering, borders, and
4107    // delimiter are twig's to keep straight.
4108
4109    /// Whether the caret is inside a table — what a frontend asks to enable or
4110    /// disable its table controls.
4111    ///
4112    /// An HTML `<table>` still answers `true`: the caret really is in a table,
4113    /// and the reason the grid controls stay dark there is
4114    /// [`Capabilities::table`], which is a fact about the document's format
4115    /// rather than about the caret. A frontend needs both.
4116    pub fn caret_in_table(&mut self) -> bool {
4117        let caret = self.caret.min(self.source.len());
4118        self.editor
4119            .ancestors_at(caret)
4120            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
4121            .unwrap_or(false)
4122    }
4123
4124    /// One grid op, guarded and settled — the shared body of the seven below.
4125    ///
4126    /// The guard is why this exists rather than seven copies of the same three
4127    /// lines, and it is the one guard leaf cannot delegate to twig. The table
4128    /// editor is the gesture family that consults no `Syntax` table (it spells a
4129    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
4130    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
4131    /// grid as a *pipe table* and reports success, swapping the element out for
4132    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
4133    /// downstream could tell that from a successful edit — the splice is real,
4134    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
4135    /// stopping at the door rather than detecting after the fact. See
4136    /// [`spells_pipe_tables`].
4137    fn table_op(
4138        &mut self,
4139        what: &str,
4140        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
4141    ) {
4142        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
4143            return;
4144        }
4145        self.record_caret();
4146        let at = self.caret;
4147        let r = op(&mut self.editor, at);
4148        self.apply_table(r, what);
4149    }
4150
4151    /// Insert an empty row below (`below`) or above the caret's row.
4152    pub fn table_insert_row(&mut self, below: bool) {
4153        self.table_op("table row", |e, at| e.table_insert_row(at, below));
4154    }
4155
4156    /// Delete the caret's row (not the header, not the last body row).
4157    pub fn table_delete_row(&mut self) {
4158        self.table_op("table row", |e, at| e.table_delete_row(at));
4159    }
4160
4161    /// Insert an empty column right (`right`) or left of the caret's column.
4162    pub fn table_insert_column(&mut self, right: bool) {
4163        self.table_op("table column", |e, at| e.table_insert_column(at, right));
4164    }
4165
4166    /// Delete the caret's column (unless it is the only one).
4167    pub fn table_delete_column(&mut self) {
4168        self.table_op("table column", |e, at| e.table_delete_column(at));
4169    }
4170
4171    /// Set the caret's column to `alignment`.
4172    pub fn table_set_alignment(&mut self, alignment: Alignment) {
4173        self.table_op("table alignment", |e, at| {
4174            e.table_set_alignment(at, alignment)
4175        });
4176    }
4177
4178    /// Move the caret's row one place down (`down`) or up, within the body rows.
4179    pub fn table_move_row(&mut self, down: bool) {
4180        self.table_op("table row", |e, at| e.table_move_row(at, down));
4181    }
4182
4183    /// Move the caret's column one place right (`right`) or left.
4184    pub fn table_move_column(&mut self, right: bool) {
4185        self.table_op("table column", |e, at| e.table_move_column(at, right));
4186    }
4187
4188    /// Settle the caret and document flags after a table op (or report its
4189    /// error). twig re-spells the whole table, so the caret rides its old byte
4190    /// offset and is clamped back into the rebuilt bytes — near enough to where
4191    /// it was, since the op preserves the cells' content and order around it.
4192    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
4193        match result {
4194            Ok(()) => {
4195                self.last_edit_kind = None;
4196                self.refresh();
4197                self.anchor = None;
4198                self.clamp_caret();
4199                self.dirty = self.source != self.clean_source;
4200                self.status = None;
4201                self.record_caret();
4202            }
4203            Err(e) => self.status = Some(format!("{what}: {e}")),
4204        }
4205    }
4206
4207    /// One `toggle_block_container` over the block-level target.
4208    ///
4209    /// leaf says *where*; twig decides everything else — which blocks the range
4210    /// covers, whether that means wrapping, unwrapping, nesting or converting,
4211    /// and how this document's format spells the prefix. The rule that a
4212    /// container only comes off when the range covers every block it holds is
4213    /// what the re-anchoring below is built around.
4214    fn toggle_container(&mut self, kind: BlockContainerKind) {
4215        // The read-only gate — this door reaches twig without the splice.
4216        if self.read_only {
4217            return;
4218        }
4219        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
4220            return;
4221        }
4222        let selected = self.selection();
4223        // A blank line holds no block, and twig opens an *empty* container on one
4224        // — since 3.2.0; it used to decline the range with `NotFound`, which is
4225        // why this used to lend it a scratch paragraph to wrap. Worth knowing
4226        // here because the line-for-line caret mapping below cannot describe it:
4227        // opening one under a paragraph writes the blank line the format needs
4228        // above the marker too, so the rewritten region has a line the old one
4229        // didn't, and "the same line, the same distance from its end" lands on
4230        // that new blank instead of in the container.
4231        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
4232        // Without a selection the target is the caret's own block, resolved the
4233        // way `set_block` resolves it — a caret at a line end sits at the doc
4234        // level and has to be nudged back onto the block it looks like it's in.
4235        // An empty range is enough: twig widens to the whole lines it touches.
4236        let (start, end) = match selected {
4237            Some(range) => range,
4238            None => {
4239                let off = self.block_offset_for_caret().unwrap_or(self.caret);
4240                (off, off)
4241            }
4242        };
4243        self.record_caret();
4244        match self.editor.toggle_block_container(start, end, kind) {
4245            Ok(change) => {
4246                // Read the caret's place out of the *pre-edit* source, before
4247                // `refresh` swaps that source out from under it.
4248                let place = (selected.is_none() && !opened_empty)
4249                    .then(|| self.caret_line_tail(&change.old));
4250                self.last_edit_kind = None; // structural edit is its own undo step
4251                self.refresh();
4252                match place {
4253                    // Both land the caret at the far end of what twig wrote, and
4254                    // differ only in what they leave selected.
4255                    //
4256                    // From a selection: select what the container now holds, the
4257                    // way `toggle` keeps its marked region selected — and for a
4258                    // stronger reason than symmetry: a container comes *off* only
4259                    // a range covering every block it holds, so a selection left
4260                    // on its old bytes (now short by a prefix per line) would nest
4261                    // on the second press instead of reversing the first.
4262                    //
4263                    // From a blank line: nothing to select, and the end of the
4264                    // region is exactly past the bare `> ` / `- ` twig wrote —
4265                    // the caret standing inside the container that was asked for.
4266                    None => {
4267                        self.anchor = (!opened_empty).then_some(change.new.start);
4268                        self.caret = change.new.end;
4269                    }
4270                    Some(place) => {
4271                        self.anchor = None;
4272                        self.caret = self.line_tail_offset(&change.new, place);
4273                    }
4274                }
4275                self.dirty = self.source != self.clean_source;
4276                self.status = None;
4277                self.clamp_caret();
4278                self.record_caret();
4279            }
4280            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4281        }
4282    }
4283
4284    /// The caret's place inside the region a container toggle is rewriting, in
4285    /// the only terms the rewrite preserves: which of the region's lines it sits
4286    /// on, and how many bytes of that line lie ahead of it.
4287    ///
4288    /// A container's markup goes in at column 0 and never touches what follows
4289    /// on the line, so that pair survives the edit exactly where a byte offset
4290    /// does not — a caret left on its old offset slides back by one prefix per
4291    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
4292    /// `> ` it just asked for.
4293    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
4294        let caret = self.caret.clamp(old.start, old.end);
4295        let line = self.source[old.start..caret].matches('\n').count();
4296        let end = self.source[caret..old.end]
4297            .find('\n')
4298            .map_or(old.end, |i| caret + i);
4299        (line, end - caret)
4300    }
4301
4302    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
4303    /// region: the offset `tail` bytes back from the end of the region's `line`.
4304    ///
4305    /// Both walks are clamped rather than trusted, because the one op that does
4306    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
4307    /// the items back apart with blank lines between them — and a caret landing
4308    /// on the nearest line of the right item beats one landing out of the region
4309    /// entirely.
4310    fn line_tail_offset(
4311        &self,
4312        new: &std::ops::Range<usize>,
4313        (line, tail): (usize, usize),
4314    ) -> usize {
4315        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
4316        let mut start = 0;
4317        for _ in 0..line {
4318            match region[start..].find('\n') {
4319                Some(i) => start += i + 1,
4320                None => break,
4321            }
4322        }
4323        let end = region[start..]
4324            .find('\n')
4325            .map_or(region.len(), |i| start + i);
4326        new.start + end.saturating_sub(tail).max(start)
4327    }
4328
4329    /// Link the selection to `destination` — the toolbar's Link button. With no
4330    /// selection it acts at the caret, which re-points a link the caret is
4331    /// already standing in (twig replaces an existing link's destination and
4332    /// keeps its text) and otherwise spells a link that has no text of its own:
4333    /// an autolink (`<https://x.dev>`) where the destination is one, and
4334    /// `[destination](destination)` where it isn't.
4335    ///
4336    /// `destination` reaches twig raw. Escaping it is format knowledge and the
4337    /// two formats genuinely disagree — Markdown ends a destination at the first
4338    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
4339    /// itself — so the side holding the document is the side that gets to spell
4340    /// it. A destination twig can't carry at all (one with a newline) comes back
4341    /// as an error rather than a quietly rewritten URL.
4342    pub fn insert_link(&mut self, destination: &str) {
4343        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
4344            return;
4345        }
4346        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4347        self.record_caret();
4348        match self.editor.insert_link(start, end, destination) {
4349            Ok(change) => {
4350                self.last_edit_kind = None;
4351                self.refresh();
4352                match self.link_text_span(change.new.start) {
4353                    // A link with text of its own: select it, so typing replaces
4354                    // a `[dest](dest)`'s stand-in label and a second press
4355                    // re-points what the first one linked.
4356                    Some(text) => {
4357                        self.anchor = (text.start != text.end).then_some(text.start);
4358                        self.caret = text.end;
4359                    }
4360                    // An autolink is finished the moment it's written — its text
4361                    // *is* the URL. Leaving it selected would aim the next press
4362                    // at the one shape twig still wraps instead of re-points.
4363                    None => {
4364                        self.anchor = None;
4365                        self.caret = change.new.end;
4366                    }
4367                }
4368                self.dirty = self.source != self.clean_source;
4369                self.status = None;
4370                self.clamp_caret();
4371                self.record_caret();
4372            }
4373            Err(e) => self.status = Some(format!("link: {e}")),
4374        }
4375    }
4376
4377    /// Insert a block-level image at the caret: `![alt](destination)`. Any
4378    /// selection becomes the alt text (so "select a caption, insert image" labels
4379    /// it); with no selection, `alt` is used — empty for none. The caret lands
4380    /// just past the inserted image.
4381    ///
4382    /// Both halves go through twig (`insert_literal` for the alt text,
4383    /// `insert_image` for the image), so neither is spelled here. That used to be a
4384    /// `format!`, and it was wrong the first time an app inserted a real filename:
4385    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
4386    /// not an image at all — and the fix is per-format, since moving into the
4387    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
4388    pub fn insert_image(&mut self, destination: &str, alt: &str) {
4389        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
4390            return;
4391        }
4392        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4393        self.record_caret();
4394        // With no selection and an explicit `alt`, the alt text has to exist in the
4395        // document before it can be the image's — and it is raw caller input, so
4396        // it goes in through `insert_literal`, which escapes it for the format
4397        // rather than letting a `]` in someone's caption close the image early.
4398        let (start, end) = if start == end && !alt.is_empty() {
4399            match self.editor.insert_literal(start, alt) {
4400                Ok(change) => (change.new.start, change.new.end),
4401                Err(e) => {
4402                    self.status = Some(format!("image: {e}"));
4403                    return;
4404                }
4405            }
4406        } else {
4407            (start, end)
4408        };
4409        match self.editor.insert_image(start, end, destination) {
4410            Ok(change) => {
4411                self.last_edit_kind = None;
4412                self.refresh();
4413                // Just past the image, nothing selected — where a caret belongs
4414                // after inserting one.
4415                self.anchor = None;
4416                self.caret = change.new.end;
4417                self.dirty = self.source != self.clean_source;
4418                self.status = None;
4419                self.clamp_caret();
4420                self.record_caret();
4421            }
4422            Err(e) => self.status = Some(format!("image: {e}")),
4423        }
4424    }
4425
4426    /// Insert a block-level image, video, or audio at the caret. The image case
4427    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
4428    /// HTML elements, which is the only spelling Markdown and Djot have for them:
4429    ///
4430    /// ```text
4431    /// <video src="clip.mp4" controls>alt</video>
4432    /// <audio src="take.mp3" controls>alt</audio>
4433    /// ```
4434    ///
4435    /// HTML rather than a `::video{…}` directive deliberately. A directive means
4436    /// something only to an app that knows the vocabulary, so the document would
4437    /// read as literal punctuation everywhere else; `<video>` is what every other
4438    /// renderer already understands, and what leaf's own reader picks back up
4439    /// through `html_elements` promotion (see [`parse_extensions`]).
4440    ///
4441    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
4442    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
4443    /// `html_elements`. Before that only the multi-line form parsed as a block at
4444    /// all, and this wrote three lines to work around it.
4445    ///
4446    /// `controls` is always written: a player with no transport is a still frame
4447    /// the reader can't do anything with. Any selection becomes the element's
4448    /// fallback text, exactly as it becomes an image's alt.
4449    ///
4450    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
4451    /// applies, and bites harder here: a `"` in `destination` closes the
4452    /// attribute. A frontend taking these from a file picker is fine; one taking
4453    /// them from free text should keep them tame.
4454    ///
4455    /// [`MediaInfo`]: crate::MediaInfo
4456    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
4457        if kind == MediaKind::Image {
4458            return self.insert_image(destination, alt);
4459        }
4460        // Gated on the *image* gesture, not on one of its own — there isn't one,
4461        // since the bytes below are spelled here rather than by twig, and an HTML
4462        // document would in fact parse them. The button is one control with three
4463        // kinds behind it, and two of them working in a format where the third
4464        // cannot is a worse surface than three that agree — especially as
4465        // `insert_image` is the kind anyone reaches for first.
4466        if self.refuse_unsupported("media", Gesture::InsertImage) {
4467            return;
4468        }
4469        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4470        let alt_text = self
4471            .selected_text()
4472            .map(str::to_string)
4473            .unwrap_or_else(|| alt.to_string());
4474        let tag = match kind {
4475            MediaKind::Audio => "audio",
4476            _ => "video",
4477        };
4478        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
4479        self.edit(start, end, &markup);
4480    }
4481
4482    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
4483    /// button. Spelling and placement are both twig's; leaf used to write `---`
4484    /// itself, which was the Markdown spelling in a djot document too.
4485    ///
4486    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
4487    /// mid-paragraph and lands it after the caret's whole block. To get a rule
4488    /// *at* the caret — the paragraph parted in two around it, which is what a
4489    /// rule button is understood to do — the paragraph is first divided with
4490    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
4491    /// the offset `split_block` returns puts the rule after the *second* half
4492    /// instead, which is a rule in the right document and the wrong place.
4493    ///
4494    /// Only a plain paragraph is split, and only where there is something to
4495    /// part: at the paragraph's end the split has no second half to mint and
4496    /// would write the separator anyway — a blank line and the empty slot Enter
4497    /// leaves for the next paragraph, which the rule then lands above and
4498    /// nothing fills — so there the rule goes straight after the paragraph,
4499    /// which is where the split-and-aim was sending it regardless. At the
4500    /// paragraph's *start* the split is kept, though it parts nothing either:
4501    /// `|para` becomes `\npara` with the caret on the new blank line, and a
4502    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
4503    /// "before the paragraph" is said through a gesture that only knows
4504    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
4505    /// else the rule simply lands after the block, which is both twig's own
4506    /// answer and the better one: splitting a fenced code block would leave two
4507    /// fences with a rule between them, and splitting a list item would mint an
4508    /// item nobody asked for on the way to a rule that lands after the list
4509    /// regardless. A table and a setext heading refuse the split outright, so
4510    /// they take the same path by themselves.
4511    pub fn insert_thematic_break(&mut self) {
4512        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
4513        {
4514            return;
4515        }
4516        self.caret = self.skip_trailing_close_delims(self.caret);
4517        // A selection is replaced by the rule, so collapse it first and let the
4518        // split-and-rule below run from the caret it leaves behind.
4519        if let Some((s, e)) = self.selection() {
4520            self.splice(s, e, "", EditKind::Other);
4521        }
4522        self.anchor = None;
4523        self.record_caret();
4524        let at = self.caret;
4525        if self.caret_parts_bare_paragraph() {
4526            // A failure here is not fatal: the rule still lands after the block,
4527            // which is exactly what this call was trying to improve on.
4528            let _ = self.editor.split_block(at);
4529        }
4530        match self.editor.insert_thematic_break(at) {
4531            Ok(change) => {
4532                self.last_edit_kind = None;
4533                self.refresh();
4534                self.anchor = None;
4535                self.caret = change.new.end;
4536                self.dirty = self.source != self.clean_source;
4537                self.status = None;
4538                self.clamp_caret();
4539                self.record_caret();
4540            }
4541            Err(e) => self.status = Some(format!("thematic break: {e}")),
4542        }
4543    }
4544
4545    /// Insert a fresh table at the caret — the toolbar's Table button. One
4546    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
4547    /// the document's own dialect and placed the way its thematic break is:
4548    /// after the caret's block, blank-separated. A bare paragraph is parted
4549    /// around the caret first, exactly as
4550    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
4551    /// table lands *at* the caret rather than after everything the caret's
4552    /// paragraph says.
4553    ///
4554    /// The caret ends in the first header cell, selected the way Tab selects
4555    /// a cell — the natural next act is to type the heading, and Tab then
4556    /// walks the grid. That cell is read back from the rebuilt table map
4557    /// rather than computed from the splice, because twig's blank line and
4558    /// quote prefix put the first bar at an offset only the reparse knows.
4559    ///
4560    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
4561    /// rows or columns is twig's refusal (a header with nothing under it is
4562    /// what its row delete refuses to leave), reported through `status`.
4563    pub fn insert_table(&mut self, rows: usize, cols: usize) {
4564        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
4565            return;
4566        }
4567        self.caret = self.skip_trailing_close_delims(self.caret);
4568        if let Some((s, e)) = self.selection() {
4569            self.splice(s, e, "", EditKind::Other);
4570        }
4571        self.anchor = None;
4572        self.record_caret();
4573        let at = self.caret;
4574        if self.caret_parts_bare_paragraph() {
4575            let _ = self.editor.split_block(at);
4576        }
4577        match self.editor.insert_table(at, rows, cols) {
4578            Ok(change) => {
4579                self.last_edit_kind = None;
4580                self.refresh();
4581                self.anchor = None;
4582                self.caret = change.new.end;
4583                self.dirty = self.source != self.clean_source;
4584                self.status = None;
4585                self.clamp_caret();
4586                // Into the first header cell of the table just written: the
4587                // first table whose grid begins inside the splice.
4588                self.rebuild_map();
4589                let first_cell = self
4590                    .vmap
4591                    .tables
4592                    .iter()
4593                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
4594                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
4595                    .map(|cell| (cell.start, cell.end));
4596                if let Some((start, end)) = first_cell {
4597                    self.select_cell(start, end);
4598                }
4599                self.record_caret();
4600            }
4601            Err(e) => self.status = Some(format!("table: {e}")),
4602        }
4603    }
4604
4605    /// Whether the caret sits in a paragraph and nothing else — no list item, no
4606    /// quote, no fence, no table — with paragraph text still ahead of it. The
4607    /// one shape where parting the block around the caret is unambiguously what
4608    /// a rule button means; see
4609    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
4610    /// container is left to take the rule after itself.
4611    ///
4612    /// The "text ahead" half is what keeps `split_block` from running at the
4613    /// one edge where its output composes badly. At a paragraph's end twig
4614    /// cannot mint the empty second half (no format spells an empty
4615    /// paragraph), so it writes only the separator — a blank line and the
4616    /// slot Enter leaves for the paragraph to come — and a block then aimed at
4617    /// the first half lands above a slot that nothing fills: `para\n` with the
4618    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
4619    /// as nothing ahead, since the split would shed it as the second half's
4620    /// leading indent and leave the same slot. Which end of the newline a
4621    /// paragraph's span stops at differs between the formats (Markdown before
4622    /// it, djot after), which is why this reads the remaining bytes rather
4623    /// than comparing offsets. The paragraph's
4624    /// start is deliberately not the same case — see
4625    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
4626    /// split is kept.
4627    fn caret_parts_bare_paragraph(&mut self) -> bool {
4628        let caret = self.caret.min(self.source.len());
4629        let Ok(chain) = self.editor.ancestors_at(caret) else {
4630            return false;
4631        };
4632        let mut para_end = None;
4633        for m in chain {
4634            match m.kind {
4635                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
4636                Kind::ListItem
4637                | Kind::TaskListItem
4638                | Kind::BlockQuote
4639                | Kind::CodeBlock
4640                | Kind::Table => return false,
4641                _ => {}
4642            }
4643        }
4644        match para_end {
4645            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
4646            _ => false,
4647        }
4648    }
4649
4650    /// The destination of the link under the caret — what a Link prompt shows so
4651    /// ⌘K on an existing link edits its URL instead of asking for it again.
4652    /// `None` when the caret stands in no link.
4653    ///
4654    /// An autolink carries no separate destination: its text *is* the URL, so
4655    /// that's what comes back for one.
4656    pub fn link_destination_at_caret(&mut self) -> Option<String> {
4657        self.link_destination_at(self.caret)
4658    }
4659
4660    /// The destination of the link at `off`.
4661    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
4662    /// the caret isn't.
4663    ///
4664    /// The offset form exists for the same reason
4665    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
4666    /// the document somewhere else — a footnote's text in a popover, say — has
4667    /// rows and runs but no caret in them, and still needs to know which of those
4668    /// runs a reader can follow.
4669    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
4670        self.nodes()
4671            .into_iter()
4672            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
4673            .filter(|n| n.span.start <= off && off < n.span.end)
4674            .max_by_key(|n| n.span.start)
4675            .and_then(|n| n.destination.or(n.text))
4676    }
4677
4678    /// Where the locator `id` lands in this document — the `#v2` half of a
4679    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
4680    /// answers to it.
4681    ///
4682    /// The other end of a link, and the reason this exists: without it a
4683    /// destination has only file granularity, so following a citation into a
4684    /// chapter drops the reader at the top of it to hunt for the verse. Which is
4685    /// also why it is a *document* query rather than a caret one — the document
4686    /// being asked is usually not the one the reader is in.
4687    ///
4688    /// Three readings, tried in order, because the same `#some-heading` is
4689    /// written three ways across the formats leaf opens:
4690    ///
4691    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
4692    ///    the auto-ids djot mints for its headings. The only exact answer, so it
4693    ///    goes first — a document that says `{#v1}` has settled the question.
4694    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
4695    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
4696    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
4697    ///    authored anywhere land on a djot heading.
4698    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
4699    ///    none and `{#custom}` is literal text in a Markdown heading — so for
4700    ///    the format most vaults are written in, the heading's own words are the
4701    ///    only thing a fragment can name. This is the rule every Markdown
4702    ///    renderer already follows, which is what makes `#a-heading` mean in
4703    ///    diaryx what it means on the web.
4704    ///
4705    /// Ties go to the earliest match, then to the widest: a duplicated id is the
4706    /// document's mistake and the first one is the answer every anchor
4707    /// implementation gives, while preferring the wider span picks the section
4708    /// over the heading that opens it — more for a peek to show, same place to
4709    /// land.
4710    pub fn locate(&mut self, id: &str) -> Option<Landing> {
4711        let id = id.trim();
4712        if id.is_empty() {
4713            return None;
4714        }
4715        let nodes = self.nodes();
4716
4717        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
4718        // is picking, among nodes that start together, the one that ends last.
4719        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
4720            matches
4721                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
4722                .map(|n| Landing {
4723                    start: n.span.start,
4724                    end: n.span.end,
4725                })
4726        };
4727
4728        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
4729            return Some(landing);
4730        }
4731        let want = slug(id);
4732        if want.is_empty() {
4733            return None;
4734        }
4735        if let Some(landing) = pick(
4736            &mut nodes
4737                .iter()
4738                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
4739        ) {
4740            return Some(landing);
4741        }
4742
4743        // A heading by its words. Its span is one line, so the end comes from
4744        // where the *section* it opens gives out — the next heading that is not
4745        // under it, or the end of the document. A Markdown heading has no
4746        // section node to ask (twig only builds those for djot), and a peek that
4747        // showed the heading alone would answer "what does that say" with the
4748        // title of the thing it says.
4749        let heading = nodes
4750            .iter()
4751            .filter(|n| n.kind == Kind::Heading)
4752            .filter(|n| {
4753                n.content_span
4754                    .clone()
4755                    .and_then(|s| self.source.get(s))
4756                    .is_some_and(|text| slug(text) == want)
4757            })
4758            .min_by_key(|n| n.span.start)?;
4759        let level = heading.level.unwrap_or(u32::MAX);
4760        let end = nodes
4761            .iter()
4762            .filter(|n| n.kind == Kind::Heading)
4763            .filter(|n| n.span.start > heading.span.start)
4764            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4765            .map(|n| n.span.start)
4766            .min()
4767            .unwrap_or(self.source.len());
4768        Some(Landing {
4769            start: heading.span.start,
4770            end,
4771        })
4772    }
4773
4774    /// Write a footnote at the caret — the toolbar's Footnote button, and the
4775    /// one gesture in the footnote story that *authors* rather than follows.
4776    ///
4777    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4778    /// the `[^1]:` definition at the end of the document. Half a footnote is not
4779    /// a footnote — a bare reference with nothing defining it renders as literal
4780    /// brackets — so a single button that wrote only the reference would leave
4781    /// the author to hand-spell the other half in a document that had just
4782    /// stopped showing them what the first half meant. One edit also means one
4783    /// undo takes both back.
4784    ///
4785    /// The definition's body is left empty and **the caret lands in it**, which
4786    /// is the whole point of pressing the button: nobody wants a reference to a
4787    /// note they have not written yet. Getting back to where they were writing
4788    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4789    /// the same return leg a reader following a reference already uses, so the
4790    /// author is left standing on the near end of a round trip that works.
4791    ///
4792    /// A selection collapses to its *end* rather than being replaced: a
4793    /// reference annotates the words before it, so "select the claim, add a
4794    /// footnote" should mark that claim, not consume it.
4795    pub fn insert_footnote(&mut self) {
4796        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4797            return;
4798        }
4799        let at = self.selection().map_or(self.caret, |(_, end)| end);
4800        self.anchor = None;
4801        self.caret = at;
4802        self.record_caret();
4803        let label = self.next_footnote_label();
4804        match self.editor.insert_footnote(at, &label) {
4805            Ok(change) => {
4806                self.last_edit_kind = None;
4807                self.refresh();
4808                self.anchor = None;
4809                // `change.new` runs from the reference to the end of the
4810                // document, so its start is the `[^1]` just written and
4811                // `footnote_at` resolves it to the note the same way a reader's
4812                // tap does — and to the note's *body*, which is already a caret
4813                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4814                // and has none), so this needs no snap on top. The fallback is
4815                // the reference's own offset: a format that spelled the pair some
4816                // way leaf can't read back should still leave the caret on the
4817                // edit rather than at the far end of a document it just grew.
4818                self.caret = self
4819                    .footnote_at(change.new.start)
4820                    .and_then(|note| note.offset)
4821                    .unwrap_or(change.new.start);
4822                self.dirty = self.source != self.clean_source;
4823                self.status = None;
4824                self.clamp_caret();
4825                self.record_caret();
4826            }
4827            Err(e) => self.status = Some(format!("footnote: {e}")),
4828        }
4829    }
4830
4831    /// The label to give a footnote the author has not named: the lowest counting
4832    /// number no footnote in the document is already wearing.
4833    ///
4834    /// twig takes the label rather than minting one, because it holds no opinion
4835    /// about what a document's footnotes should be called — and it is right not
4836    /// to. Numbering them is what every author of a numbered note expects, and
4837    /// re-using a taken number would silently point the new reference at somebody
4838    /// else's note (twig reuses an existing definition rather than appending a
4839    /// second one, which is the right rule for citing a note twice on purpose and
4840    /// exactly the wrong accident to have by default).
4841    ///
4842    /// *References* are counted alongside definitions, not just definitions: a
4843    /// document carrying a dangling `[^2]` has a 2 that means something to
4844    /// whoever wrote it, and minting a definition for it here would answer a
4845    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4846    /// count entirely — they take no number, so they block none.
4847    fn next_footnote_label(&mut self) -> String {
4848        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4849            .into_iter()
4850            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4851            .filter_map(|label| label.parse().ok())
4852            .collect();
4853        taken.extend(
4854            self.nodes()
4855                .into_iter()
4856                .filter(|n| n.kind == Kind::FootnoteReference)
4857                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4858                .filter_map(|label| label.parse::<u32>().ok()),
4859        );
4860        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4861    }
4862
4863    /// The footnote reference under the caret, resolved to the note it names.
4864    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4865    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4866        self.footnote_at(self.caret)
4867    }
4868
4869    /// The footnote reference at `off`, resolved to the note it names — what a
4870    /// frontend shows when a reader activates a `[^1]`.
4871    ///
4872    /// A reference is not a link node, so
4873    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4874    /// (and should not) answer for one: a link names a destination to leave for,
4875    /// a reference names a note that is already in this document. Following one
4876    /// is a move within the page, which is why this hands back an `offset`
4877    /// rather than something to open.
4878    ///
4879    /// Offset-based rather than caret-only because the gesture that wants this
4880    /// most is the one that must not move the caret: a pointer hovering a `[1]`
4881    /// asks what note it names without disturbing where the reader was typing.
4882    /// The caret is just the offset a click already placed —
4883    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4884    ///
4885    /// `None` when `off` stands in no reference. A reference whose note the
4886    /// document never defines is *not* `None` — it answers with the label it
4887    /// looked for and no text, which is what lets a frontend say so instead of
4888    /// silently doing nothing.
4889    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4890        // Innermost-wins by latest start, the rule its link sibling uses.
4891        let span = self
4892            .nodes()
4893            .into_iter()
4894            .filter(|n| n.kind == Kind::FootnoteReference)
4895            .filter(|n| n.span.start <= off && off < n.span.end)
4896            .max_by_key(|n| n.span.start)?
4897            .span;
4898        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4899
4900        // The note itself. Definitions are roots beside `doc` rather than
4901        // children of it, so they're asked for directly — see
4902        // `wysiwyg::footnote_definitions`.
4903        let note = wysiwyg::footnote_definitions(&mut self.editor)
4904            .into_iter()
4905            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4906        let Some(note) = note else {
4907            return Some(FootnoteRef {
4908                label,
4909                text: None,
4910                offset: None,
4911                end: None,
4912            });
4913        };
4914        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4915        Some(FootnoteRef {
4916            label,
4917            text: body
4918                .clone()
4919                .and_then(|b| self.source.get(b))
4920                .map(str::to_string),
4921            // The body's start, not the definition's — see `FootnoteRef::offset`.
4922            offset: body.clone().map(|b| b.start),
4923            end: body.map(|b| b.end),
4924        })
4925    }
4926
4927    /// The footnote *definition* the caret stands in, and where the reference
4928    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4929    /// at the caret's offset.
4930    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4931        self.footnote_definition_at(self.caret)
4932    }
4933
4934    /// The footnote definition spanning `off`, and where the reference that
4935    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4936    ///
4937    /// The mirror image, deliberately: the same gesture that takes a reader from
4938    /// `[1]` down to the note takes them from the note back up to `[1]`, so
4939    /// following a footnote is a round trip rather than a fall. It needs no
4940    /// memory of how the reader arrived — the document says where the reference
4941    /// is — which is what makes it work for a reader who scrolled to the notes
4942    /// themselves, and what keeps it right after an edit moves either end.
4943    ///
4944    /// `None` when `off` stands in no definition. A definition nothing cites is
4945    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4946    /// its label and no offset, so a frontend can say "nothing refers to this"
4947    /// rather than offer a jump that goes nowhere.
4948    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4949        // Definitions are roots beside `doc`, so `nodes()` — which walks the
4950        // document body — never reports one. They're asked for directly, the way
4951        // `footnote_at` asks for the note it resolves to.
4952        //
4953        // Closed at the end, unlike the half-open test its neighbours use. A
4954        // definition's span stops at its last content byte — the newline ending
4955        // the line is outside it — so `span.end` is the caret stop at the end of
4956        // the note's own row, not the first byte of anything after. Excluding it
4957        // meant the one caret an author is guaranteed to have, the one left
4958        // sitting at the end of the note they just typed, was in no definition at
4959        // all: writing a note and then asking to go back to its reference
4960        // answered nothing. Two definitions in a row still can't both match —
4961        // there is a blank line between them — and `max_by_key` decides anyway.
4962        let note = wysiwyg::footnote_definitions(&mut self.editor)
4963            .into_iter()
4964            .filter(|m| m.span.start <= off && off <= m.span.end)
4965            .max_by_key(|m| m.span.start)?;
4966        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4967
4968        // The earliest reference carrying this label. `min` rather than a `find`,
4969        // because `nodes()` reports a flattened walk whose order is twig's
4970        // business, not document order. Bound first: the walk needs `&mut self`
4971        // and reading the labels back out needs `&self.source`.
4972        let nodes = self.nodes();
4973        let offset = nodes
4974            .into_iter()
4975            .filter(|n| n.kind == Kind::FootnoteReference)
4976            .filter(|n| {
4977                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4978            })
4979            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4980            .map(|n| n.span.start + 2)
4981            .min();
4982        Some(FootnoteDef { label, offset })
4983    }
4984
4985    /// The destination of the image under the caret — what an image prompt shows
4986    /// so editing an existing image starts from its current URL instead of blank,
4987    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4988    /// `None` when the caret stands in no image. A caret resting just after a
4989    /// block image (its trailing stop) is still "in" it — the half-open span test
4990    /// excludes that offset, which is the intended precision: past the image is
4991    /// past it.
4992    pub fn image_destination_at_caret(&mut self) -> Option<String> {
4993        let off = self.caret;
4994        self.nodes()
4995            .into_iter()
4996            .filter(|n| n.kind == Kind::Image)
4997            .filter(|n| n.span.start <= off && off < n.span.end)
4998            .max_by_key(|n| n.span.start)
4999            .and_then(|n| n.destination)
5000    }
5001
5002    /// The language of the fenced code block the caret stands in — what a
5003    /// language prompt shows so editing it starts from the current value rather
5004    /// than blank. `None` when the caret is in no code block, or in one whose
5005    /// fence carries no language (or an indented block, which has no fence).
5006    pub fn code_language_at_caret(&mut self) -> Option<String> {
5007        let start = self.code_block_start_at_caret()?;
5008        wysiwyg::code_language(&self.source, start)
5009    }
5010
5011    /// Whether the caret stands in a fenced code block — the one a language
5012    /// prompt could edit. A frontend gates its "set language" affordance on this
5013    /// (an indented block, which can't carry a language, reports `false`).
5014    pub fn caret_in_fenced_code(&mut self) -> bool {
5015        self.code_block_start_at_caret()
5016            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
5017    }
5018
5019    /// Set (or clear, with `""`) the language of the fenced code block the caret
5020    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
5021    /// block, and a reported error for a language the format's fence cannot
5022    /// carry.
5023    ///
5024    /// twig rewrites the info string, so the fence's own width — measured
5025    /// against a body neither side touches — is kept, and a language holding a
5026    /// space, a line end or the fence character is refused rather than written
5027    /// out to reparse as something else. Leaf used to splice over the info span
5028    /// itself and `trim()` the input, which handled the one bad case it had
5029    /// thought of.
5030    pub fn set_code_language(&mut self, lang: &str) {
5031        // The read-only gate — this door reaches twig without the splice.
5032        if self.read_only {
5033            return;
5034        }
5035        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
5036            return;
5037        }
5038        if self.code_block_start_at_caret().is_none() {
5039            return;
5040        }
5041        let lang = lang.trim();
5042        // `None` clears the info string; `Some("")` asks for an empty one. Both
5043        // write a bare fence, and the prompt's empty value means "clear".
5044        let want = (!lang.is_empty()).then_some(lang);
5045        self.record_caret();
5046        match self.editor.set_code_language(self.caret, want) {
5047            Ok(_) => {
5048                self.last_edit_kind = None;
5049                self.refresh();
5050                self.anchor = None;
5051                self.dirty = self.source != self.clean_source;
5052                self.status = None;
5053                self.clamp_caret();
5054                self.record_caret();
5055            }
5056            Err(e) => self.status = Some(format!("code language: {e}")),
5057        }
5058    }
5059
5060    /// The `span.start` of the code block covering the caret — the anchor
5061    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
5062    /// in none.
5063    fn code_block_start_at_caret(&mut self) -> Option<usize> {
5064        let off = self.caret;
5065        self.nodes()
5066            .into_iter()
5067            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
5068            .max_by_key(|n| n.span.start)
5069            .map(|n| n.span.start)
5070    }
5071
5072    /// The source range of the text inside the link covering `off` — what sits
5073    /// between its `[` and `]`. `None` when twig reports no link there.
5074    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
5075        self.nodes()
5076            .into_iter()
5077            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
5078            // the other's `span.start`; the link that starts latest at or before
5079            // `off` is the one `off` is actually in.
5080            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
5081            .max_by_key(|n| n.span.start)
5082            .and_then(|n| n.content_span)
5083    }
5084
5085    // ── undo / redo ───────────────────────────────────────────────────────────
5086    // twig owns the history of *bytes* (it owns the buffer) and now carries the
5087    // caret through it too: `record_caret` stashes each state's caret in twig's
5088    // opaque per-step blob, and undo/redo hand it back with the source they
5089    // restore. So leaf keeps no history of its own — no parallel stacks to march
5090    // in lockstep and silently drift out of it.
5091
5092    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
5093    /// where they were when that step began.
5094    pub fn undo(&mut self) {
5095        if self.read_only {
5096            return;
5097        }
5098        let (undone, redoable) = (self.undo_steps, self.redo_steps);
5099        match self.editor.undo() {
5100            Ok(Some(change)) => {
5101                self.after_history(change);
5102                // `refresh` counted the restore as an edit; it was a step back.
5103                self.undo_steps = undone.saturating_sub(1);
5104                self.redo_steps = redoable + 1;
5105            }
5106            Ok(None) => {
5107                self.undo_steps = 0;
5108                self.status = Some("nothing to undo".into());
5109            }
5110            Err(e) => self.status = Some(format!("undo: {e}")),
5111        }
5112    }
5113
5114    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
5115    /// selection back where that step originally left them.
5116    pub fn redo(&mut self) {
5117        if self.read_only {
5118            return;
5119        }
5120        let (undone, redoable) = (self.undo_steps, self.redo_steps);
5121        match self.editor.redo() {
5122            Ok(Some(change)) => {
5123                self.after_history(change);
5124                // `refresh` counted the restore as an edit; it was a step forward.
5125                self.undo_steps = undone + 1;
5126                self.redo_steps = redoable.saturating_sub(1);
5127            }
5128            Ok(None) => {
5129                self.redo_steps = 0;
5130                self.status = Some("nothing to redo".into());
5131            }
5132            Err(e) => self.status = Some(format!("redo: {e}")),
5133        }
5134    }
5135
5136    /// Refresh the cached source and put the caret back where the step being
5137    /// undone/redone had it, clearing any active run.
5138    ///
5139    /// The caret comes from twig's blob for the restored state (what
5140    /// `record_caret` stored). `change` is only the fallback for a state with no
5141    /// blob — a caret at the end of the restored text, which is where this always
5142    /// landed before the blobs were kept. It is the edit site, not where the user
5143    /// was standing, so it's a floor and not the behaviour: undoing should hand
5144    /// back the document *and* the place you were working, which for an edit made
5145    /// anywhere but under the caret are two different places.
5146    fn after_history(&mut self, change: Change) {
5147        self.refresh();
5148        match self
5149            .editor
5150            .caret_blob()
5151            .ok()
5152            .and_then(|b| CaretState::from_blob(&b))
5153        {
5154            Some(state) => {
5155                self.caret = state.caret.min(self.source.len());
5156                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
5157            }
5158            None => {
5159                self.caret = change.new.end.min(self.source.len());
5160                self.anchor = None;
5161            }
5162        }
5163        self.goal_col = None;
5164        self.last_edit_kind = None;
5165        self.dirty = self.source != self.clean_source;
5166        self.status = None;
5167        self.clamp_caret();
5168    }
5169
5170    // ── the file ──────────────────────────────────────────────────────────────
5171
5172    #[cfg(feature = "fs")]
5173    pub fn save(&mut self) {
5174        if self.is_untitled() {
5175            // No path to write and no name to invent: ⌘S on an untitled document
5176            // is a Save As, and only a frontend has a picker to ask with. Say so
5177            // rather than failing at the filesystem with an empty path.
5178            self.status = Some("untitled — save as…".into());
5179            return;
5180        }
5181        let path = self.path.clone();
5182        if self.write(&path) {
5183            self.mark_saved();
5184        }
5185    }
5186
5187    /// Save As: write the document to `path` and *move* it there — `self.path`
5188    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
5189    /// what Save As means; a copy would leave the user editing a document whose
5190    /// name is no longer where their keystrokes go.
5191    ///
5192    /// The move only happens if the bytes actually landed. A failed write leaves
5193    /// the path, `dirty`, and the disk watermark exactly as they were, with the
5194    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
5195    /// must never come away believing it was saved.
5196    ///
5197    /// An existing `path` is overwritten, and the caller is the one that knows
5198    /// whether to ask first: a Save As picker has already run that prompt, and a
5199    /// second confirmation from down here would be the same question twice.
5200    ///
5201    /// `format` does **not** follow the new extension. The buffer is parsed as
5202    /// the format it was opened with, and re-reading it as another one is a
5203    /// conversion — a different, lossy operation that would throw away the undo
5204    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
5205    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
5206    /// until it's reopened.
5207    #[cfg(feature = "fs")]
5208    pub fn save_as(&mut self, path: PathBuf) {
5209        if !self.write(&path) {
5210            return;
5211        }
5212        self.path = path;
5213        self.mark_saved();
5214    }
5215
5216    /// Put `source` on disk at `path`, reporting whether it got there. The one
5217    /// place leaf writes a document, so a save and a Save As can't disagree
5218    /// about what a failure looks like.
5219    #[cfg(feature = "fs")]
5220    fn write(&mut self, path: &Path) -> bool {
5221        match std::fs::write(path, self.source.as_bytes()) {
5222            Ok(()) => true,
5223            Err(e) => {
5224                self.status = Some(format!("save failed: {e}"));
5225                false
5226            }
5227        }
5228    }
5229
5230    /// Re-base the document's saved watermark to the current bytes: clears
5231    /// `dirty`, records `source` as the new clean state (so undoing back to here
5232    /// clears the flag again), and re-stamps the on-disk hash.
5233    ///
5234    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
5235    /// the hook a **filesystem-free host** calls itself once it has persisted
5236    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
5237    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
5238    /// are already where that host wants them, and this just tells the model they
5239    /// are safe.
5240    pub fn mark_saved(&mut self) {
5241        self.clean_source = self.source.clone();
5242        self.dirty = false;
5243        // The bytes on disk are now ours, so this is the new watermark: without
5244        // re-stamping it, every save would report its own work as an external
5245        // change forever after.
5246        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
5247        self.status = Some(format!("saved {}", self.file_name()));
5248    }
5249
5250    /// What the file looks like now against the bytes leaf last read or wrote.
5251    ///
5252    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
5253    /// so this is a filesystem round-trip, not a per-frame question — ask it
5254    /// when a window regains focus, on a timer, or before a save.
5255    ///
5256    /// This *only* reports the file. Whether the document also has unsaved edits
5257    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
5258    /// [`DiskState::Changed`] means a save overwrites someone's work and a
5259    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
5260    /// it has no way to ask — so it hands a frontend both halves and lets it put
5261    /// the question to the person who can answer it.
5262    #[cfg(feature = "fs")]
5263    pub fn disk_state(&self) -> DiskState {
5264        let Some(want) = self.disk_hash else {
5265            return DiskState::Untitled;
5266        };
5267        match std::fs::read(&self.path) {
5268            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
5269            Ok(_) => DiskState::Changed,
5270            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
5271            Err(_) => DiskState::Unreadable,
5272        }
5273    }
5274
5275    /// Re-read the file and replace the document with what's there — the other
5276    /// answer to a [`DiskState::Changed`].
5277    ///
5278    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
5279    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
5280    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
5281    /// document shouldn't have to argue with a guard.
5282    ///
5283    /// **The undo history survives, and the reload is one step in it.** The
5284    /// whole buffer is spliced with the file's bytes through the same door every
5285    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
5286    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
5287    /// swapped the document out from under a reader gives them back what they
5288    /// were looking at, marked dirty, and ^Z again carries on into whatever they
5289    /// had done before it. This used to build a fresh parse and drop the stack,
5290    /// on the reasoning that twig's history belongs to the buffer and these are
5291    /// different bytes; that is true of *rebasing* a step onto them and not of
5292    /// recording the swap itself as one, which is all this is. A splice twig
5293    /// won't take falls back to the fresh parse, and only that path still costs
5294    /// the history.
5295    ///
5296    /// The caret keeps its byte offset, clamped to the new length; the selection
5297    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
5298    /// file changed, so it can't know where the caret "still" is. Clamping keeps
5299    /// it where the user left it in the common case (a change further down the
5300    /// file, or none in the text they're sitting in), and never puts it
5301    /// somewhere invalid. A selection has two such offsets and no such excuse —
5302    /// silently reinterpreting one over changed bytes would arm the *next*
5303    /// keystroke to delete something the user never selected.
5304    ///
5305    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
5306    /// document alone with a status.
5307    #[cfg(feature = "fs")]
5308    pub fn reload(&mut self) {
5309        if self.is_untitled() {
5310            self.status = Some("no file to reload".into());
5311            return;
5312        }
5313        let bytes = match std::fs::read(&self.path) {
5314            Ok(b) => b,
5315            Err(e) => {
5316                self.status = Some(format!("reload failed: {e}"));
5317                return;
5318            }
5319        };
5320        let Ok(source) = String::from_utf8(bytes) else {
5321            self.status = Some("reload failed: file is not UTF-8".into());
5322            return;
5323        };
5324        // Already these bytes — someone saved a file back unchanged, or leaf's
5325        // own write is being read back. Re-baseline against it and stop: a
5326        // splice of the text onto itself would put an undo step on the stack for
5327        // something nobody did.
5328        if source == self.source {
5329            self.disk_hash = Some(hash_bytes(source.as_bytes()));
5330            self.clean_source = source;
5331            self.dirty = false;
5332            self.status = Some(format!("reloaded {}", self.file_name()));
5333            return;
5334        }
5335        let caret = self.caret;
5336        // The pre-reload caret, so undoing the swap puts it back where the
5337        // reader was standing — the same bracketing `splice_exact` does.
5338        self.record_caret();
5339        if self
5340            .editor
5341            .edit_range(0, self.source.len(), &source)
5342            .is_ok()
5343        {
5344            self.refresh();
5345        } else {
5346            // twig wouldn't take the splice. Start over from the bytes, which is
5347            // what this always did, and is the one path that still costs the
5348            // history — `format` is the format this document *is*, not what the
5349            // (unchanged) name now says, see `save_as`.
5350            match new_editor(source.as_bytes(), self.format) {
5351                Ok(editor) => {
5352                    self.editor = editor;
5353                    self.source = source.clone();
5354                    // Not going through `refresh`, so the revision has to move
5355                    // here or every frontend keeps painting the old file from
5356                    // cache.
5357                    self.revision += 1;
5358                }
5359                Err(e) => {
5360                    self.status = Some(format!("reload failed: {e}"));
5361                    return;
5362                }
5363            }
5364        }
5365        self.disk_hash = Some(hash_bytes(source.as_bytes()));
5366        self.clean_source = self.source.clone();
5367        self.caret = caret.min(self.source.len());
5368        self.anchor = None;
5369        self.goal_col = None;
5370        self.last_edit_kind = None;
5371        self.dirty = false;
5372        self.status = Some(format!("reloaded {}", self.file_name()));
5373        self.clamp_caret();
5374        // And the post-reload caret, so a redo restores it.
5375        self.record_caret();
5376    }
5377
5378    /// Re-read the source from twig after it has changed the document. The one
5379    /// funnel every edit, undo, and redo comes through — so it's where the
5380    /// revision moves, and anything cached against the text dies here.
5381    fn refresh(&mut self) {
5382        if let Ok(s) = self.editor.source_str() {
5383            self.source = s;
5384        }
5385        self.revision += 1;
5386        // An edit is a step onto the history and the end of anything undone;
5387        // `undo`/`redo` come through here too and correct this after.
5388        self.undo_steps += 1;
5389        self.redo_steps = 0;
5390        self.clamp_caret();
5391    }
5392
5393    /// Whether [`undo`](Self::undo) has a step to take back — for a native
5394    /// Edit menu to enable its item by. See the note on `undo_steps` for what
5395    /// "has" means here.
5396    pub fn can_undo(&self) -> bool {
5397        !self.read_only && self.undo_steps > 0
5398    }
5399
5400    /// Whether [`redo`](Self::redo) has an undone step to restore.
5401    pub fn can_redo(&self) -> bool {
5402        !self.read_only && self.redo_steps > 0
5403    }
5404
5405    // ── caret movement ─────────────────────────────────────────────────────────
5406    // `extend` grows the selection (Shift+motion): it pins the anchor on the
5407    // first extended step and moves only the caret; an un-extended motion drops
5408    // the selection.
5409
5410    /// Place the caret at byte `offset` (clamped to a char boundary), extending
5411    /// the selection when `extend` is set. The public form of `move_to`, for a
5412    /// frontend that hit-tests pixels straight to a source offset.
5413    pub fn place_caret(&mut self, offset: usize, extend: bool) {
5414        self.goal_col = None;
5415        let before = self.caret;
5416        // A pixel hit-test can land between the visible caret stops — in the
5417        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
5418        // Snap to the nearest real stop so the caret can't come to rest where it
5419        // would draw in one place and type in another. The `(row, col)` click
5420        // path (`click`) already snaps this way through `offset_of_pos`; the
5421        // source view reaches every byte, so it snaps to nothing.
5422        let target = match self.view {
5423            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
5424            // The source view reaches every byte, so there is no stop to snap
5425            // to — but "every byte" still means every *character* boundary. A
5426            // caret resting inside a multi-byte character draws nowhere real
5427            // and panics the next time anything slices there.
5428            View::Source => self.char_boundary_at_or_before(offset),
5429        };
5430        self.move_to(target, extend);
5431        self.clamp_caret();
5432        self.debug_assert_on_a_stop(before);
5433    }
5434
5435    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
5436    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
5437    /// grab the metadata) while the source view still selects the literal whole.
5438    pub fn select_all(&mut self) {
5439        self.anchor = Some(self.caret_floor());
5440        self.caret = self.source.len();
5441        self.goal_col = None;
5442        self.last_edit_kind = None;
5443        self.status = None;
5444    }
5445
5446    /// Select the word (or whitespace / punctuation run) at `offset` — the
5447    /// double-click gesture. Anchors on the run's start with the caret at its
5448    /// end so a following Shift-motion extends from the far edge.
5449    pub fn select_word_at(&mut self, offset: usize) {
5450        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
5451        self.anchor = Some(s);
5452        self.caret = e;
5453        self.goal_col = None;
5454        self.last_edit_kind = None;
5455        self.status = None;
5456        self.clamp_caret();
5457    }
5458
5459    /// Select the whole enclosing text block (paragraph, heading, list item's
5460    /// text…) at `offset` — the triple-click gesture. Reads the range straight
5461    /// from the AST (twig's `content_span`), so it selects the entire *logical*
5462    /// paragraph even when that paragraph soft-wraps across several visual rows —
5463    /// where a visual-row-based select breaks down, because one source offset at
5464    /// a wrap boundary belongs to two rows at once.
5465    pub fn select_block_at(&mut self, offset: usize) {
5466        let off = offset.min(self.source.len());
5467        let range = self
5468            .editor
5469            .ancestors_at(off)
5470            .ok()
5471            .and_then(|chain| {
5472                // Ancestors run root → deepest; the deepest node that is neither
5473                // an inline span nor a multi-block container is the text block
5474                // the caret sits in (a paragraph, a heading, a code block…).
5475                chain
5476                    .into_iter()
5477                    .rev()
5478                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5479                    .map(|m| m.content_span.unwrap_or(m.span))
5480            })
5481            .unwrap_or_else(|| source_line_range(&self.source, off));
5482        self.anchor = Some(range.start.min(self.source.len()));
5483        self.caret = range.end.min(self.source.len());
5484        self.goal_col = None;
5485        self.last_edit_kind = None;
5486        self.status = None;
5487        self.clamp_caret();
5488    }
5489
5490    /// Select the exact source range `[start, end)` — anchor at `start`, caret
5491    /// at `end` — without snapping either end to a visible caret stop.
5492    ///
5493    /// The one caret verb that takes a range it was *handed* rather than one it
5494    /// worked out, for a host that already knows the bytes it means: a search
5495    /// hit, an annotation's footprint, a quote re-anchored through
5496    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
5497    /// wrong tool for that, and not by a little — it snaps to the nearest
5498    /// *visible* stop, and where a range butts up against a hidden delimiter
5499    /// the nearest stop is the one before it, so selecting the "needle" of
5500    /// `**needle**` comes back with "needl" and an edit against it strands the
5501    /// "e".
5502    ///
5503    /// What `place_caret` does that is bookkeeping rather than snapping still
5504    /// happens here, because a host handing in a range is not asking to opt out
5505    /// of the invariants:
5506    ///
5507    /// - both ends are clamped into the document and up to
5508    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
5509    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
5510    ///   types into the metadata;
5511    /// - both land on character boundaries, so nothing slices a `é` in half;
5512    /// - the sticky vertical goal column is dropped, and any armed inline mark
5513    ///   disarmed, since a range from outside inherits neither.
5514    ///
5515    /// An empty range is a caret rather than a selection —
5516    /// [`selection`](Self::selection) reports `None` for it, as it does for any
5517    /// anchor that has met the caret.
5518    pub fn select_range(&mut self, start: usize, end: usize) {
5519        let floor = self.caret_floor();
5520        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
5521        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
5522        self.anchor = Some(anchor);
5523        self.caret = caret;
5524        self.goal_col = None;
5525        self.status = None;
5526        self.last_edit_kind = None;
5527        self.clear_pending();
5528    }
5529
5530    /// `offset` itself if it is a character boundary, else the boundary before
5531    /// it. An offset that isn't one draws nowhere real and panics the next time
5532    /// anything slices there.
5533    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
5534        let mut o = offset.min(self.source.len());
5535        while o > 0 && !self.source.is_char_boundary(o) {
5536            o -= 1;
5537        }
5538        o
5539    }
5540
5541    /// The lowest source offset the caret may occupy in the active view. In
5542    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
5543    /// the first rendered offset; the source view reaches everything, so it's 0.
5544    fn caret_floor(&self) -> usize {
5545        match self.view {
5546            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
5547            View::Source => 0,
5548        }
5549    }
5550
5551    /// Land in a table cell with its whole content selected — the anchor at the
5552    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
5553    /// like tabbing into a form field: the text comes up selected, so typing
5554    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
5555    /// end`) collapses to a plain caret home (an empty selection is no selection).
5556    fn select_cell(&mut self, start: usize, end: usize) {
5557        self.select_range(start, end);
5558    }
5559
5560    fn move_to(&mut self, offset: usize, extend: bool) {
5561        if extend {
5562            if self.anchor.is_none() {
5563                self.anchor = Some(self.caret);
5564            }
5565        } else {
5566            self.anchor = None;
5567        }
5568        self.caret = offset.min(self.source.len()).max(self.caret_floor());
5569        self.status = None;
5570        // A caret move ends the current typing/deletion run, so the next edit
5571        // starts a fresh undo group rather than coalescing across the gap.
5572        self.last_edit_kind = None;
5573        // Moving away disarms any sticky mark — "start bold" applies only where
5574        // it was asked for, not wherever the caret next lands.
5575        self.clear_pending();
5576    }
5577
5578    // In the source view, motion walks source bytes / source lines. In the
5579    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
5580    // what steps the caret cleanly over hidden delimiters.
5581
5582    pub fn move_left(&mut self, extend: bool) {
5583        self.goal_col = None;
5584        if !extend && let Some((s, _e)) = self.selection() {
5585            self.move_to(s, false);
5586            return;
5587        }
5588        let target = match self.view {
5589            View::Source => {
5590                if self.caret > 0 {
5591                    prev_boundary(&self.source, self.caret)
5592                } else {
5593                    0
5594                }
5595            }
5596            // Walks caret *stops*, not columns: decoration (a table border, a
5597            // cell's padding) is stepped over in one press, and a hidden
5598            // delimiter never holds the caret up — though the end of a mark's
5599            // content is a stop of its own (`VisualMap::mark_ends`), so
5600            // leaving `**bold**` from past its `**` is a press onto the end of
5601            // the bold and another onto the `d`.
5602            View::Wysiwyg => self
5603                .vmap
5604                .caret_stop_before(self.caret)
5605                .unwrap_or(self.caret),
5606        };
5607        let before = self.caret;
5608        self.move_to(target, extend);
5609        self.debug_assert_on_a_stop(before);
5610    }
5611
5612    pub fn move_right(&mut self, extend: bool) {
5613        self.goal_col = None;
5614        if !extend && let Some((_s, e)) = self.selection() {
5615            self.move_to(e, false);
5616            return;
5617        }
5618        let target = match self.view {
5619            View::Source => {
5620                if self.caret < self.source.len() {
5621                    next_boundary(&self.source, self.caret)
5622                } else {
5623                    self.caret
5624                }
5625            }
5626            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
5627        };
5628        let before = self.caret;
5629        self.move_to(target, extend);
5630        self.debug_assert_on_a_stop(before);
5631    }
5632
5633    /// Move to the start of the previous word (⌥← / Ctrl+←).
5634    pub fn move_word_left(&mut self, extend: bool) {
5635        self.goal_col = None;
5636        let before = self.caret;
5637        let target = self.word_left_from(self.caret);
5638        self.move_to(target, extend);
5639        self.debug_assert_on_a_stop(before);
5640    }
5641
5642    /// Move to the end of the next word (⌥→ / Ctrl+→).
5643    pub fn move_word_right(&mut self, extend: bool) {
5644        self.goal_col = None;
5645        let before = self.caret;
5646        let target = self.word_right_from(self.caret);
5647        self.move_to(target, extend);
5648        self.debug_assert_on_a_stop(before);
5649    }
5650
5651    // Word boundaries are found in the space the *view* is in. The source view
5652    // walks the source, because there the source is what's rendered. WYSIWYG
5653    // walks the rendered text instead: `**` is invisible to the user, so it has
5654    // to be invisible to word motion too — a caret parked inside one draws in
5655    // the column after `bold` and types two bytes earlier, and a word-delete
5656    // that stops there shreds the markup into `a ** c`.
5657
5658    /// The word boundary to the left of `off` in the active view's space.
5659    fn word_left_from(&self, off: usize) -> usize {
5660        match self.view {
5661            View::Source => prev_word(&self.source, off),
5662            View::Wysiwyg => self.glyph_word_left(off),
5663        }
5664    }
5665
5666    /// The word boundary to the right of `off` in the active view's space.
5667    fn word_right_from(&self, off: usize) -> usize {
5668        match self.view {
5669            View::Source => next_word(&self.source, off),
5670            View::Wysiwyg => self.glyph_word_right(off),
5671        }
5672    }
5673
5674    /// The character class of the glyph drawn at stop `off`.
5675    ///
5676    /// Read from the source, because a stop points at the source byte its glyph
5677    /// came from — the source *is* where the rendered character is written. What
5678    /// makes the walk glyph space rather than source space is that it only ever
5679    /// visits stops, and the hidden bytes between them have none.
5680    fn class_at(&self, off: usize) -> Class {
5681        self.source
5682            .get(off..)
5683            .and_then(|s| s.chars().next())
5684            .map_or(Class::Space, classify)
5685    }
5686
5687    /// [`next_word`] in glyph space: skip any leading separators, then consume
5688    /// the following word run, with the stop table standing in for the source's
5689    /// characters.
5690    fn glyph_word_right(&self, from: usize) -> usize {
5691        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
5692            return from;
5693        };
5694        let mut in_word = false;
5695        loop {
5696            match self.class_at(off) {
5697                Class::Word => in_word = true,
5698                _ if in_word => return off,
5699                _ => {}
5700            }
5701            match self.vmap.stop_after(off) {
5702                Some(next) => off = next,
5703                None => return off,
5704            }
5705        }
5706    }
5707
5708    /// [`prev_word`] in glyph space: skip separators walking left, then consume
5709    /// the preceding word run.
5710    fn glyph_word_left(&self, from: usize) -> usize {
5711        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
5712            return from;
5713        };
5714        let mut in_word = false;
5715        while let Some(prev) = self.vmap.stop_before(off) {
5716            match self.class_at(prev) {
5717                Class::Word => in_word = true,
5718                _ if in_word => return off,
5719                _ => {}
5720            }
5721            off = prev;
5722        }
5723        off
5724    }
5725
5726    /// After a motion that walks the visual map, the caret must be *on* the map.
5727    /// A stop is the only offset where the caret draws and edits in the same
5728    /// place, and it's the invariant both a caret parked inside an emoji and one
5729    /// parked inside a `**` were quietly breaking.
5730    ///
5731    /// Only when the caret actually moved: a walk with nowhere to go leaves it
5732    /// where it was, which is wherever the floor or a frontend put it rather
5733    /// than somewhere this motion chose.
5734    fn debug_assert_on_a_stop(&self, before: usize) {
5735        debug_assert!(
5736            self.view != View::Wysiwyg
5737                || self.vmap.num_rows() == 0
5738                || self.caret == before
5739                || self.vmap.is_stop(self.caret),
5740            "motion left the caret at {}, which is not a caret stop: it would draw in \
5741             one place and type in another",
5742            self.caret
5743        );
5744    }
5745
5746    // Up and Down run off the ends of the document rather than stopping dead at
5747    // them: Up from the first row lands at the document's start, Down from the
5748    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
5749    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
5750    // reaching the end of the text is what a reader means by it.
5751    //
5752    // The views used to disagree here by accident rather than by decision: the
5753    // source view fell into the edge behaviour through `row_col_to_offset`
5754    // clamping an out-of-range row to the end of the string, while WYSIWYG had
5755    // no row below to walk to and did nothing at all. They share the rule now,
5756    // each in its own space — the source view reaches every byte, WYSIWYG only
5757    // the offsets it draws.
5758
5759    pub fn move_up(&mut self, extend: bool) {
5760        let (row, col) = self.caret_pos();
5761        let goal = self.goal_col.unwrap_or(col);
5762        let target = match self.view {
5763            View::Source => match row.checked_sub(1) {
5764                Some(r) => row_col_to_offset(&self.source, r, goal),
5765                None => self.reachable_start(),
5766            },
5767            // A table's border rules are drawn but hold no caret, so Up steps
5768            // over them to the row that does.
5769            View::Wysiwyg => match self.vmap.navigable_above(row) {
5770                Some(r) => self.row_target(r, goal),
5771                None => self.reachable_start(),
5772            },
5773        };
5774        self.step_vertical(target, goal, extend);
5775    }
5776
5777    pub fn move_down(&mut self, extend: bool) {
5778        let (row, col) = self.caret_pos();
5779        let goal = self.goal_col.unwrap_or(col);
5780        let target = match self.view {
5781            View::Source => match self.source_row_below(row) {
5782                Some(r) => row_col_to_offset(&self.source, r, goal),
5783                None => self.reachable_end(),
5784            },
5785            View::Wysiwyg => match self.vmap.navigable_below(row) {
5786                Some(r) => self.row_target(r, goal),
5787                None => self.reachable_end(),
5788            },
5789        };
5790        self.step_vertical(target, goal, extend);
5791    }
5792
5793    /// Land a vertical motion at `target`, latching the `goal` column it aimed
5794    /// with so the rest of the run keeps aiming there.
5795    ///
5796    /// A motion with nowhere to go changes *nothing*, the goal column included:
5797    /// the latch used to run before the early return at the top of the document,
5798    /// so an Up that did nothing still armed a column, and the next Down aimed
5799    /// at one the caret had never been in.
5800    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
5801        let before = self.caret;
5802        if target == before {
5803            return;
5804        }
5805        self.goal_col = Some(goal);
5806        self.move_to(target, extend);
5807        self.debug_assert_on_a_stop(before);
5808    }
5809
5810    /// The source line below `row`, or `None` when `row` is the last one. Lines
5811    /// are counted by newline, so a trailing one leaves a real, empty last line
5812    /// for the caret to sit on — the document ends below it, not on it.
5813    fn source_row_below(&self, row: usize) -> Option<usize> {
5814        let last = self.source.bytes().filter(|&b| b == b'\n').count();
5815        (row < last).then_some(row + 1)
5816    }
5817
5818    /// Where a vertical motion aiming at the `goal` column lands on visual row
5819    /// `r`: the column clamped to the row, mapped to its offset, then held
5820    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
5821    /// column belongs to the row below, and a gutter's column 0 points at the
5822    /// block rather than at this row.
5823    fn row_target(&self, r: usize, goal: usize) -> usize {
5824        let (start, end) = self.row_bounds(r);
5825        self.vmap
5826            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
5827            .clamp(start, end)
5828    }
5829
5830    /// The first and last offsets the caret can reach in the active view.
5831    ///
5832    /// Not the same span in both: the source view shows every byte, so it can
5833    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
5834    /// sits below the first stop, and a document's trailing newline is drawn
5835    /// nowhere and so sits past the last.
5836    fn reachable_start(&self) -> usize {
5837        match self.view {
5838            View::Source => 0,
5839            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
5840        }
5841    }
5842
5843    fn reachable_end(&self) -> usize {
5844        match self.view {
5845            View::Source => self.source.len(),
5846            View::Wysiwyg => self
5847                .vmap
5848                .stop_at_or_before(self.source.len())
5849                .unwrap_or(self.caret),
5850        }
5851    }
5852
5853    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
5854    /// including the space a soft wrap ate off its end, which is drawn on this
5855    /// row however much the offset past it belongs to the next one.
5856    fn row_span(&self, r: usize) -> (usize, usize) {
5857        let start = self
5858            .vmap
5859            .row_start(r)
5860            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
5861        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
5862        (start.min(end), end)
5863    }
5864
5865    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
5866    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
5867    /// this row's last position is the one before it — the offset before the
5868    /// space the wrap ate, where the caret draws just past the row's last word
5869    /// and types there too.
5870    ///
5871    /// Aiming at the shared offset instead is what stalled End: it is the row's
5872    /// last *column*, so End pressed on the row reached it and then read back as
5873    /// the row below's start, where a second press ran on to that row's end and
5874    /// the next to the one after — End walking down the paragraph a row a press.
5875    fn row_bounds(&self, r: usize) -> (usize, usize) {
5876        let (start, end) = self.row_span(r);
5877        let wraps = self
5878            .vmap
5879            .navigable_below(r)
5880            .and_then(|b| self.vmap.row_start(b))
5881            .is_some_and(|off| off == end);
5882        match wraps {
5883            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5884            false => (start, end),
5885        }
5886    }
5887
5888    /// The `[start, end]` of the line Home and End aim at: the visual row in
5889    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5890    ///
5891    /// A soft-wrapped row is a line here, because it is one to the eye and the
5892    /// eye is what these keys are aimed by — a reader pressing End means the end
5893    /// of the line they can see. (`select_block_at` wants the opposite and reads
5894    /// the AST for it: a triple-click grabs the whole paragraph, however many
5895    /// rows it folds into.)
5896    fn line_bounds(&self) -> (usize, usize) {
5897        let (row, _) = self.caret_pos();
5898        match self.view {
5899            View::Source => {
5900                let start = line_start(&self.source, row);
5901                (start, line_end_from(&self.source, start))
5902            }
5903            View::Wysiwyg => self.row_bounds(row),
5904        }
5905    }
5906
5907    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5908    /// *drawn* — what a kill takes.
5909    ///
5910    /// The two part only at a soft wrap, over the space the wrap ate: the caret
5911    /// can't stand after it (that offset opens the row below, and End stopping
5912    /// there would walk), but it is on this row, and a kill that spared it would
5913    /// leave a double space behind where the row's text had been. Deleting it
5914    /// joins nothing — a wrap is drawn, not written.
5915    fn line_span(&self) -> (usize, usize) {
5916        let (row, _) = self.caret_pos();
5917        match self.view {
5918            View::Source => self.line_bounds(),
5919            View::Wysiwyg => self.row_span(row),
5920        }
5921    }
5922
5923    /// The first offset in `[start, end]` holding something other than
5924    /// whitespace, or `end` when the line holds nothing else — where Home aims.
5925    ///
5926    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5927    /// so a hidden delimiter is never taken for the line's first character (nor
5928    /// landed on), and the source view steps the source it is showing.
5929    fn first_non_space(&self, start: usize, end: usize) -> usize {
5930        let mut off = start;
5931        while off < end {
5932            if self.class_at(off) != Class::Space {
5933                return off;
5934            }
5935            off = match self.view {
5936                View::Source => next_boundary(&self.source, off),
5937                View::Wysiwyg => match self.vmap.stop_after(off) {
5938                    Some(next) => next,
5939                    None => return end,
5940                },
5941            };
5942        }
5943        end
5944    }
5945
5946    /// Home: to the first character on the line, or to column 0 when the caret
5947    /// is already on it — the two-press toggle every editor spells this way.
5948    /// The indentation is somewhere the caret has to be able to reach and almost
5949    /// never where a reader is headed, so it costs the second press.
5950    pub fn move_home(&mut self, extend: bool) {
5951        self.goal_col = None;
5952        let (start, end) = self.line_bounds();
5953        let text = self.first_non_space(start, end);
5954        let target = if self.caret == text { start } else { text };
5955        let before = self.caret;
5956        self.move_to(target, extend);
5957        self.debug_assert_on_a_stop(before);
5958    }
5959
5960    /// End: to the end of the line.
5961    pub fn move_end(&mut self, extend: bool) {
5962        self.goal_col = None;
5963        let (_, end) = self.line_bounds();
5964        let before = self.caret;
5965        self.move_to(end, extend);
5966        self.debug_assert_on_a_stop(before);
5967    }
5968
5969    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5970    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5971    /// when the caret isn't in a table, or is already in the last/first cell — the
5972    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5973    /// meaning everywhere else.
5974    pub fn cell_hop(&mut self, forward: bool) -> bool {
5975        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5976            return false;
5977        };
5978        // Flatten to document (row-major) order and step one cell either way.
5979        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5980        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5981        let next = if forward {
5982            i.checked_add(1)
5983        } else {
5984            i.checked_sub(1)
5985        };
5986        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5987            return false; // at the table's edge; leave Tab to the frontend
5988        };
5989        self.select_cell(start, end);
5990        true
5991    }
5992
5993    /// Move the caret to the cell directly above (`down == false`) or below in
5994    /// the same column, landing with the cell's whole content selected (see
5995    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5996    /// when the caret isn't in a table), so the frontend can fall through — the
5997    /// vertical counterpart of [`Self::cell_hop`].
5998    ///
5999    /// A ragged row that is short a column clamps to its last cell, so Down never
6000    /// falls out of the table over a gap the row above happened to have.
6001    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
6002        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
6003            return false;
6004        };
6005        let target = match down {
6006            true => r + 1,
6007            false if r == 0 => return false,
6008            false => r - 1,
6009        };
6010        let Some(row) = grid.get(target) else {
6011            return false;
6012        };
6013        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
6014            return false;
6015        };
6016        self.select_cell(start, end);
6017        true
6018    }
6019
6020    /// The table containing `off` as a row-major grid of `(start, end)` cell
6021    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
6022    /// isn't in a table. Read straight off the visual map's laid-out grid, so
6023    /// every cell (an empty one included, whose derived home twig gives no
6024    /// `content_span` for) is present and in the order Tab walks them.
6025    // Grid, row, column — three returns that only ever travel together, and a
6026    // named type for the pair of them would be read at one call site.
6027    #[allow(clippy::type_complexity)]
6028    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
6029        for t in &self.vmap.tables {
6030            let mut pos = None;
6031            let grid: Vec<Vec<(usize, usize)>> = t
6032                .grid
6033                .iter()
6034                .enumerate()
6035                .map(|(r, row)| {
6036                    row.cells
6037                        .iter()
6038                        .enumerate()
6039                        .map(|(c, cell)| {
6040                            if pos.is_none() && off >= cell.start && off <= cell.end {
6041                                pos = Some((r, c));
6042                            }
6043                            (cell.start, cell.end)
6044                        })
6045                        .collect()
6046                })
6047                .collect();
6048            if let Some((r, c)) = pos {
6049                return Some((grid, r, c));
6050            }
6051        }
6052        None
6053    }
6054
6055    // ── table key policy ──────────────────────────────────────────────────────
6056    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
6057    // as one policy every frontend shares, rather than each re-deriving it. Each
6058    // reports whether it acted *as a table key*; a `false` hands the key back to
6059    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
6060    // everywhere else.
6061
6062    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
6063    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
6064    /// back and simply stays put at the very first cell. `false` when the caret
6065    /// isn't in a table.
6066    pub fn cell_tab(&mut self, forward: bool) -> bool {
6067        if !self.caret_in_table() {
6068            return false;
6069        }
6070        if self.cell_hop(forward) {
6071            return true;
6072        }
6073        // Off the last cell: grow the table by a row and step into its first
6074        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
6075        if forward {
6076            self.append_row_and_enter(0);
6077        }
6078        true
6079    }
6080
6081    /// Return inside a table: drop to the cell below in the same column,
6082    /// appending a new row when the caret is already in the last one. `false`
6083    /// when the caret isn't in a table, so the frontend inserts a newline.
6084    pub fn cell_return(&mut self) -> bool {
6085        if !self.caret_in_table() {
6086            return false;
6087        }
6088        if self.cell_move_vertical(true) {
6089            return true;
6090        }
6091        // Already on the last row: grow one below and drop into the same column.
6092        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
6093        self.append_row_and_enter(col);
6094        true
6095    }
6096
6097    /// Append a row below the caret's (last) row and land in `col` of it. The
6098    /// caret is in the last row, so twig's "insert below" makes the fresh row the
6099    /// table's new last — but twig re-spells the whole table, moving every byte,
6100    /// so the destination is read back from the rebuilt grid by the table's
6101    /// position (stable across a row insert), not from the pre-edit caret.
6102    fn append_row_and_enter(&mut self, col: usize) {
6103        let table = self.caret_table_index();
6104        self.table_insert_row(true);
6105        self.rebuild_map();
6106        let Some((start, end)) = table
6107            .and_then(|ti| self.vmap.tables.get(ti))
6108            .and_then(|t| t.grid.last())
6109            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
6110            .map(|cell| (cell.start, cell.end))
6111        else {
6112            return;
6113        };
6114        self.select_cell(start, end);
6115    }
6116
6117    /// The index, among the document's tables, of the one the caret sits in —
6118    /// `None` when it's in none. Used to re-find a table after an edit re-spells
6119    /// it (a row insert leaves the table order unchanged).
6120    fn caret_table_index(&self) -> Option<usize> {
6121        let off = self.caret;
6122        self.vmap.tables.iter().position(|t| {
6123            t.grid
6124                .iter()
6125                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
6126        })
6127    }
6128
6129    /// Shift+Return inside a table: insert a hard line break *within* the current
6130    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
6131    /// table, so the frontend inserts an ordinary line break.
6132    ///
6133    /// A table row is a single source line, so the newline-spelled hard break
6134    /// can't live in a cell. twig spells the in-cell break the format's way
6135    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
6136    /// break round-trips as structure the renderer reads back as a line — not the
6137    /// opaque raw HTML the old raw-splice left behind.
6138    ///
6139    /// Djot has no idiomatic in-cell break, so twig refuses it
6140    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
6141    /// would render as the literal text `<br>`. The gesture is still *consumed*
6142    /// there — returning `false` would let the frontend insert a real newline,
6143    /// which splits the one-line row — it just leaves the cell unchanged and says
6144    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
6145    ///
6146    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
6147    /// have to be read together: djot is not the only `false`, and naming it in
6148    /// the message was already a guess that HTML — which spells the break as its
6149    /// own `<br>` — would have made wrong.
6150    pub fn cell_line_break(&mut self) -> bool {
6151        if self.read_only || !self.caret_in_table() {
6152            return false;
6153        }
6154        self.record_caret();
6155        match self.editor.insert_line_break(self.caret) {
6156            Ok(change) => {
6157                self.last_edit_kind = None;
6158                self.refresh();
6159                self.caret = change.new.end;
6160                self.anchor = None;
6161                self.goal_col = None;
6162                self.clamp_caret();
6163                self.dirty = self.source != self.clean_source;
6164                self.status = None;
6165                self.record_caret();
6166            }
6167            Err(twig::Error::UnsupportedFormat) => {
6168                self.status = Some(format!(
6169                    "in-cell line breaks aren't supported in {}",
6170                    self.format_name()
6171                ));
6172            }
6173            Err(_) => {}
6174        }
6175        true
6176    }
6177
6178    /// Rebuild the visual map at the width the last build used. A structural edit
6179    /// bumps the revision and swaps the source in, but leaves the *map* stale;
6180    /// when a single gesture edits and then moves over the result (Tab appending
6181    /// a row, then stepping into it), the move needs the map to already show the
6182    /// edit rather than waiting for the frontend's next frame.
6183    fn rebuild_map(&mut self) {
6184        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
6185        self.build_map(wrap);
6186    }
6187
6188    /// Move the caret to the very start of the document (⌘↑ on macOS,
6189    /// Ctrl+Home on Windows/Linux).
6190    pub fn move_doc_start(&mut self, extend: bool) {
6191        self.goal_col = None;
6192        self.move_to(0, extend);
6193    }
6194
6195    /// Move the caret to the very end of the document (⌘↓ on macOS,
6196    /// Ctrl+End on Windows/Linux).
6197    pub fn move_doc_end(&mut self, extend: bool) {
6198        self.goal_col = None;
6199        let end = self.source.len();
6200        self.move_to(end, extend);
6201    }
6202
6203    /// Point the caret at the body cell `(row, col)` the mouse landed on —
6204    /// `col` being a cell of the terminal grid, which is what a display column
6205    /// is. A click on the far cell of a wide character lands at that
6206    /// character's start; the mapping's own doc-comments carry the rule.
6207    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
6208        self.goal_col = None;
6209        let target = match self.view {
6210            View::Source => row_col_to_offset(&self.source, row, col),
6211            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
6212        };
6213        let before = self.caret;
6214        self.move_to(target, extend);
6215        self.debug_assert_on_a_stop(before);
6216    }
6217
6218    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
6219    /// screen if it has moved since the last frame, and never scroll past the
6220    /// last of `rows`.
6221    ///
6222    /// Only if it has *moved* — that's the whole point. Revealing the caret on
6223    /// every frame ties the viewport to it, and a scroll wheel that fights the
6224    /// caret for the viewport loses: the view snaps back the instant it tries to
6225    /// pass the caret's row, so the document can't be scrolled beyond what's
6226    /// already on screen. A caret move is the frontend's cue to follow; a scroll
6227    /// with the caret sitting still is the reader's cue to leave it alone.
6228    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
6229        if self.drawn_caret != Some(self.caret) {
6230            if caret_row < self.scroll {
6231                self.scroll = caret_row;
6232            } else if height > 0 && caret_row >= self.scroll + height {
6233                self.scroll = caret_row + 1 - height;
6234            }
6235            self.drawn_caret = Some(self.caret);
6236        }
6237        self.scroll = self.scroll.min(rows.saturating_sub(1));
6238    }
6239
6240    /// The caret's screen position `(row, col)` in the active view's grid, with
6241    /// `col` a display column: the cell to draw the caret in, which on a line of
6242    /// `你好` or emoji is not the count of characters before it.
6243    pub fn caret_pos(&self) -> (usize, usize) {
6244        match self.view {
6245            View::Source => offset_to_row_col(&self.source, self.caret),
6246            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
6247        }
6248    }
6249
6250    fn clamp_caret(&mut self) {
6251        if self.caret > self.source.len() {
6252            self.caret = self.source.len();
6253        }
6254        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
6255        // any selection anchor) to the first rendered offset.
6256        let floor = self.caret_floor();
6257        if self.caret < floor {
6258            self.caret = floor;
6259        }
6260        if let Some(a) = self.anchor
6261            && a < floor
6262        {
6263            self.anchor = Some(floor);
6264        }
6265        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
6266            self.caret -= 1;
6267        }
6268    }
6269}
6270
6271// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
6272
6273// Left/right motion and backspace/delete step by *grapheme cluster*, not
6274// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
6275// marks moves and deletes as the single character a user sees. Grapheme
6276// boundaries are a superset of char boundaries, so the caret stays valid for twig.
6277
6278/// How an insert of `text` groups for undo: a single typed character folds into
6279/// the run of typing around it, while a newline or a multi-character insert is a
6280/// step of its own.
6281fn typed_edit_kind(text: &str) -> EditKind {
6282    if text.chars().take(2).count() == 1 && text != "\n" {
6283        EditKind::Insert
6284    } else {
6285        EditKind::Other
6286    }
6287}
6288
6289fn prev_boundary(s: &str, i: usize) -> usize {
6290    let mut cursor = GraphemeCursor::new(i, s.len(), true);
6291    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
6292}
6293
6294fn next_boundary(s: &str, i: usize) -> usize {
6295    let mut cursor = GraphemeCursor::new(i, s.len(), true);
6296    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
6297}
6298
6299// ── word boundaries ──────────────────────────────────────────────────────────
6300// The shared primitive behind word-wise motion, word deletion, and
6301// double-click-to-select-a-word. A "word" is a maximal run of one character
6302// class; whitespace and punctuation are their own classes, so motion skips
6303// cleanly between them the way native text fields do.
6304
6305#[derive(PartialEq, Eq, Clone, Copy)]
6306enum Class {
6307    Word,
6308    Space,
6309    Other,
6310}
6311
6312/// The source range of an inline node's own visible text — the part of it a
6313/// WYSIWYG caret can reach, as against the delimiters that only spell it.
6314/// `None` for a node with no interior to empty (a `str`, a break).
6315///
6316/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
6317/// one delimiter in from the span — the same place the renderer maps it to. A
6318/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
6319/// against the source rather than trusted: a range guessed wrong here is text
6320/// deleted wrong.
6321fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
6322    if let Some(span) = n.content_span.clone() {
6323        return Some(span);
6324    }
6325    match n.kind.as_str() {
6326        "verbatim" | "inline_math" => {
6327            let text = n.text.as_ref()?;
6328            let start = n.span.start + 1;
6329            let range = start..start + text.len();
6330            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
6331        }
6332        _ => None,
6333    }
6334}
6335
6336/// The `id` a node declares, or `None` for one that declares none — the
6337/// attribute djot writes for a `{#v1}` and mints for a heading.
6338///
6339/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
6340/// names nothing, so it reads as absent rather than as the empty string.
6341fn declared_id(n: &FlatNode) -> Option<&str> {
6342    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
6343}
6344
6345/// A heading's words reduced to the form a link fragment spells them in:
6346/// lowercase, runs of anything else collapsed to a single `-`, with none left
6347/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
6348///
6349/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
6350/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
6351/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
6352/// any other language is still a heading someone will link to. Underscores
6353/// survive for the same reason they do on the web: they are word characters
6354/// wherever identifiers are written.
6355fn slug(text: &str) -> String {
6356    let mut out = String::new();
6357    let mut pending = false;
6358    for c in text.chars() {
6359        if c.is_alphanumeric() || c == '_' {
6360            if pending && !out.is_empty() {
6361                out.push('-');
6362            }
6363            pending = false;
6364            out.extend(c.to_lowercase());
6365        } else {
6366            pending = true;
6367        }
6368    }
6369    out
6370}
6371
6372fn is_block_container(kind: &Kind) -> bool {
6373    matches!(
6374        kind,
6375        Kind::Doc
6376            | Kind::Section
6377            | Kind::BlockQuote
6378            | Kind::BulletList
6379            | Kind::OrderedList
6380            | Kind::TaskList
6381            | Kind::ListItem
6382            | Kind::TaskListItem
6383            // Every `container` — a directive in any of its three forms, or a
6384            // promoted HTML element. A *text* directive is really inline, so
6385            // claiming it here is a small overreach, and the deliberate one this
6386            // function's kind-only peer `is_inline_kind` documents: the pair is
6387            // consulted together, and answering "block container" for something
6388            // inline is what keeps an ancestor walk from stopping short of the
6389            // paragraph that actually holds it.
6390            | Kind::Container
6391    )
6392}
6393
6394/// The `[start, end)` byte range of the source line containing `off` (newline
6395/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
6396/// line between paragraphs).
6397fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
6398    let off = off.min(s.len());
6399    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
6400    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
6401    start..end
6402}
6403
6404/// How many leading bytes an outdent takes off `line`: a whole indent level
6405/// where the line has one, and whatever it has where it has less.
6406///
6407/// A leading tab counts as a level on its own. It's indentation some other
6408/// editor wrote, and one tab is one level everywhere it came from — measuring it
6409/// in spaces it doesn't contain would leave it untouchable.
6410fn outdent_width(line: &str, unit: usize) -> usize {
6411    if line.starts_with('\t') {
6412        return 1;
6413    }
6414    line.bytes().take(unit).take_while(|b| *b == b' ').count()
6415}
6416
6417/// A list marker found at the head of a line, together with everything before it
6418/// that a sibling line has to repeat.
6419///
6420/// The three offsets differ only inside a block quote, where `>   - b` opens with
6421/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
6422/// `line_start == marker_start`, and `text` is the plain `"  - "`.
6423#[derive(Clone, Debug)]
6424struct ListMarker {
6425    /// The line's first byte.
6426    line_start: usize,
6427    /// Where the marker proper begins, past any quote prefix. The offset to hand
6428    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
6429    marker_start: usize,
6430    /// `line_start` through the marker's trailing space — quote prefix, indent
6431    /// and bullet together, which is what the next item's line opens with.
6432    text: String,
6433}
6434
6435impl ListMarker {
6436    /// Where the item's content starts — one past the marker's trailing space.
6437    fn content_start(&self) -> usize {
6438        self.line_start + self.text.len()
6439    }
6440}
6441
6442fn classify(c: char) -> Class {
6443    if c == '_' || c.is_alphanumeric() {
6444        Class::Word
6445    } else if c.is_whitespace() {
6446        Class::Space
6447    } else {
6448        Class::Other
6449    }
6450}
6451
6452/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
6453/// skip any leading separators, then consume the following word run.
6454fn next_word(s: &str, i: usize) -> usize {
6455    let mut off = i;
6456    let mut in_word = false;
6457    for c in s[i..].chars() {
6458        if classify(c) == Class::Word {
6459            in_word = true;
6460        } else if in_word {
6461            break;
6462        }
6463        off += c.len_utf8();
6464    }
6465    off
6466}
6467
6468/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
6469/// skip separators walking left, then consume the preceding word run.
6470fn prev_word(s: &str, i: usize) -> usize {
6471    let mut off = i;
6472    let mut in_word = false;
6473    for c in s[..i].chars().rev() {
6474        if classify(c) == Class::Word {
6475            in_word = true;
6476        } else if in_word {
6477            break;
6478        }
6479        off -= c.len_utf8();
6480    }
6481    off
6482}
6483
6484/// The `[start, end)` run of same-class characters surrounding `off` — the
6485/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
6486/// the run ending there is used.
6487fn word_range_at(s: &str, off: usize) -> (usize, usize) {
6488    if s.is_empty() {
6489        return (0, 0);
6490    }
6491    let off = off.min(s.len());
6492    let reference = if off < s.len() {
6493        s[off..].chars().next()
6494    } else {
6495        s[..off].chars().next_back()
6496    };
6497    let Some(rc) = reference else {
6498        return (off, off);
6499    };
6500    let class = classify(rc);
6501
6502    let mut start = off;
6503    for c in s[..start].chars().rev() {
6504        if classify(c) == class {
6505            start -= c.len_utf8();
6506        } else {
6507            break;
6508        }
6509    }
6510    let mut end = off;
6511    for c in s[end..].chars() {
6512        if classify(c) == class {
6513            end += c.len_utf8();
6514        } else {
6515            break;
6516        }
6517    }
6518    (start, end)
6519}
6520
6521/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
6522/// the line's start — terminal cells, not characters, so the column names the
6523/// cell the caret is drawn in even on a line of `你好` or emoji.
6524fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
6525    let off = off.min(s.len());
6526    let mut row = 0;
6527    let mut line_start = 0;
6528    for (i, &b) in s.as_bytes().iter().enumerate() {
6529        if i >= off {
6530            break;
6531        }
6532        if b == b'\n' {
6533            row += 1;
6534            line_start = i + 1;
6535        }
6536    }
6537    (row, wysiwyg::text_width(&s[line_start..off]))
6538}
6539
6540/// The byte offset at display column `col` of `row` (clamped to that line's
6541/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
6542///
6543/// A column landing *inside* a character — the second cell of `你`, or any cell
6544/// but the first of an emoji — resolves to that character's start, which is the
6545/// column the caret would have been drawn at to begin with. So both cells of a
6546/// wide character mean the character, and every offset survives the round trip
6547/// out to a column and back. The walk steps by grapheme cluster for the same
6548/// reason the caret does: a cluster is the character, and the cells belong to it
6549/// rather than to the codepoints spelling it.
6550fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
6551    let start = line_start(s, row);
6552    let end = line_end_from(s, start);
6553    let mut off = start;
6554    let mut at = 0; // the display column `off` sits at
6555    while off < end {
6556        let next = next_boundary(s, off).min(end);
6557        let cells = wysiwyg::text_width(&s[off..next]);
6558        if at + cells > col {
6559            break; // `col` is one of this cluster's own cells
6560        }
6561        at += cells;
6562        off = next;
6563    }
6564    off
6565}
6566
6567fn line_start(s: &str, row: usize) -> usize {
6568    if row == 0 {
6569        return 0;
6570    }
6571    let mut r = 0;
6572    for (i, &b) in s.as_bytes().iter().enumerate() {
6573        if b == b'\n' {
6574            r += 1;
6575            if r == row {
6576                return i + 1;
6577            }
6578        }
6579    }
6580    s.len()
6581}
6582
6583fn line_end_from(s: &str, start: usize) -> usize {
6584    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
6585}
6586
6587/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
6588/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
6589/// twig applies writing the mark out, so the toolbar can light the same button
6590/// that made the node.
6591///
6592/// `None` for every other kind, including the inline nodes that aren't marks at
6593/// all (`str`, `link`, `image`, the math and break kinds): they're things a
6594/// caret stands in, not formatting a button toggles.
6595fn inline_kind(kind: &Kind) -> Option<InlineKind> {
6596    Some(match kind {
6597        Kind::Strong => InlineKind::Strong,
6598        Kind::Emph => InlineKind::Emph,
6599        Kind::Verbatim => InlineKind::Verbatim,
6600        Kind::Mark => InlineKind::Mark,
6601        Kind::Superscript => InlineKind::Superscript,
6602        Kind::Subscript => InlineKind::Subscript,
6603        Kind::Insert => InlineKind::Insert,
6604        Kind::Delete => InlineKind::Delete,
6605        _ => return None,
6606    })
6607}
6608
6609/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
6610/// a highlight's opening `==`.
6611///
6612/// Two enums for one closed vocabulary, and the duplication is the boundary
6613/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
6614/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
6615/// the editor writes. Spelled as a match rather than routed through the two
6616/// crates' name strings so that a colour added on either side is a compile
6617/// error here, where the pairing is decided, rather than a runtime `None` that
6618/// would read as "clear the colour".
6619fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
6620    match color {
6621        MarkColor::Red => twig::MarkColor::Red,
6622        MarkColor::Orange => twig::MarkColor::Orange,
6623        MarkColor::Yellow => twig::MarkColor::Yellow,
6624        MarkColor::Green => twig::MarkColor::Green,
6625        MarkColor::Blue => twig::MarkColor::Blue,
6626        MarkColor::Purple => twig::MarkColor::Purple,
6627        MarkColor::Brown => twig::MarkColor::Brown,
6628    }
6629}
6630
6631/// Where an offset lands after a splice it didn't make — twig's own rule, from
6632/// [`Change`]: shift anything at or past the replaced range's end by the length
6633/// the replacement gained or lost, and leave anything before it alone.
6634///
6635/// An offset *inside* the replaced range has no text of its own to ride any
6636/// more, and lands at the end of what replaced it: for
6637/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
6638/// the prefix is cleared, which then sits where the highlighted text begins.
6639fn reanchor(off: usize, change: &Change) -> usize {
6640    if off < change.old.start {
6641        return off;
6642    }
6643    if off < change.old.end {
6644        return change.new.end;
6645    }
6646    (off + change.new.end).saturating_sub(change.old.end)
6647}
6648
6649/// A watermark for a file's contents (see `Doc::disk_hash`).
6650///
6651/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
6652/// watermark is compared only against one taken by the same process moments
6653/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
6654/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
6655fn hash_bytes(bytes: &[u8]) -> u64 {
6656    use std::hash::{Hash, Hasher};
6657    let mut h = std::collections::hash_map::DefaultHasher::new();
6658    bytes.hash(&mut h);
6659    h.finish()
6660}
6661
6662#[cfg(feature = "fs")]
6663fn detect_format(path: &Path) -> Result<Format> {
6664    let ext = path
6665        .extension()
6666        .and_then(|e| e.to_str())
6667        .unwrap_or("")
6668        .to_ascii_lowercase();
6669    Ok(match ext.as_str() {
6670        "dj" | "djot" => Format::Djot,
6671        "md" | "markdown" => Format::Markdown,
6672        "xml" => Format::Xml,
6673        "html" | "htm" => Format::Html,
6674        other => return Err(anyhow!("unknown document extension: .{other}")),
6675    })
6676}
6677
6678#[cfg(test)]
6679mod tests {
6680    use super::*;
6681
6682    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
6683    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
6684    /// without one is a view no user is ever in.
6685    fn doc_in(view: View, name: &str, body: &str) -> Doc {
6686        // The fixture name doubles as the temp file's, so two tests picking the
6687        // same one raced under the parallel runner and read each other's body —
6688        // a green suite proving the wrong thing. The counter makes that
6689        // unreachable rather than asking every future caller to notice.
6690        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6691        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6692        let mut p = std::env::temp_dir();
6693        p.push(format!("leaf_test_{name}_{seq}.md"));
6694        std::fs::write(&p, body).unwrap();
6695        let mut d = Doc::open(p).unwrap();
6696        d.view = view;
6697        if view == View::Wysiwyg {
6698            d.build_visual(80);
6699        }
6700        d
6701    }
6702
6703    // Source-view document for the source-behaviour tests. `Doc::open` now
6704    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
6705    // `wysiwyg_doc` builds the rich-text variant on top of this.
6706    fn doc_with(name: &str, body: &str) -> Doc {
6707        doc_in(View::Source, name, body)
6708    }
6709
6710    /// Every visual row's drawn text — what the reader actually sees, which is
6711    /// the only thing the reveal preference is supposed to change.
6712    fn drawn_rows(d: &Doc) -> Vec<String> {
6713        d.vmap
6714            .rows
6715            .iter()
6716            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6717            .collect()
6718    }
6719
6720    /// Put the caret at the first byte of `needle` and rebuild, so the row under
6721    /// it becomes the revealed line.
6722    fn caret_at(d: &mut Doc, needle: &str) {
6723        d.caret = d.source.find(needle).expect("needle in source");
6724        d.build_visual(80);
6725    }
6726
6727    #[test]
6728    fn blockquote_after_a_list_is_not_bulleted() {
6729        // twig nests a following top-level block quote under the `bullet_list`
6730        // (a direct child, not a `list_item`). The map must render it de-nested —
6731        // `│ quote`, never `• │ quote` — with a blank separator, like any block
6732        // that follows a list. Regression for the "combined list + blockquote" bug.
6733        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
6734        d.build_visual(80);
6735        let rows: Vec<String> = d
6736            .vmap
6737            .rows
6738            .iter()
6739            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6740            .collect();
6741        assert!(
6742            rows.iter().any(|r| r == "│ quote"),
6743            "block quote should render on its own gutter, got rows: {rows:?}"
6744        );
6745        assert!(
6746            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
6747            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
6748        );
6749    }
6750
6751    // ── the map is built at most once per (revision, wrap) ───────────────────
6752    //
6753    // A frontend repaints for reasons that have nothing to do with the text — a
6754    // blinking caret, a scroll — and rebuilding the map is O(document). These
6755    // pin *that the cache fires*, which a passing suite can't tell you: a cache
6756    // that never hits is invisible to every other test in this file.
6757    //
6758    // The probe is to wreck the built map and ask for it again. A rebuild
6759    // repairs it; a cache hit hands the wreckage straight back. Nothing else
6760    // can distinguish the two from outside.
6761
6762    #[test]
6763    fn a_rebuild_with_nothing_changed_reuses_the_map() {
6764        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
6765        d.build_visual(80);
6766        assert!(!d.vmap.rows.is_empty());
6767        d.vmap.rows.clear(); // wreck it
6768        d.build_visual(80);
6769        assert!(
6770            d.vmap.rows.is_empty(),
6771            "the map was rebuilt though nothing changed — the cache never fired"
6772        );
6773    }
6774
6775    #[test]
6776    fn an_edit_rebuilds_the_map() {
6777        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
6778        d.build_visual(80);
6779        let before = d.revision();
6780        d.vmap.rows.clear();
6781        d.insert("x");
6782        d.build_visual(80);
6783        assert!(d.revision() > before, "an edit must move the revision");
6784        assert!(
6785            !d.vmap.rows.is_empty(),
6786            "an edited document must not paint from a stale map"
6787        );
6788    }
6789
6790    #[test]
6791    fn a_width_change_rebuilds_the_map() {
6792        // The map is a function of the wrap width too, so a resize is a miss
6793        // even though the text is untouched.
6794        let mut d = doc_in(
6795            View::Wysiwyg,
6796            "cache_width",
6797            "one two three four five six\n",
6798        );
6799        d.build_visual(80);
6800        d.vmap.rows.clear();
6801        d.build_visual(12);
6802        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
6803        // And the unwrapped map is its own key, not the same as any width.
6804        d.vmap.rows.clear();
6805        d.build_visual_unwrapped();
6806        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
6807    }
6808
6809    #[test]
6810    fn a_motion_does_not_rebuild_the_map() {
6811        // The whole point: moving the caret changes nothing the map is built
6812        // from. If a motion bumped the revision, every arrow key would cost a
6813        // full rebuild and the cache would be worthless.
6814        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
6815        d.build_visual(80);
6816        let rev = d.revision();
6817        d.move_right(false);
6818        d.move_right(true);
6819        d.move_down(false);
6820        assert_eq!(d.revision(), rev, "a motion must not move the revision");
6821        d.vmap.rows.clear();
6822        d.build_visual(80);
6823        assert!(
6824            d.vmap.rows.is_empty(),
6825            "a motion should not rebuild the map"
6826        );
6827    }
6828
6829    #[test]
6830    fn saving_does_not_rebuild_the_map() {
6831        // Saving changes `dirty`, not the text.
6832        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
6833        d.insert("x");
6834        d.build_visual(80);
6835        let rev = d.revision();
6836        d.save();
6837        assert_eq!(d.revision(), rev, "a save must not move the revision");
6838        assert!(!d.dirty, "the save should have cleaned the document");
6839    }
6840
6841    #[test]
6842    fn a_reload_rebuilds_the_map() {
6843        // Reload replaces the text without going through `refresh`, so it has to
6844        // move the revision itself — else the editor paints the old file.
6845        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
6846        d.build_visual(80);
6847        let rev = d.revision();
6848        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
6849        d.reload();
6850        assert!(d.revision() > rev, "a reload must move the revision");
6851        d.build_visual(80);
6852        let text: String = d
6853            .vmap
6854            .rows
6855            .iter()
6856            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6857            .collect();
6858        assert!(
6859            text.contains("wholly new"),
6860            "the reloaded text should be on screen, got {text:?}"
6861        );
6862    }
6863
6864    // ── golden-case harness ──────────────────────────────────────────────────
6865    // The pattern the whole parity suite can reuse: write a fixture with the
6866    // caret marked by `|`, run one action, and compare the rendered result —
6867    // also caret-marked — against the expected string. One readable line per
6868    // behavior, and it exercises the exact `Doc` ops both frontends call.
6869
6870    /// Split a `|`-marked fixture into `(source, caret_offset)`.
6871    fn parse_caret(marked: &str) -> (String, usize) {
6872        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
6873        (marked.replacen('|', "", 1), caret)
6874    }
6875
6876    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
6877    /// selection) so a result reads like the fixtures.
6878    fn render_caret(d: &Doc) -> String {
6879        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
6880        // so the caret always renders inside its own selection.
6881        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
6882        if let Some((s, e)) = d.selection() {
6883            marks.push((s, 0, '['));
6884            marks.push((e, 2, ']'));
6885        }
6886        // Insert right-to-left: descending offset, then descending rank.
6887        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
6888        let mut out = d.source.clone();
6889        for (at, _, ch) in marks {
6890            out.insert(at, ch);
6891        }
6892        out
6893    }
6894
6895    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
6896    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6897        golden_in(View::Source, name, marked, action)
6898    }
6899
6900    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
6901    /// the same fixture has to read the same way in both.
6902    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6903        let (src, caret) = parse_caret(marked);
6904        let mut d = doc_in(view, name, &src);
6905        d.caret = caret;
6906        action(&mut d);
6907        render_caret(&d)
6908    }
6909
6910    #[test]
6911    fn word_motion_walks_word_by_word() {
6912        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
6913        assert_eq!(
6914            g("hello wor|ld", |d| d.move_word_left(false)),
6915            "hello |world"
6916        );
6917        assert_eq!(
6918            g("hello| world", |d| d.move_word_left(false)),
6919            "|hello world"
6920        );
6921        assert_eq!(
6922            g("hel|lo world", |d| d.move_word_right(false)),
6923            "hello| world"
6924        );
6925        assert_eq!(
6926            g("hello| world", |d| d.move_word_right(false)),
6927            "hello world|"
6928        );
6929        // Punctuation is its own class, so motion stops at the boundary.
6930        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6931    }
6932
6933    #[test]
6934    fn word_motion_extends_the_selection_when_asked() {
6935        assert_eq!(
6936            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6937            "hello [world|]"
6938        );
6939    }
6940
6941    #[test]
6942    fn delete_word_removes_a_whole_word() {
6943        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6944        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6945        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6946        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6947    }
6948
6949    // ── Home / End ───────────────────────────────────────────────────────────
6950
6951    #[test]
6952    fn home_toggles_between_the_line_s_text_and_its_margin() {
6953        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6954        // indent to the markup it spells everywhere it means one, so the fixture
6955        // with whitespace left to walk is a code block, which is verbatim.
6956        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6957        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
6958        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
6959        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
6960        // A line with no indentation has one place to go, so the toggle is a
6961        // no-op rather than a trip to nowhere.
6962        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6963        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6964
6965        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
6966        let indent = d.source.find("    indented").unwrap();
6967        d.caret = indent + 6; // inside "indented"
6968        d.move_home(false);
6969        assert_eq!(
6970            d.caret,
6971            indent + 4,
6972            "wysiwyg: Home aims at the code line's text"
6973        );
6974        d.move_home(false);
6975        assert_eq!(
6976            d.caret, indent,
6977            "wysiwyg: the second press takes the indent"
6978        );
6979        d.move_home(false);
6980        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6981    }
6982
6983    #[test]
6984    fn end_takes_the_line_the_view_is_showing() {
6985        // The line differs by view for the same document, and that is the point:
6986        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6987        // as a space on one row and the source view as two lines.
6988        let mut d = doc_with("end_src", "one two\nthree\n");
6989        d.caret = 1;
6990        d.move_end(false);
6991        assert_eq!(d.caret, 7, "source: the end of the source line");
6992
6993        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6994        d.caret = 1;
6995        d.move_end(false);
6996        assert_eq!(
6997            d.caret, 13,
6998            "wysiwyg: the end of the row, soft break and all"
6999        );
7000    }
7001
7002    #[test]
7003    fn home_and_end_extend_the_selection_when_asked() {
7004        for (view, tag) in VIEWS {
7005            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
7006            d.caret = 6;
7007            d.move_end(true);
7008            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
7009            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
7010            d.caret = 6;
7011            d.move_home(true);
7012            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
7013        }
7014    }
7015
7016    // ── kill to the line's start / end ───────────────────────────────────────
7017
7018    #[test]
7019    fn kill_to_the_line_start_and_end_in_both_views() {
7020        for (view, tag) in VIEWS {
7021            // The gap that reads as a paragraph break in each view: the source
7022            // view's lines are the renderer's rows only where the source says so.
7023            let gap = if view == View::Source { "\n" } else { "\n\n" };
7024            let mut d = doc_in(
7025                view,
7026                &format!("kill_end_{tag}"),
7027                &format!("one two{gap}three\n"),
7028            );
7029            d.caret = 3;
7030            d.delete_to_line_end();
7031            assert_eq!(
7032                d.source,
7033                format!("one{gap}three\n"),
7034                "{tag}: ^K to the line's end"
7035            );
7036            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
7037
7038            let mut d = doc_in(
7039                view,
7040                &format!("kill_start_{tag}"),
7041                &format!("one two{gap}three\n"),
7042            );
7043            d.caret = 7; // the end of the first line
7044            d.delete_to_line_start();
7045            assert_eq!(
7046                d.source,
7047                format!("{gap}three\n"),
7048                "{tag}: ⌘⌫ to the line's start"
7049            );
7050            assert_eq!(d.caret, 0, "{tag}");
7051        }
7052    }
7053
7054    #[test]
7055    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
7056        // The decision: at the boundary both kills do nothing, rather than
7057        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
7058        // at a soft wrap as often as at a newline, where there is nothing
7059        // written to delete — and a source newline is only half of the blank
7060        // line between two paragraphs, so taking it leaves a soft break rather
7061        // than the join it looks like. Backspace and Delete are the keys for it.
7062        for (view, tag) in VIEWS {
7063            let gap = if view == View::Source { "\n" } else { "\n\n" };
7064            let src = format!("one{gap}three\n");
7065            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
7066            d.caret = 3; // the end of "one"
7067            d.delete_to_line_end();
7068            assert_eq!(
7069                d.source, src,
7070                "{tag}: ^K at the line's end joined it to the next"
7071            );
7072
7073            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
7074            d.caret = 3 + gap.len(); // the start of "three"
7075            d.delete_to_line_start();
7076            assert_eq!(
7077                d.source, src,
7078                "{tag}: ⌘⌫ at the line's start joined it to the last"
7079            );
7080        }
7081    }
7082
7083    #[test]
7084    fn a_kill_takes_the_selection_when_there_is_one() {
7085        // What every other delete here does with one, so these two as well.
7086        for (view, tag) in VIEWS {
7087            for (name, kill) in [
7088                (
7089                    "end",
7090                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
7091                ),
7092                ("start", |d: &mut Doc| d.delete_to_line_start()),
7093            ] {
7094                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
7095                d.anchor = Some(4);
7096                d.caret = 7; // "two"
7097                kill(&mut d);
7098                assert_eq!(
7099                    d.source, "one  three\n",
7100                    "{tag}: {name} ignored the selection"
7101                );
7102                assert_eq!(d.selection(), None, "{tag}: {name}");
7103            }
7104        }
7105    }
7106
7107    #[test]
7108    fn a_kill_takes_the_markup_it_empties_with_it() {
7109        // The same hazard a word-delete has: a WYSIWYG range covers what the
7110        // user can see, which for `**bold**` is the word and never the
7111        // delimiters, so a kill that stopped at the text would leave `a ****` —
7112        // markup wrapped around nothing.
7113        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
7114        d.caret = d.source.find("bold").unwrap();
7115        d.delete_to_line_end();
7116        assert_eq!(d.source, "a \n");
7117    }
7118
7119    #[test]
7120    fn a_kill_is_undone_in_one_step() {
7121        for (view, tag) in VIEWS {
7122            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
7123            d.caret = 3;
7124            d.delete_to_line_end();
7125            assert_eq!(d.source, "one\n", "{tag}");
7126            d.undo();
7127            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
7128        }
7129    }
7130
7131    #[test]
7132    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
7133        // Regression: triple-click used move_home/move_end over visual rows, so
7134        // it only worked on a paragraph's first row (a wrap-boundary offset maps
7135        // to the earlier row). select_block_at reads the AST, so every offset in
7136        // the paragraph selects the whole thing.
7137        let body = "one two three four five six seven eight\n";
7138        let mut d = doc_with("sel_block", body);
7139        d.view = View::Wysiwyg;
7140        d.build_visual(12); // force the paragraph to wrap into several rows
7141        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
7142        let para = (0, "one two three four five six seven eight".len());
7143        for off in [0usize, 8, 19, 28, 38] {
7144            d.caret = 0;
7145            d.anchor = None;
7146            d.select_block_at(off);
7147            assert_eq!(
7148                d.selection(),
7149                Some(para),
7150                "offset {off} should select the paragraph"
7151            );
7152        }
7153    }
7154
7155    #[test]
7156    fn select_block_uses_content_span_for_a_heading() {
7157        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
7158        d.select_block_at(4); // inside "Title"
7159        // content_span excludes the "# " marker.
7160        assert_eq!(d.selected_text(), Some("Title"));
7161        d.select_block_at(10); // inside "body"
7162        assert_eq!(d.selected_text(), Some("body"));
7163    }
7164
7165    #[test]
7166    fn select_all_spans_the_document() {
7167        let mut d = doc_with("sel_all", "abc\n\ndef\n");
7168        d.select_all();
7169        assert_eq!(d.selection(), Some((0, d.source.len())));
7170    }
7171
7172    #[test]
7173    fn select_word_at_picks_the_surrounding_word() {
7174        let mut d = doc_with("sel_word", "hello world\n");
7175        d.select_word_at(8); // inside "world"
7176        assert_eq!(d.selection(), Some((6, 11)));
7177        // Double-clicking at end-of-word still grabs the word to its left.
7178        d.select_word_at(5); // the space between the words
7179        assert_eq!(d.selection(), Some((5, 6)));
7180    }
7181
7182    #[test]
7183    fn word_helpers_respect_utf8_boundaries() {
7184        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
7185        assert_eq!(
7186            golden("utf8", "|café ok", |d| d.move_word_right(false)),
7187            "café| ok"
7188        );
7189        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
7190    }
7191
7192    #[test]
7193    fn typing_inserts_at_the_caret_and_advances_it() {
7194        let mut d = doc_with("type", "hello\n");
7195        d.insert("Hi ");
7196        assert_eq!(d.source, "Hi hello\n");
7197        assert_eq!(d.caret, 3);
7198        assert!(d.dirty);
7199    }
7200
7201    #[test]
7202    fn backspace_deletes_the_char_before_the_caret() {
7203        let mut d = doc_with("bs", "hello\n");
7204        d.caret = 3; // after "hel"
7205        d.backspace();
7206        assert_eq!(d.source, "helo\n");
7207        assert_eq!(d.caret, 2);
7208    }
7209
7210    #[test]
7211    fn typing_replaces_the_selection() {
7212        let mut d = doc_with("replace", "a word b\n");
7213        d.anchor = Some(2);
7214        d.caret = 6; // "word" selected
7215        d.insert("X");
7216        assert_eq!(d.source, "a X b\n");
7217        assert_eq!(d.caret, 3);
7218        assert_eq!(d.anchor, None);
7219    }
7220
7221    #[test]
7222    fn toggle_bold_wraps_then_unwraps_the_selection() {
7223        let mut d = doc_with("bold", "a word b\n");
7224        d.anchor = Some(2);
7225        d.caret = 6;
7226        d.toggle(InlineKind::Strong);
7227        assert_eq!(d.source, "a **word** b\n");
7228        // The toggled region stays selected, so a second toggle reverses it.
7229        d.toggle(InlineKind::Strong);
7230        assert_eq!(d.source, "a word b\n");
7231        d.toggle(InlineKind::Strong);
7232        assert_eq!(d.source, "a **word** b\n");
7233    }
7234
7235    #[test]
7236    fn toggle_code_wraps_then_unwraps_the_selection() {
7237        let mut d = doc_with("code_rt", "a word b\n");
7238        d.anchor = Some(2);
7239        d.caret = 6;
7240        d.toggle(InlineKind::Verbatim);
7241        assert_eq!(d.source, "a `word` b\n");
7242        d.toggle(InlineKind::Verbatim);
7243        assert_eq!(d.source, "a word b\n");
7244    }
7245
7246    #[test]
7247    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
7248        // ⌘b at a bare caret, then type: the text comes out bold with no
7249        // selection ever made — the word-processor "start bold here" gesture.
7250        let mut d = doc_with("sticky_wrap", "xy\n");
7251        d.caret = 1; // between x and y
7252        d.toggle(InlineKind::Strong);
7253        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
7254        d.insert("A");
7255        assert_eq!(d.source, "x**A**y\n");
7256    }
7257
7258    #[test]
7259    fn sticky_bold_lights_the_toolbar_before_any_typing() {
7260        // The button must light the instant ⌘b is pressed, or the mode is
7261        // invisible until the first character lands.
7262        let mut d = doc_with("sticky_light", "xy\n");
7263        d.caret = 1;
7264        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7265        d.toggle(InlineKind::Strong);
7266        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7267    }
7268
7269    #[test]
7270    fn sticky_bold_toggled_off_types_normally_again() {
7271        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
7272        // in the flow of typing, the exact sequence the user described.
7273        let mut d = doc_with("sticky_off", "\n");
7274        d.caret = 0;
7275        d.toggle(InlineKind::Strong);
7276        d.insert("a");
7277        d.insert("b"); // continues inside the run, no re-arming
7278        assert_eq!(d.source, "**ab**\n");
7279        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
7280        d.insert("c");
7281        assert_eq!(d.source, "**ab**c\n");
7282    }
7283
7284    #[test]
7285    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
7286        // Once a mark is realised the caret sits inside the run, so plain typing
7287        // extends it rather than starting a second, adjacent bold span.
7288        let mut d = doc_with("sticky_cont", "\n");
7289        d.caret = 0;
7290        d.toggle(InlineKind::Emph);
7291        d.insert("h");
7292        d.insert("i");
7293        assert_eq!(d.source, "*hi*\n");
7294    }
7295
7296    #[test]
7297    fn moving_the_caret_disarms_a_sticky_mark() {
7298        // Arming a mark and then moving away must not style text elsewhere.
7299        let mut d = doc_with("sticky_disarm", "xy\n");
7300        d.caret = 0;
7301        d.toggle(InlineKind::Strong);
7302        d.move_right(false); // caret 0 → 1, disarms
7303        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7304        d.insert("A");
7305        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
7306    }
7307
7308    #[test]
7309    fn stacked_sticky_marks_apply_together() {
7310        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
7311        let mut d = doc_with("sticky_stack", "\n");
7312        d.caret = 0;
7313        d.toggle(InlineKind::Strong);
7314        d.toggle(InlineKind::Emph);
7315        d.insert("x");
7316        // Land the caret on the styled character and confirm both marks are live.
7317        d.anchor = Some(d.source.find('x').unwrap());
7318        d.caret = d.anchor.unwrap() + 1;
7319        let marks = d.active_inline_marks();
7320        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
7321        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
7322    }
7323
7324    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
7325
7326    #[test]
7327    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
7328        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
7329        // The space inside the run made `**bold **`, which is *not* bold — four
7330        // literal asterisks — so the rich view drew them, correctly and
7331        // uselessly, until the next character happened to close the run again.
7332        let mut d = wysiwyg_doc("edge_typing", "a \n");
7333        d.caret = 2;
7334        d.toggle(InlineKind::Strong);
7335        for c in "bold".chars() {
7336            d.insert(&c.to_string());
7337        }
7338        assert_eq!(d.source, "a **bold**\n");
7339        d.insert(" ");
7340        assert_eq!(
7341            d.source, "a **bold** \n",
7342            "the space belongs outside the run"
7343        );
7344        assert!(
7345            d.active_inline_marks().contains(InlineKind::Strong),
7346            "bold is still what's being typed, so the button stays lit"
7347        );
7348        // What the writer is looking at while all this happens: their words.
7349        d.build_visual(80);
7350        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
7351        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
7352        for c in "hey".chars() {
7353            d.insert(&c.to_string());
7354        }
7355        assert_eq!(
7356            d.source, "a **bold hey**\n",
7357            "one bold phrase, not two runs"
7358        );
7359    }
7360
7361    #[test]
7362    fn typing_past_a_space_can_still_leave_the_bold_behind() {
7363        // The other half: the marks stay armed across the space, so ⌘b turns
7364        // them off again there and the next word is plain — the run isn't
7365        // rejoined by a caret that was told not to.
7366        let mut d = wysiwyg_doc("edge_shed", "\n");
7367        d.caret = 0;
7368        d.toggle(InlineKind::Strong);
7369        for c in "bold ".chars() {
7370            d.insert(&c.to_string());
7371        }
7372        assert_eq!(d.source, "**bold** \n");
7373        d.toggle(InlineKind::Strong);
7374        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7375        d.insert("x");
7376        assert_eq!(d.source, "**bold** x\n");
7377    }
7378
7379    #[test]
7380    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
7381        // ⌘b and then a space before any word: the space is not marked (nothing
7382        // is), and the word after it is.
7383        let mut d = wysiwyg_doc("edge_space_first", "a\n");
7384        d.caret = 1;
7385        d.toggle(InlineKind::Strong);
7386        d.insert(" ");
7387        assert_eq!(d.source, "a \n");
7388        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7389        d.insert("b");
7390        assert_eq!(d.source, "a **b**\n");
7391    }
7392
7393    #[test]
7394    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
7395        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
7396        d.caret = 8; // the caret's home at the end of the run's text
7397        d.insert(" ");
7398        assert_eq!(
7399            d.source, "x **bold** \n",
7400            "the space lands past the delimiters"
7401        );
7402        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
7403
7404        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
7405        d.caret = 4; // in front of the "b"
7406        d.insert(" ");
7407        assert_eq!(d.source, "x  **bold** y\n");
7408        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
7409    }
7410
7411    #[test]
7412    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
7413        // Backspace over the last letter of a bold phrase.
7414        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
7415        d.caret = 10; // past the "h"
7416        d.backspace();
7417        assert_eq!(d.source, "a **bold** \n");
7418        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
7419        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7420        d.insert("x");
7421        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
7422    }
7423
7424    #[test]
7425    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
7426        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
7427        // mark, which is only text. The marks live on in the caret instead.
7428        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
7429        d.caret = 5;
7430        d.backspace();
7431        assert_eq!(d.source, "a  c\n");
7432        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7433        d.insert("x");
7434        assert_eq!(d.source, "a **x** c\n");
7435    }
7436
7437    #[test]
7438    fn typing_over_a_whole_bold_word_keeps_it_bold() {
7439        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
7440        d.anchor = Some(4);
7441        d.caret = 8; // the word, not its delimiters
7442        d.insert("x");
7443        assert_eq!(d.source, "a **x** c\n");
7444    }
7445
7446    #[test]
7447    fn a_code_span_keeps_the_space_it_is_given() {
7448        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
7449        // is still verbatim, so nothing is re-spelt. The repair asks the parser
7450        // rather than a table of kinds, and this is the answer it gets.
7451        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
7452        d.caret = 7;
7453        d.insert(" ");
7454        assert_eq!(d.source, "a `code ` c\n");
7455    }
7456
7457    #[test]
7458    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
7459        // A run's closing delimiter has a caret home on each side of it, one
7460        // column apart on screen — and a plain ← off the space after a bold word
7461        // lands on the outer one. The character drawn behind the caret there is
7462        // still the last letter of the phrase, so that is what Backspace takes;
7463        // the byte behind it is a `*` nobody can see.
7464        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
7465        d.caret = 9;
7466        d.move_left(false);
7467        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
7468        d.backspace();
7469        assert_eq!(
7470            d.source, "**bol** x\n",
7471            "a letter of the phrase, not its `*`"
7472        );
7473        assert_eq!(d.caret, 5);
7474
7475        // And the mirror in front of the opening delimiter, where Delete's
7476        // character is the first letter of the run.
7477        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
7478        d.caret = 1;
7479        d.delete_forward();
7480        assert_eq!(d.source, "x**old**\n");
7481        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
7482    }
7483
7484    #[test]
7485    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
7486        // The byte beside the caret at either edge of a bold word is a `*` the
7487        // rich view draws nothing for. Taking it is not the character delete the
7488        // key was pressed for — it unspells the run and puts a literal asterisk
7489        // on screen (`a *bold** c`). The visible character is the one that goes.
7490        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
7491        d.caret = 4; // in front of the "b"
7492        d.backspace();
7493        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
7494
7495        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
7496        d.caret = 8; // past the "d"
7497        d.delete_forward();
7498        assert_eq!(d.source, "a **bold**c\n");
7499        assert_eq!(d.caret, 8, "and the caret stays inside the run");
7500        d.insert("x");
7501        assert_eq!(d.source, "a **boldx**c\n");
7502
7503        // A code span's backticks are hidden the same way, so they are covered
7504        // by the same rule and not by a list of kinds.
7505        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
7506        d.caret = 3;
7507        d.backspace();
7508        assert_eq!(d.source, "a`code` c\n");
7509    }
7510
7511    #[test]
7512    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
7513        // The asterisks are on the screen there and the caret can stand between
7514        // them, so a delete takes exactly the byte it is aimed at.
7515        let mut d = doc_with("edge_open_src", "a **bold** c\n");
7516        d.caret = 4;
7517        d.backspace();
7518        assert_eq!(d.source, "a *bold** c\n");
7519
7520        let mut d = doc_with("edge_close_src", "a **bold** c\n");
7521        d.caret = 8;
7522        d.delete_forward();
7523        assert_eq!(d.source, "a **bold* c\n");
7524    }
7525
7526    #[test]
7527    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
7528        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
7529        // The space had stepped outside the run (the mark-edge rule), taking the
7530        // caret with it, so the delete put it back down on the far side of the
7531        // closing `**` — one place on screen, and the wrong side of it. Typing
7532        // came out plain and the toolbar went dark, with nothing to see.
7533        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
7534        d.caret = 0;
7535        d.toggle(InlineKind::Strong);
7536        for c in "bold".chars() {
7537            d.insert(&c.to_string());
7538        }
7539        d.insert(" ");
7540        assert_eq!(d.source, "**bold** \n");
7541        d.backspace();
7542        assert_eq!(
7543            d.source, "**bold**\n",
7544            "the space goes, the delimiters stay"
7545        );
7546        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
7547        assert!(
7548            d.active_inline_marks().contains(InlineKind::Strong),
7549            "so the button is still lit"
7550        );
7551        d.insert("x");
7552        assert_eq!(
7553            d.source, "**boldx**\n",
7554            "and the next character is still bold"
7555        );
7556    }
7557
7558    #[test]
7559    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
7560        // What the stranded caret did next: the byte behind it was the closing
7561        // `*`, so a second press took that instead of a letter — `**bold*`, the
7562        // styling gone and an asterisk on the screen where the word had been.
7563        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
7564        d.caret = 0;
7565        d.toggle(InlineKind::Strong);
7566        for c in "bold ".chars() {
7567            d.insert(&c.to_string());
7568        }
7569        assert_eq!(d.source, "**bold** \n");
7570        d.backspace();
7571        d.backspace();
7572        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
7573        assert_eq!(d.caret, 5);
7574    }
7575
7576    #[test]
7577    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
7578        // `***both***` closes two runs with one stack of asterisks: the caret has
7579        // to walk in through all of them, or it lands between the emph and the
7580        // strong and types half-marked.
7581        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
7582        d.caret = 11;
7583        d.backspace();
7584        assert_eq!(d.source, "***both***\n");
7585        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
7586        d.insert("x");
7587        assert_eq!(d.source, "***bothx***\n");
7588    }
7589
7590    #[test]
7591    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
7592        // The settle only moves a caret a run actually closed over. Ordinary
7593        // deletes — inside a run, or in plain prose — are untouched.
7594        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
7595        d.caret = 8;
7596        d.backspace();
7597        assert_eq!(d.source, "a **bol** c\n");
7598        assert_eq!(d.caret, 7);
7599
7600        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
7601        d.caret = 5;
7602        d.backspace();
7603        assert_eq!(d.source, "plai\n");
7604        assert_eq!(d.caret, 4);
7605    }
7606
7607    #[test]
7608    fn the_source_view_leaves_a_delete_where_it_landed() {
7609        // The delimiters are on the screen there, so the offset past them is a
7610        // place the caret can be seen to be — nothing to settle.
7611        let mut d = doc_with("edge_bksp_src", "**bold** \n");
7612        d.caret = 9;
7613        d.backspace();
7614        assert_eq!(d.source, "**bold**\n");
7615        assert_eq!(d.caret, 8);
7616    }
7617
7618    #[test]
7619    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
7620        // `***both***` closes two runs with one stack of asterisks; a space that
7621        // clears only the inner one lands against the outer's and breaks that
7622        // instead.
7623        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
7624        d.caret = 9;
7625        d.insert(" ");
7626        assert_eq!(d.source, "a ***both*** \n");
7627        assert_eq!(d.caret, 13);
7628        d.insert("x");
7629        assert_eq!(d.source, "a ***both x***\n");
7630    }
7631
7632    #[test]
7633    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
7634        // The delimiter shuffle is not an edit the writer made, so it is not a
7635        // step they have to undo past.
7636        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
7637        d.caret = 8;
7638        d.insert(" ");
7639        assert_eq!(d.source, "a **bold** \n");
7640        d.undo();
7641        assert_eq!(d.source, "a **bold**\n");
7642    }
7643
7644    #[test]
7645    fn the_source_view_types_the_space_where_it_was_asked_to() {
7646        // The rule is a rich-view courtesy. In the source view the delimiters are
7647        // on the screen and the user is editing the bytes they can see.
7648        let mut d = doc_with("edge_src", "a **bold** c\n");
7649        d.caret = 8;
7650        d.insert(" ");
7651        assert_eq!(d.source, "a **bold ** c\n");
7652    }
7653
7654    #[test]
7655    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
7656        // Double-clicking a word takes the space after it; bolding that must not
7657        // spell `**word **`, which is not bold at all.
7658        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
7659        d.anchor = Some(2);
7660        d.caret = 7; // "word "
7661        d.toggle(InlineKind::Strong);
7662        assert_eq!(d.source, "a **word** b\n");
7663        d.toggle(InlineKind::Strong);
7664        assert_eq!(d.source, "a word b\n");
7665        d.toggle(InlineKind::Strong);
7666        assert_eq!(
7667            d.source, "a **word** b\n",
7668            "reapplying the mark must not wrap stale delimiter offsets"
7669        );
7670        // And a selection of nothing but whitespace has no word to mark.
7671        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
7672        d.anchor = Some(6);
7673        d.caret = 7;
7674        d.toggle(InlineKind::Strong);
7675        assert_eq!(d.source, "a word b\n");
7676        assert!(d.status.is_some());
7677    }
7678
7679    #[test]
7680    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
7681        let mut d = doc_with("head_set", "hello\n");
7682        d.caret = 2; // caret inside the paragraph, no selection
7683        d.set_block(BlockKind::Heading(1));
7684        assert_eq!(d.source, "# hello\n");
7685    }
7686
7687    #[test]
7688    fn set_block_heading_works_in_wysiwyg_view() {
7689        // The app defaults to WYSIWYG; the caret is a source offset either way.
7690        let mut d = wysiwyg_doc("head_wys", "hello\n");
7691        d.caret = 2;
7692        d.set_block(BlockKind::Heading(1));
7693        assert_eq!(d.source, "# hello\n");
7694    }
7695
7696    #[test]
7697    fn toggle_heading_applies_switches_and_reverts() {
7698        let mut d = doc_with("head_toggle", "hello\n");
7699        d.caret = 2;
7700        d.toggle_heading(1);
7701        assert_eq!(d.source, "# hello\n"); // paragraph → H1
7702        d.toggle_heading(2);
7703        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
7704        d.toggle_heading(2);
7705        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
7706    }
7707
7708    #[test]
7709    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
7710        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
7711        // the blank line but the caret rendered on the *next* line, because the
7712        // separator was a non-navigable decoration row. In Preserve flow that
7713        // blank line is a real caret home — the caret must resolve onto it, and
7714        // typing there makes the soft break that continues the paragraph.
7715        let src = "line one:\nsecond line\n";
7716        let mut d = wysiwyg_doc("pre_enter_lineend", src);
7717        d.set_line_flow(LineFlow::Preserve);
7718        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
7719        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
7720        d.newline();
7721        d.build_visual_unwrapped();
7722        assert_eq!(d.source, "line one:\n\nsecond line\n");
7723        assert_eq!(
7724            d.caret, 10,
7725            "caret sits on the new blank line, not the next line"
7726        );
7727        // The blank line is row 1, and the caret resolves onto it — not row 2.
7728        assert_eq!(
7729            d.vmap.pos_of_offset(10),
7730            (1, 0),
7731            "caret renders on the blank row"
7732        );
7733        assert!(
7734            !d.vmap.rows[1].decoration,
7735            "the blank line is navigable in Preserve"
7736        );
7737        // Typing there makes a soft break: one paragraph, three lines.
7738        d.insert("new clause,");
7739        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
7740    }
7741
7742    #[test]
7743    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
7744        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
7745        // that keeps it one paragraph — where Fold would open a second paragraph.
7746        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
7747        d.set_line_flow(LineFlow::Preserve);
7748        d.caret = 3;
7749        d.newline();
7750        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
7751
7752        // End-of-paragraph: Enter then typing continues the same paragraph on a
7753        // new line (a soft break), not a fresh paragraph.
7754        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
7755        d.set_line_flow(LineFlow::Preserve);
7756        d.caret = 3;
7757        d.newline();
7758        d.insert("def");
7759        assert_eq!(
7760            d.source, "abc\ndef\n",
7761            "end-of-line Enter + typing is a soft break"
7762        );
7763    }
7764
7765    #[test]
7766    fn preserve_double_enter_still_makes_a_paragraph() {
7767        // Two Enters in a row promote to a real paragraph break: the second lands
7768        // on the blank line the first opened and takes the empty-line branch.
7769        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
7770        d.set_line_flow(LineFlow::Preserve);
7771        d.caret = 3;
7772        d.newline();
7773        d.newline();
7774        d.insert("def");
7775        assert_eq!(
7776            d.source, "abc\n\ndef\n",
7777            "double Enter is a paragraph break"
7778        );
7779    }
7780
7781    #[test]
7782    fn preserve_backspace_joins_across_a_soft_break() {
7783        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
7784        // soft break it deletes the single newline and joins the two lines.
7785        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
7786        d.set_line_flow(LineFlow::Preserve);
7787        d.build_visual(80);
7788        d.caret = 4; // start of "def", just past the soft break
7789        d.backspace();
7790        assert_eq!(
7791            d.source, "abcdef\n",
7792            "Backspace joins across the soft break"
7793        );
7794        assert_eq!(d.caret, 3, "caret lands where the lines meet");
7795    }
7796
7797    #[test]
7798    fn fold_enter_still_starts_a_new_paragraph() {
7799        // The default flow is unchanged: a lone `\n` would render as an invisible
7800        // space, so Enter keeps opening the paragraph break that actually shows.
7801        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
7802        d.caret = 3;
7803        d.newline();
7804        assert_eq!(
7805            d.source, "abc\n\ndef\n",
7806            "Fold mid-line Enter is a paragraph break"
7807        );
7808    }
7809
7810    #[test]
7811    fn wysiwyg_one_enter_starts_a_new_paragraph() {
7812        // Regression: one Enter left the caret between the two newlines, so typing
7813        // made a soft break (one paragraph) and you needed a second Enter.
7814        let mut d = wysiwyg_doc("wys_enter", "abc\n");
7815        d.caret = 3;
7816        d.newline();
7817        d.insert("def");
7818        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
7819    }
7820
7821    #[test]
7822    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
7823        // Regression: Enter at the caret's natural End-of-line resting place
7824        // after a bold run with nothing following it (on screen: right after
7825        // "bold", before the hidden closing "**") spliced the paragraph break
7826        // at that very byte offset — which sits *before* the closing "**" in
7827        // the source, since the delimiter is hidden and emits no glyph of its
7828        // own for `push_row`'s "end of row" fallback to count. That severed the
7829        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
7830        // "**" alone on the new line instead of leaving "**bold**" intact with
7831        // a fresh empty paragraph after it.
7832        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
7833        d.move_end(false); // the WYSIWYG End key, from caret 0
7834        assert_eq!(
7835            d.caret, 6,
7836            "caret rests right after \"bold\", before the hidden \"**\""
7837        );
7838        d.newline();
7839        assert!(
7840            d.source.starts_with("**bold**"),
7841            "the closing ** must stay attached to \"bold\": got {:?}",
7842            d.source
7843        );
7844        assert_eq!(
7845            d.source, "**bold**\n\n\n",
7846            "a fresh empty paragraph follows the still-intact bold run"
7847        );
7848    }
7849
7850    #[test]
7851    fn source_view_enter_is_a_single_newline() {
7852        let mut d = doc_with("src_enter", "abc\n");
7853        d.caret = 3;
7854        d.newline();
7855        assert_eq!(d.source, "abc\n\n");
7856    }
7857
7858    #[test]
7859    fn heading_applies_at_the_end_of_a_paragraph() {
7860        // The caret at a line end sits at the doc level; set_block must still find
7861        // the block on that line.
7862        let mut d = doc_with("head_end", "abc\n");
7863        d.caret = 3; // end of "abc"
7864        d.toggle_heading(1);
7865        assert_eq!(d.source, "# abc\n");
7866    }
7867
7868    #[test]
7869    fn heading_on_an_empty_new_paragraph_creates_one() {
7870        let mut d = wysiwyg_doc("head_empty", "abc\n");
7871        d.caret = 3;
7872        d.newline(); // caret now on a fresh, empty paragraph
7873        d.toggle_heading(1);
7874        d.insert("Title");
7875        assert!(d.source.contains("# Title"), "got {:?}", d.source);
7876    }
7877
7878    #[test]
7879    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
7880        // The reported bug, end to end: click a blank line with another one under
7881        // it, press H1, type. The text landed in the heading and the caret's
7882        // offset was right (the source view drew it there), but the rich view
7883        // drew it two rows lower, on the trailing blank line — the empty `# `
7884        // heading had left every row below it short by the marker's two bytes,
7885        // and the blank line ended up claiming the heading's own end offset.
7886        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
7887        d.build_visual_unwrapped();
7888        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
7889        d.toggle_heading(1);
7890        for c in "title".chars() {
7891            d.insert(&c.to_string());
7892            d.build_visual_unwrapped(); // as a frontend does, one frame per key
7893        }
7894        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
7895        assert_eq!(
7896            d.caret_pos(),
7897            (4, 5),
7898            "the caret draws at the end of the heading"
7899        );
7900    }
7901
7902    #[test]
7903    fn clicking_an_empty_heading_types_after_its_marker() {
7904        // The same anchor from the other side: the empty heading's row is its own
7905        // caret home, so a click on it must land past the hidden `# `. Landing in
7906        // front of the hashes made the first keystroke un-heading the line.
7907        let mut d = wysiwyg_doc("head_click", "# \n");
7908        d.build_visual_unwrapped();
7909        d.caret = d.vmap.offset_of_pos(0, 0);
7910        d.insert("x");
7911        assert_eq!(d.source, "# x\n");
7912    }
7913
7914    #[test]
7915    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
7916        let mut d = wysiwyg_doc("head_enter", "# Title\n");
7917        d.caret = 7; // end of the heading
7918        d.newline();
7919        d.insert("body");
7920        assert_eq!(d.source, "# Title\n\nbody\n");
7921    }
7922
7923    #[test]
7924    fn wysiwyg_enter_continues_a_bullet_list() {
7925        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7926        d.caret = 6; // end of "item"
7927        d.newline();
7928        d.insert("two");
7929        assert_eq!(d.source, "- item\n- two\n");
7930    }
7931
7932    #[test]
7933    fn wysiwyg_enter_increments_an_ordered_list() {
7934        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7935        d.caret = 6; // end of "one"
7936        d.newline();
7937        d.insert("two");
7938        assert_eq!(d.source, "1. one\n2. two\n");
7939    }
7940
7941    #[test]
7942    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7943        // Regression for the "extra newline" left between a list and the paragraph
7944        // below it. Enter, Enter leaves the list on a fresh empty paragraph
7945        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7946        // Backspace should then take the caret cleanly back to the end of the list
7947        // item, `- item\n\nnext`, not delete a single newline and strand it on the
7948        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7949        // no caret can land on. The map is rebuilt between keystrokes exactly as a
7950        // frontend does, since Backspace reads the stop table to place the delete.
7951        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7952        d.caret = 6; // end of "item"
7953        d.newline();
7954        d.build_visual(80);
7955        d.newline(); // leave the list onto a fresh empty paragraph
7956        d.build_visual(80);
7957        assert_eq!(
7958            d.source, "- item\n\n\n\nnext\n",
7959            "double-Enter opens the empty paragraph"
7960        );
7961        d.backspace();
7962        assert_eq!(
7963            d.source, "- item\n\nnext\n",
7964            "one Backspace collapses the whole gap"
7965        );
7966        assert_eq!(
7967            d.caret, 6,
7968            "and lands the caret back at the end of the list item"
7969        );
7970    }
7971
7972    #[test]
7973    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7974        // The stop-wise delete must not over-reach when there is no block boundary
7975        // to cross: two blank lines in a row are one caret stop apart, so pressing
7976        // Enter on an empty line and then Backspace removes exactly the one newline
7977        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7978        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7979        d.caret = 5; // the empty paragraph the first Enter already opened
7980        d.build_visual(80);
7981        d.newline();
7982        d.build_visual(80);
7983        assert_eq!(
7984            d.source, "abc\n\n\n\n",
7985            "Enter on the blank line adds one newline"
7986        );
7987        d.backspace();
7988        assert_eq!(
7989            d.source, "abc\n\n\n",
7990            "Backspace takes back exactly that one newline"
7991        );
7992    }
7993
7994    #[test]
7995    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7996        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7997        d.caret = 6; // end of the empty "- " item
7998        d.newline();
7999        d.insert("p");
8000        assert_eq!(d.source, "- a\n\np\n");
8001    }
8002
8003    #[test]
8004    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
8005        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
8006        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
8007        // not take the list-exit path (which would splice the `- ` away as if
8008        // leaving an empty item); the AST guard sends it to a normal break and
8009        // leaves the underline intact.
8010        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
8011        assert!(
8012            d.nodes().iter().any(|n| n.kind == Kind::Heading),
8013            "precondition: twig parses this as a heading, not a list",
8014        );
8015        d.caret = 7; // on the `- ` underline line
8016        d.newline();
8017        assert!(
8018            d.source.contains("- "),
8019            "the setext underline survives, not spliced away as a list item: {:?}",
8020            d.source,
8021        );
8022    }
8023
8024    #[test]
8025    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
8026        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
8027        d.caret = 7; // end of "abc" inside the fence
8028        d.newline();
8029        d.insert("def");
8030        assert_eq!(d.source, "```\nabc\ndef\n```\n");
8031    }
8032
8033    #[test]
8034    fn wysiwyg_enter_continues_a_block_quote() {
8035        // Enter opens a new *paragraph* inside the quote, not a second line of
8036        // the same one. `> quote\n> more` is a soft break, which under
8037        // `LineFlow::Fold` renders as a space — the keystroke would look like it
8038        // did nothing. The quoted blank line is what makes the break visible, and
8039        // it's the same thing Enter does in running prose.
8040        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
8041        d.caret = 7; // end of "quote"
8042        d.newline();
8043        d.insert("more");
8044        assert_eq!(d.source, "> quote\n>\n> more\n");
8045        // Still one quote, now holding two paragraphs — not a quote and a stray
8046        // line that fell out of it.
8047        let quotes = d
8048            .nodes()
8049            .iter()
8050            .filter(|n| n.kind == Kind::BlockQuote)
8051            .count();
8052        assert_eq!(quotes, 1);
8053    }
8054
8055    #[test]
8056    fn set_block_makes_a_heading_at_the_caret() {
8057        let mut d = doc_with("head", "Title\n\nbody\n");
8058        d.caret = 0;
8059        d.set_block(BlockKind::Heading(2));
8060        assert_eq!(d.source, "## Title\n\nbody\n");
8061        d.set_block(BlockKind::Paragraph);
8062        assert_eq!(d.source, "Title\n\nbody\n");
8063    }
8064
8065    // ── block containers (quote / list) ──────────────────────────────────────
8066
8067    #[test]
8068    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
8069        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
8070        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
8071        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
8072        // A caret at a line end sits at the doc level; the block is still found.
8073        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
8074    }
8075
8076    #[test]
8077    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
8078        // Every source line of the paragraph gets its own `> `, so a caret left
8079        // on its old byte offset falls one prefix per line above it too far
8080        // back — inside the markup it just asked for rather than in its word.
8081        assert_eq!(
8082            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
8083            "> aaa\n> b|bb\n> ccc\n"
8084        );
8085    }
8086
8087    #[test]
8088    fn toggle_blockquote_works_in_wysiwyg_view() {
8089        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
8090        assert_eq!(
8091            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
8092            "> hel|lo\n"
8093        );
8094        assert_eq!(
8095            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
8096            "hel|lo\n"
8097        );
8098    }
8099
8100    #[test]
8101    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
8102        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
8103        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
8104        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
8105        // The *other* kind converts in place instead of nesting, which is what
8106        // makes the two buttons one three-state control.
8107        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
8108        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
8109        // Its own kind, over the only item the list holds, takes it off.
8110        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
8111    }
8112
8113    #[test]
8114    fn toggle_list_works_in_wysiwyg_view() {
8115        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
8116        assert_eq!(
8117            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
8118            "1. hel|lo\n"
8119        );
8120        assert_eq!(
8121            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
8122            "- hel|lo\n"
8123        );
8124        assert_eq!(
8125            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
8126            "hel|lo\n"
8127        );
8128    }
8129
8130    #[test]
8131    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
8132        // The selection has to grow with the markup: twig takes a container off
8133        // only a range covering every block it holds, so the second press can
8134        // reverse the first only if the result is what's selected.
8135        let mut d = doc_with("list_sel", "abc\n\ndef\n");
8136        d.select_all();
8137        d.toggle_list(true);
8138        assert_eq!(d.source, "1. abc\n\n2. def\n");
8139        assert_eq!(d.selection(), Some((0, d.source.len())));
8140        d.toggle_list(true);
8141        assert_eq!(d.source, "abc\n\ndef\n");
8142    }
8143
8144    #[test]
8145    fn toggle_blockquote_nests_a_partly_covered_quote() {
8146        // twig's rule: covering only some of a container's blocks nests, because
8147        // taking the quote off would drag its uncovered siblings out with it.
8148        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
8149        d.caret = 2; // in the first quoted paragraph only
8150        d.toggle_blockquote();
8151        assert_eq!(d.source, "> > a\n>\n> b\n");
8152    }
8153
8154    #[test]
8155    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
8156        // A blank line used to be no block for twig to wrap —
8157        // `toggle_block_container` answered `NotFound` — so Quote and the list
8158        // buttons did nothing on the very line the H1 button works on, and leaf
8159        // lent twig a scratch paragraph to wrap and took it back out again.
8160        // twig 3.2.0 opens an empty container there itself, so what is left here
8161        // is where the caret lands: inside the marker that was just written.
8162        let mut d = doc_with("quote_blank", "\nabc\n");
8163        d.caret = 0;
8164        d.toggle_blockquote();
8165        assert_eq!(d.source, "> \nabc\n");
8166        assert_eq!(
8167            d.caret, 2,
8168            "the caret belongs inside the quote it just opened"
8169        );
8170        assert!(d.status.is_none(), "{:?}", d.status);
8171        assert!(d.dirty);
8172
8173        // And the paragraph below is still its own block: an empty container one
8174        // soft break from `abc` would take that paragraph into the quote with it.
8175        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
8176        d.caret = 0;
8177        d.toggle_blockquote();
8178        d.build_visual(80);
8179        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
8180
8181        // The same from the other side: a blank line directly under a paragraph
8182        // earns the blank line an empty block needs, rather than being read as a
8183        // soft break inside that paragraph.
8184        let mut d = doc_with("list_blank_below", "abc\n");
8185        d.caret = 4;
8186        d.toggle_list(false);
8187        assert_eq!(d.source, "abc\n\n- ");
8188        assert_eq!(d.caret, 7);
8189    }
8190
8191    #[test]
8192    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
8193        // The gesture the rendering fix is for. `newline` inside a quote already
8194        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
8195        // spelling — but the two marker lines it adds belonged to no node until
8196        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
8197        // just made drew as plain prose under the quote.
8198        let mut d = wysiwyg_doc("quote_enter", "> a\n");
8199        d.caret = 3; // past `a`, at the end of the quoted line
8200        d.newline();
8201        assert_eq!(d.source, "> a\n>\n> \n");
8202        d.build_visual(80);
8203        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
8204        // And the caret is on the new line, not stranded on the old one.
8205        assert_eq!(d.caret, 8);
8206    }
8207
8208    #[test]
8209    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
8210        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
8211        // now it is twig's single edit. Either way one ⌘z has to put the blank
8212        // line back rather than undoing into a half-built document.
8213        for open in [
8214            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
8215            &|d: &mut Doc| d.toggle_list(false),
8216            &|d: &mut Doc| d.toggle_list(true),
8217        ] {
8218            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
8219            d.caret = 3;
8220            open(&mut d);
8221            assert_ne!(d.source, "a\n\n\n\nb\n");
8222            d.undo();
8223            assert_eq!(d.source, "a\n\n\n\nb\n");
8224        }
8225    }
8226
8227    #[test]
8228    fn a_container_toggle_is_one_undo_step() {
8229        let mut d = doc_with("quote_undo", "hello\n");
8230        d.caret = 3;
8231        d.insert("X"); // a typing run the structural edit must not fold into
8232        d.toggle_blockquote();
8233        assert_eq!(d.source, "> helXlo\n");
8234        d.undo();
8235        assert_eq!(d.source, "helXlo\n");
8236    }
8237
8238    // ── links ────────────────────────────────────────────────────────────────
8239
8240    #[test]
8241    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
8242        let mut d = doc_with("link_sel", "word here\n");
8243        d.anchor = Some(0);
8244        d.caret = 4;
8245        d.insert_link("http://x.dev");
8246        assert_eq!(d.source, "[word](http://x.dev) here\n");
8247        // The text, not the destination — so a second press re-points the link
8248        // the first one made rather than nesting one inside it.
8249        assert_eq!(d.selected_text(), Some("word"));
8250        d.insert_link("http://y.dev");
8251        assert_eq!(d.source, "[word](http://y.dev) here\n");
8252        assert_eq!(d.selected_text(), Some("word"));
8253    }
8254
8255    #[test]
8256    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
8257        let mut d = doc_with("img_caret", "before after\n");
8258        d.caret = 7; // between "before " and "after"
8259        d.insert_image("cat.png", "a cat");
8260        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
8261        // The caret sits just past the inserted image, nothing selected.
8262        assert_eq!(d.selection(), None);
8263        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
8264    }
8265
8266    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
8267    /// destination at the first space, so the `format!` this used to be wrote
8268    /// something that was not an image at all — and the reader saw the markup as
8269    /// text. twig owns the spelling now, and moves it into the angle form.
8270    #[test]
8271    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
8272        let mut d = doc_with("img_space", "x\n");
8273        d.caret = 0;
8274        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
8275        assert_eq!(
8276            d.source,
8277            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
8278        );
8279        // And it reads back as an image pointing at the unescaped path — the angle
8280        // brackets are spelling, not part of the destination.
8281        d.caret = 2;
8282        assert_eq!(
8283            d.image_destination_at_caret(),
8284            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
8285        );
8286    }
8287
8288    /// A `)` in a caption or a filename must not close the image early.
8289    #[test]
8290    fn insert_image_escapes_a_paren_in_either_half() {
8291        let mut d = doc_with("img_paren", "x\n");
8292        d.caret = 0;
8293        d.insert_image("a)b.png", "");
8294        assert_eq!(d.source, "![](a\\)b.png)x\n");
8295        d.caret = 2;
8296        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
8297    }
8298
8299    #[test]
8300    fn insert_image_uses_the_selection_as_alt_text() {
8301        let mut d = doc_with("img_sel", "caption here\n");
8302        d.anchor = Some(0);
8303        d.caret = 7; // "caption"
8304        d.insert_image("p.png", "ignored fallback");
8305        assert_eq!(d.source, "![caption](p.png) here\n");
8306    }
8307
8308    #[test]
8309    fn insert_image_with_no_alt_leaves_empty_brackets() {
8310        let mut d = doc_with("img_noalt", "\n");
8311        d.caret = 0;
8312        d.insert_image("logo.svg", "");
8313        assert_eq!(d.source, "![](logo.svg)\n");
8314    }
8315
8316    #[test]
8317    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
8318        // The round trip is the point: it's no use writing markup the reader
8319        // can't pick up again. This is the pair that only holds from twig 2.5.1
8320        // on — before it, the one-line form went in fine and came back as a
8321        // paragraph of raw tags, publishing no media at all.
8322        let mut d = doc_with("vid_rt", "\n");
8323        d.caret = 0;
8324        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
8325        assert_eq!(
8326            d.source,
8327            "<video src=\"clip.mp4\" controls>a clip</video>\n"
8328        );
8329
8330        d.build_visual(80);
8331        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
8332        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
8333        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
8334        assert_eq!(d.vmap.media[0].alt, "a clip");
8335    }
8336
8337    #[test]
8338    fn insert_media_spells_audio_with_its_own_tag() {
8339        let mut d = doc_with("aud_rt", "\n");
8340        d.caret = 0;
8341        d.insert_media(MediaKind::Audio, "take.mp3", "");
8342        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
8343        d.build_visual(80);
8344        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
8345    }
8346
8347    #[test]
8348    fn insert_media_uses_the_selection_as_fallback_text() {
8349        // The same courtesy `insert_image` does with alt: select a caption,
8350        // insert, and the caption labels the thing rather than being replaced.
8351        let mut d = doc_with("vid_sel", "the talk here\n");
8352        d.anchor = Some(0);
8353        d.caret = 8; // "the talk"
8354        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
8355        assert_eq!(
8356            d.source,
8357            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
8358        );
8359    }
8360
8361    #[test]
8362    fn insert_media_with_an_image_kind_is_just_insert_image() {
8363        let mut d = doc_with("img_via_media", "\n");
8364        d.caret = 0;
8365        d.insert_media(MediaKind::Image, "logo.svg", "x");
8366        assert_eq!(d.source, "![x](logo.svg)\n");
8367    }
8368
8369    // ── thematic breaks ─────────────────────────────────────────────────────
8370
8371    /// The node the source parses as at `caret` — what confirms an inserted
8372    /// `---` actually reads back as a rule, not stray text or a setext heading.
8373    ///
8374    /// The *narrowest* node covering the offset. Every ancestor covers it too,
8375    /// and since twig 2.8 that includes the `doc` root, which now carries a real
8376    /// span (it reported none before, so taking the first match used to land on
8377    /// the block by luck and now always answers `"doc"`).
8378    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
8379        d.nodes()
8380            .into_iter()
8381            .filter(|n| n.span.start <= caret && caret < n.span.end)
8382            .min_by_key(|n| n.span.end - n.span.start)
8383            .map(|n| n.kind)
8384    }
8385
8386    #[test]
8387    fn a_task_box_toggles_at_the_caret_and_reads_back() {
8388        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
8389        d.caret = 8; // inside "todo"
8390        assert_eq!(d.task_checked_at_caret(), Some(false));
8391        d.toggle_task_checked();
8392        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
8393        assert_eq!(d.task_checked_at_caret(), Some(true));
8394        d.toggle_task_checked();
8395        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
8396    }
8397
8398    #[test]
8399    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
8400        // The whole reason `toggle_task_at` exists apart from the caret form:
8401        // ticking a box elsewhere must not move the cursor out of what's being
8402        // typed.
8403        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
8404        d.caret = 8; // inside "first"
8405        let second = d.source.find("second").unwrap();
8406        d.toggle_task_at(second);
8407        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
8408        assert_eq!(d.caret, 8, "the caret stayed in the first item");
8409    }
8410
8411    #[test]
8412    fn a_plain_item_gains_and_loses_a_box() {
8413        let mut d = doc_with("task_mint", "- plain\n");
8414        d.caret = 4;
8415        assert_eq!(d.task_checked_at_caret(), None);
8416        d.toggle_task_item();
8417        assert_eq!(d.source, "- [ ] plain\n");
8418        assert_eq!(
8419            d.task_checked_at_caret(),
8420            Some(false),
8421            "a new box arrives unticked"
8422        );
8423        d.toggle_task_item();
8424        assert_eq!(d.source, "- plain\n");
8425    }
8426
8427    #[test]
8428    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
8429        // `set checked` must not silently convert a bullet into a task — that is
8430        // `toggle_task_item`'s job, and twig refuses it here.
8431        let mut d = doc_with("task_none", "- plain\n");
8432        d.caret = 4;
8433        d.toggle_task_checked();
8434        assert_eq!(d.source, "- plain\n", "nothing written");
8435        assert!(
8436            d.status.is_some(),
8437            "the refusal should reach the status line"
8438        );
8439    }
8440
8441    #[test]
8442    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
8443        let mut d = doc_with("task_quote", "> - [ ] nested\n");
8444        d.caret = d.source.find("nested").unwrap();
8445        assert_eq!(d.task_checked_at_caret(), Some(false));
8446        d.toggle_task_checked();
8447        assert_eq!(d.source, "> - [x] nested\n");
8448    }
8449
8450    #[test]
8451    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
8452        // A rule is a block, so twig's `insert_thematic_break` alone lands it
8453        // after the whole paragraph. `split_block` parts the paragraph first and
8454        // the rule is aimed at the *first* half, which is what a rule button is
8455        // understood to do — and what leaf spelled by hand until twig grew both
8456        // halves of the gesture.
8457        let mut d = doc_with("hr_mid", "before after\n");
8458        d.caret = 7; // between "before " and "after"
8459        d.insert_thematic_break();
8460        assert_eq!(d.source, "before \n\n---\n\nafter\n");
8461        assert_eq!(d.selection(), None);
8462        assert_eq!(
8463            kind_at(&mut d, "before \n\n".len()),
8464            Some(Kind::ThematicBreak)
8465        );
8466    }
8467
8468    #[test]
8469    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
8470        // At the end there is nothing to part, and a split there writes the
8471        // separator anyway — a blank line and the empty slot the next paragraph
8472        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
8473        // two blank lines nothing fills. Now the rule lands after the paragraph,
8474        // where the split-and-aim was sending it regardless. Both formats, and
8475        // both shapes of a last line — terminated, and still being typed —
8476        // because the two reach the split through different doors: Markdown's
8477        // paragraph span stops before its newline, so `para\n` at 4 never split
8478        // there, but `para` at 4 did.
8479        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
8480            for src in ["para\n", "para"] {
8481                let mut d = Doc::from_source(src.into(), fmt).unwrap();
8482                d.caret = 4;
8483                d.insert_thematic_break();
8484                assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
8485                assert_eq!(d.caret, d.source.len());
8486            }
8487            // Mid-document the slot sat between the rule and the next block.
8488            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
8489            d.caret = 4;
8490            d.insert_thematic_break();
8491            assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
8492            // Trailing whitespace is nothing to part either.
8493            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
8494            d.caret = 4;
8495            d.insert_thematic_break();
8496            assert_eq!(d.source, format!("para  \n\n{rule}\n"), "{fmt:?}");
8497        }
8498    }
8499
8500    #[test]
8501    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
8502        // The split at the start parts nothing, but it is kept on purpose:
8503        // `|para` becomes `\npara` with the caret on a blank line, and twig
8504        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
8505        // way "before the paragraph" is reachable through a gesture that only
8506        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
8507        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
8508            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
8509            d.caret = 0;
8510            d.insert_thematic_break();
8511            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
8512            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
8513            d.caret = 6;
8514            d.insert_thematic_break();
8515            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
8516        }
8517    }
8518
8519    #[test]
8520    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
8521        // The gap between two blocks is where a click lands the caret; the
8522        // rule goes on the blank, one blank each side.
8523        let mut d = doc_with("hr_gap", "a\n\nb\n");
8524        d.caret = 2;
8525        d.insert_thematic_break();
8526        assert_eq!(d.source, "a\n\n---\n\nb\n");
8527    }
8528
8529    #[test]
8530    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
8531        // The same door as the rule's, through the placement they share.
8532        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
8533        d.caret = 4;
8534        d.insert_table(1, 1);
8535        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
8536        let mut d = doc_with("table_end_typed", "para");
8537        d.caret = 4;
8538        d.insert_table(1, 1);
8539        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
8540        assert!(d.caret_in_table());
8541    }
8542
8543    #[test]
8544    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
8545        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
8546        // and leaf wrote the first into both until twig started spelling it.
8547        let mut md = doc_with("hr_md", "para\n");
8548        md.caret = 2;
8549        md.insert_thematic_break();
8550        assert_eq!(md.source, "pa\n\n---\n\nra\n");
8551
8552        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
8553        dj.caret = 2;
8554        dj.insert_thematic_break();
8555        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
8556    }
8557
8558    #[test]
8559    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
8560        // The table goes *at* the caret the way the rule does: the paragraph is
8561        // parted first, and twig writes the grid after its first half. The
8562        // caret then sits in the first header cell — selected, as Tab would
8563        // leave it — so the next keystroke is the heading.
8564        let mut d = doc_with("table_mid", "before after\n");
8565        d.caret = 7;
8566        d.insert_table(2, 3);
8567        assert_eq!(
8568            d.source,
8569            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
8570        );
8571        assert!(d.caret_in_table());
8572        let first_bar = d.source.find('|').unwrap();
8573        assert!(
8574            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
8575            "caret {} is not in the header row",
8576            d.caret
8577        );
8578        d.insert("Name");
8579        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
8580        // And the grid the table was written into is one the table keys walk
8581        // (over the map a frontend rebuilds after every edit).
8582        d.build_visual(80);
8583        assert!(d.cell_tab(true));
8584        d.insert("Qty");
8585        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
8586    }
8587
8588    #[test]
8589    fn insert_table_spells_the_grid_the_format_s_own_way() {
8590        // Djot's delimiter row is unpadded, and leaf never has to know that.
8591        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
8592        dj.caret = 2;
8593        dj.insert_table(1, 2);
8594        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
8595        assert!(dj.caret_in_table());
8596    }
8597
8598    #[test]
8599    fn insert_table_refuses_where_the_format_spells_no_table() {
8600        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
8601        d.caret = 4;
8602        d.insert_table(1, 1);
8603        assert_eq!(d.source, "<p>ab</p>\n");
8604        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
8605        assert!(!d.capabilities().table);
8606    }
8607
8608    #[test]
8609    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
8610        let mut d = doc_with("table_zero", "para\n");
8611        d.caret = 2;
8612        d.insert_table(0, 2);
8613        assert_eq!(d.source, "para\n");
8614        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
8615    }
8616
8617    #[test]
8618    fn clicking_below_a_final_thematic_break_can_type_after_it() {
8619        let mut d = wysiwyg_doc("hr_final_click", "---\n");
8620        d.build_visual(80);
8621        d.click(d.vmap.num_rows() + 2, 0, false);
8622        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
8623        d.insert("after");
8624        assert_eq!(d.source, "---\nafter");
8625    }
8626
8627    #[test]
8628    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
8629        // The same bytes are two documents. In Markdown `  - b` is a nested item
8630        // and the next one belongs beside it, at its indent. In Djot a list
8631        // marker can't interrupt a paragraph, so those bytes are literal text in
8632        // item `a` and there is only one item — writing `  - ` under it would add
8633        // no item at all, just more text, and the new sibling has to go to
8634        // column zero. Both spellings come out of the *enclosing item's* line.
8635        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
8636        md.caret = "- a\n  - b".len();
8637        md.newline();
8638        assert_eq!(md.source, "- a\n  - b\n  - \n");
8639        assert_eq!(list_items(&mut md), 3);
8640
8641        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
8642        dj.view = View::Wysiwyg;
8643        dj.build_visual(80);
8644        dj.caret = "- a\n  - b".len();
8645        dj.newline();
8646        assert_eq!(dj.source, "- a\n  - b\n- \n");
8647        assert_eq!(list_items(&mut dj), 2);
8648
8649        // Where Djot's nesting is real — opened by a blank line — the indent is
8650        // reproduced there too, and the two formats agree again.
8651        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
8652        dj.view = View::Wysiwyg;
8653        dj.build_visual(80);
8654        dj.caret = "- a\n\n  - b".len();
8655        dj.newline();
8656        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
8657        assert_eq!(list_items(&mut dj), 3);
8658    }
8659
8660    #[test]
8661    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
8662        // Tab replaces the line's whole prefix with the one twig spells, so the
8663        // quote markers, the parent's indent and an ordered marker's extra
8664        // column are all its answer rather than leaf's arithmetic.
8665        for (name, body, caret, want) in [
8666            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
8667            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
8668            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
8669            // A checkbox is markup the item's own text wraps past, but a nested
8670            // list may only open at the *list* marker's column — four in from
8671            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
8672            // parses as one item, not two.
8673            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
8674            (
8675                "quoted task",
8676                "> - [ ] a\n> - [ ] b\n",
8677                18,
8678                "> - [ ] a\n>   - [ ] b\n",
8679            ),
8680        ] {
8681            let mut doc = wysiwyg_doc(name, body);
8682            doc.caret = caret;
8683            doc.indent();
8684            assert_eq!(doc.source, want, "{name}");
8685            // The nesting is real, not just indented text.
8686            assert_eq!(list_items(&mut doc), 2, "{name}");
8687        }
8688    }
8689
8690    #[test]
8691    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
8692        // The same bytes, the two formats disagreeing, and a gesture that used
8693        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
8694        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
8695        // so those bytes are literal text inside item `a` — there is nothing to
8696        // outdent, and treating them as a marker turned one item into two, a
8697        // structural edit from a keystroke that should delete one character.
8698        //
8699        // twig's `line_prefix` is what tells them apart: it reports the marker
8700        // on the Markdown line and nothing on the Djot one, which is a
8701        // continuation. No byte scan can reach that answer.
8702        let src = "- a\n  - b\n";
8703        let at = "- a\n  - ".len();
8704
8705        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
8706        md.view = View::Wysiwyg;
8707        md.build_visual(80);
8708        md.caret = at;
8709        md.backspace();
8710        assert_eq!(md.source, "- a\n- b\n");
8711        assert_eq!(list_items(&mut md), 2);
8712
8713        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
8714        dj.view = View::Wysiwyg;
8715        dj.build_visual(80);
8716        dj.caret = at;
8717        dj.backspace();
8718        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
8719        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
8720    }
8721
8722    #[test]
8723    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
8724        // Leaf used to spell the next item from the marker bytes it scanned, and
8725        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
8726        // and dropped out of the checklist. twig reproduces the whole
8727        // continuation, and a fresh item is always unticked however the one above
8728        // it stands.
8729        for (name, body, want) in [
8730            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
8731            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
8732        ] {
8733            let mut doc = wysiwyg_doc(name, body);
8734            doc.caret = body.trim_end_matches('\n').len();
8735            doc.newline();
8736            assert_eq!(doc.source, want, "{name}");
8737            // Both items are checklist items — the new one is a box, not the
8738            // plain bullet the old marker scan left behind — and it is unticked
8739            // whichever way the one above it faces.
8740            let boxes: Vec<Option<bool>> = doc
8741                .nodes()
8742                .iter()
8743                .filter(|n| n.kind == Kind::TaskListItem)
8744                .map(|n| n.checked)
8745                .collect();
8746            assert_eq!(boxes.len(), 2, "{name}");
8747            assert_eq!(boxes[1], Some(false), "{name}");
8748        }
8749    }
8750
8751    #[test]
8752    fn a_split_takes_the_space_the_caret_was_in_front_of() {
8753        // Splicing a break at the caret strands the space the words were parted
8754        // at on the head of the second block, where it reads as an indent nobody
8755        // typed. twig's split consumes it.
8756        for (name, body, caret, want) in [
8757            ("para", "one two\n", 3, "one\n\ntwo\n"),
8758            ("item", "- one two\n", 5, "- one\n- two\n"),
8759            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
8760            // A heading takes leaf's own path, which has to match.
8761            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
8762        ] {
8763            let mut doc = wysiwyg_doc(name, body);
8764            doc.caret = caret;
8765            doc.newline();
8766            assert_eq!(doc.source, want, "{name}");
8767        }
8768    }
8769
8770    #[test]
8771    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
8772        // The one place leaf keeps its own break: `split_block` repeats the `#`,
8773        // and Enter after a title is how the body under it is asked for.
8774        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
8775        doc.caret = "# Title".len();
8776        doc.newline();
8777        doc.insert("body");
8778        assert_eq!(doc.source, "# Title\n\nbody\n");
8779        assert_eq!(
8780            doc.nodes()
8781                .iter()
8782                .filter(|n| n.kind == Kind::Heading)
8783                .count(),
8784            1
8785        );
8786    }
8787
8788    #[test]
8789    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
8790        // A quoted item's marker doesn't open its line, so a scan that starts at
8791        // column zero finds a `>` where it wanted a bullet, calls the line "not a
8792        // list" and hands Enter to the plain-quote branch — which writes `> ` and
8793        // drops the list. The next item has to carry the whole prefix.
8794        for (name, body, want) in [
8795            ("flat", "> - a\n", "> - a\n> - \n"),
8796            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8797            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
8798            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
8799            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
8800        ] {
8801            let mut doc = wysiwyg_doc(name, body);
8802            doc.caret = body.trim_end_matches('\n').len();
8803            doc.newline();
8804            assert_eq!(doc.source, want, "{name}");
8805            // The marker isn't just spelled right, it parses as an item.
8806            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
8807        }
8808    }
8809
8810    #[test]
8811    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
8812        // Double-Enter exits the list. Unquoted that means a blank line, but a
8813        // *bare* blank line would end the quote too and drop the caret out of it,
8814        // so the separator keeps its `>` and the caret's line keeps its `> `.
8815        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
8816        doc.caret = "> - a\n> - ".len();
8817        doc.newline();
8818        assert_eq!(doc.source, "> - a\n>\n> \n");
8819        assert_eq!(list_items(&mut doc), 1);
8820        // What "still in the quote" means for the next keystroke: the caret sits
8821        // behind the prefix, and what's typed there lands inside the quote as a
8822        // paragraph of its own — not as more of item `a`.
8823        doc.insert("x");
8824        assert_eq!(doc.source, "> - a\n>\n> x\n");
8825        assert!(
8826            doc.editor
8827                .ancestors_at(doc.caret - 1)
8828                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
8829        );
8830    }
8831
8832    #[test]
8833    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
8834        // The marker is hidden block markup, so Backspace over it is structural —
8835        // but only the marker is the list's. Splicing from the line start would
8836        // take the `>` with it and silently unquote the line.
8837        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
8838        doc.caret = "> - ".len();
8839        doc.backspace();
8840        assert_eq!(doc.source, "> a\n");
8841        assert_eq!(list_items(&mut doc), 0);
8842
8843        // A nested one outdents instead, moving the bullet within the quote
8844        // rather than moving the quote.
8845        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
8846        doc.caret = "> - a\n>   - ".len();
8847        doc.backspace();
8848        assert_eq!(doc.source, "> - a\n> - b\n");
8849        assert_eq!(list_items(&mut doc), 2);
8850    }
8851
8852    #[test]
8853    fn only_a_bare_paragraph_is_parted_around_the_caret() {
8854        // The split is deliberately narrow. Parting a fenced block would leave
8855        // two fences with a rule between them, and parting a list item would
8856        // mint an item nobody asked for on the way to a rule that lands after
8857        // the list either way — so both keep the whole block intact and take the
8858        // rule after it. A caret in a quote is likewise left alone.
8859        for (name, body, caret, want) in [
8860            (
8861                "code",
8862                "```\nfn x() {}\n```\n",
8863                8,
8864                "```\nfn x() {}\n```\n\n---\n",
8865            ),
8866            ("list", "- one two\n", 6, "- one two\n\n---\n"),
8867            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
8868        ] {
8869            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
8870            d.caret = caret;
8871            d.insert_thematic_break();
8872            assert_eq!(d.source, want, "{name}: the block should stay whole");
8873        }
8874    }
8875
8876    #[test]
8877    fn insert_thematic_break_replaces_the_selection() {
8878        // Now that the rule lands *at* the caret again, replacing the selection
8879        // is coherent once more: the text goes, and the rule takes its place.
8880        // The space the deletion left leading the second half is consumed by the
8881        // split rather than opening the new paragraph with it.
8882        let mut d = doc_with("hr_sel", "one two three\n");
8883        d.anchor = Some(4);
8884        d.caret = 7; // "two"
8885        d.insert_thematic_break();
8886        assert_eq!(d.source, "one \n\n---\n\nthree\n");
8887        assert_eq!(d.selection(), None);
8888    }
8889
8890    #[test]
8891    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
8892        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
8893        // because writing `---` into one is code, not a rule — twig now walks out
8894        // to the block that owns the caret's line, so there is nothing to refuse.
8895        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
8896        code.caret = 5; // inside the fenced code
8897        code.insert_thematic_break();
8898        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
8899        assert_eq!(code.status, None, "no refusal to report any more");
8900
8901        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
8902        table.caret = 3; // in the header row
8903        table.insert_thematic_break();
8904        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
8905    }
8906
8907    #[test]
8908    fn insert_thematic_break_in_a_list_item_ends_the_list() {
8909        // The un-indented rule cannot continue the list, so it closes the list
8910        // and lands at the top level rather than nested inside it.
8911        let mut d = doc_with("hr_list", "- one\n- two\n");
8912        d.caret = "- one\n- tw".len(); // mid "two"
8913        d.insert_thematic_break();
8914        d.build_visual(80);
8915        let rule_at = d.source.find("---").unwrap();
8916        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8917        assert!(
8918            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
8919                && n.span.start <= rule_at
8920                && rule_at < n.span.end),
8921            "the rule must not be nested inside the list"
8922        );
8923    }
8924
8925    #[test]
8926    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
8927        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
8928        // which is the document the gesture was actually asked for.
8929        let mut d = doc_with("hr_quote", "> hello\n");
8930        d.caret = 4; // inside the quoted text
8931        d.insert_thematic_break();
8932        assert_eq!(d.source, "> hello\n>\n> ---\n");
8933        d.build_visual(80);
8934        let rule_at = d.source.find("---").unwrap();
8935        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8936        assert!(
8937            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
8938                && n.span.start <= rule_at
8939                && rule_at < n.span.end),
8940            "the rule belongs to the quote it was asked for"
8941        );
8942    }
8943
8944    // ── typing against a block picture ────────────────────────────────────────
8945
8946    /// A rendered-view document with the caret parked on one of the picture's two
8947    /// stops, and the map already built — the state a frontend is in between
8948    /// drawing a frame and the next keystroke.
8949    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
8950        let mut d = doc_in(View::Wysiwyg, name, src);
8951        d.build_visual_unwrapped();
8952        let start = src.find("![").unwrap();
8953        d.caret = match side {
8954            MediaStop::Before => start,
8955            MediaStop::After => start + "![](p.png)".len(),
8956        };
8957        d
8958    }
8959
8960    /// The block media the map publishes, after rebuilding it — "is this still a
8961    /// picture, or has it become a line of text with an image in it?"
8962    fn media_count(d: &mut Doc) -> usize {
8963        d.build_visual_unwrapped();
8964        d.vmap.media.len()
8965    }
8966
8967    #[test]
8968    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
8969        // The accident this prevents: tap the blank page under a photo (which
8970        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
8971        // paragraph with an *inline* image — the photo stops being drawn.
8972        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
8973        d.insert("xy");
8974        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
8975        assert_eq!(media_count(&mut d), 1, "still a picture");
8976    }
8977
8978    #[test]
8979    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
8980        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8981        d.insert("xy");
8982        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
8983        assert_eq!(media_count(&mut d), 1);
8984    }
8985
8986    #[test]
8987    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
8988        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
8989        d.insert("x");
8990        assert_eq!(d.source, "x\n\n![](p.png)\n");
8991        assert_eq!(media_count(&mut d), 1);
8992    }
8993
8994    #[test]
8995    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
8996        // The opened paragraph is part of the keystroke, not an edit the writer
8997        // made — so it undoes with the character, not a step later.
8998        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
8999        d.insert("x");
9000        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
9001        d.undo();
9002        assert_eq!(d.source, "hi\n\n![](p.png)\n");
9003    }
9004
9005    #[test]
9006    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
9007        // ⌘V dissolves the picture exactly as a keystroke does.
9008        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
9009        d.paste("pasted");
9010        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
9011        assert_eq!(media_count(&mut d), 1);
9012    }
9013
9014    #[test]
9015    fn typing_beside_an_inline_image_is_ordinary_editing() {
9016        // An inline image has no placeholder row and no stops of its own. Opening
9017        // a paragraph mid-sentence would be the bug, not the fix.
9018        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
9019        d.build_visual_unwrapped();
9020        d.caret = "see ![](p.png)".len();
9021        d.insert("!");
9022        assert_eq!(d.source, "see ![](p.png)! here\n");
9023    }
9024
9025    #[test]
9026    fn source_view_types_raw_markup_against_an_image_untouched() {
9027        // Source view is for writing the markup itself; a break inserted behind
9028        // the writer's back there would be the editor arguing with them.
9029        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
9030        d.caret = "![](p.png)".len();
9031        d.insert("x");
9032        assert_eq!(d.source, "![](p.png)x\n");
9033    }
9034
9035    #[test]
9036    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
9037        // A selection is replaced, not joined into, so there is nothing to
9038        // protect: the range takes the picture with it.
9039        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
9040        d.anchor = Some(d.caret);
9041        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
9042        d.insert("x");
9043        assert_eq!(d.source, "hi\n\nx\n");
9044    }
9045
9046    #[test]
9047    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
9048        // What this actually cost: a real vault's photo, to one stray Backspace.
9049        // The caret past `![](p.png)` was deleting the closing paren — invisible
9050        // in the rendered view — and the photo became the text `![](p.png`.
9051        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
9052        d.backspace();
9053        assert_eq!(d.source, "hi\n");
9054        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
9055        d.undo();
9056        assert_eq!(
9057            d.source, "hi\n\n![](p.png)\n",
9058            "and comes back in one piece"
9059        );
9060    }
9061
9062    #[test]
9063    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
9064        // Deleting the break here would join the picture to the paragraph above,
9065        // where it is an *inline* image and stops being drawn. Step over the
9066        // boundary; the next press deletes in the paragraph the caret reached.
9067        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
9068        d.backspace();
9069        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
9070        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
9071        d.backspace();
9072        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
9073        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
9074    }
9075
9076    #[test]
9077    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
9078        // The mirror. A byte-step here eats the `!` and leaves a link.
9079        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
9080        d.delete_forward();
9081        assert_eq!(d.source, "hi\n\nbye\n");
9082        assert_eq!(media_count(&mut d), 0);
9083    }
9084
9085    #[test]
9086    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
9087        let mut d = doc_at_picture(
9088            "pic_del_after",
9089            "hi\n\n![](p.png)\n\nbye\n",
9090            MediaStop::After,
9091        );
9092        d.delete_forward();
9093        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
9094        assert_eq!(
9095            d.caret,
9096            d.source.find("bye").unwrap(),
9097            "the caret stepped down to `bye`"
9098        );
9099    }
9100
9101    #[test]
9102    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
9103        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
9104        d.backspace();
9105        assert_eq!(d.source, "\n");
9106        assert_eq!(media_count(&mut d), 0);
9107    }
9108
9109    #[test]
9110    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
9111        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
9112        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
9113        d.delete_word_back();
9114        assert_eq!(d.source, "hi there\n");
9115
9116        // And in front of one it runs *through* the paragraph break into the
9117        // prose above, which merges the picture inline — so it steps out first,
9118        // and the second press deletes the word it was aimed at.
9119        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
9120        d.delete_word_back();
9121        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
9122        d.delete_word_back();
9123        assert_eq!(
9124            d.source, "hi \n\n![](p.png)\n",
9125            "the word above went, the picture stayed"
9126        );
9127        assert_eq!(media_count(&mut d), 1);
9128    }
9129
9130    #[test]
9131    fn source_view_deletes_raw_markup_against_an_image_untouched() {
9132        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
9133        d.caret = "![](p.png)".len();
9134        d.backspace();
9135        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
9136    }
9137
9138    #[test]
9139    fn image_destination_at_caret_reads_the_image_under_the_caret() {
9140        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
9141        d.caret = 3; // inside the image markup
9142        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
9143        // Past the image, the caret is in no image.
9144        d.caret = "![a cat](cat.png)".len();
9145        assert_eq!(d.image_destination_at_caret(), None);
9146    }
9147
9148    #[test]
9149    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
9150        // The image is one placeholder row by default, and `set_media_rows` grows
9151        // it to the height the frontend measured: the label row plus blank
9152        // `decoration` fillers that hold the vertical space a raster is drawn into.
9153        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
9154        assert_eq!(d.vmap.media.len(), 1);
9155        let img_row = d.vmap.media[0].rows_span.start;
9156        assert_eq!(
9157            d.vmap.media[0].rows_span,
9158            img_row..img_row + 1,
9159            "default is one row"
9160        );
9161
9162        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
9163        d.build_visual(80);
9164        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
9165        let span = d.vmap.media[0].rows_span.clone();
9166        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
9167        // The label row carries the mark and its glyphs; the three below are blank
9168        // decoration — drawn, but no caret and no text.
9169        assert!(
9170            d.vmap.rows[span.start].media.is_some(),
9171            "mark rides the first row"
9172        );
9173        for r in (span.start + 1)..span.end {
9174            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
9175            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
9176            assert!(
9177                d.vmap.rows[r].media.is_none(),
9178                "only the first row is marked"
9179            );
9180        }
9181    }
9182
9183    #[test]
9184    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
9185        // The extra rows are pure spacers: the caret's only homes stay the stop in
9186        // front of the image and the one just past it, so walking the document top
9187        // to bottom visits the same offsets whether the image is 1 row or 5.
9188        let body = "ab\n\n![x](p.png)\n\ncd\n";
9189        let stops_at = |rows: usize| -> Vec<usize> {
9190            let mut d = wysiwyg_doc("img_stops", body);
9191            if rows > 1 {
9192                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
9193                d.build_visual(80);
9194            }
9195            d.caret = 0;
9196            let mut seen = vec![d.caret];
9197            loop {
9198                d.move_right(false);
9199                if *seen.last().unwrap() == d.caret {
9200                    break;
9201                }
9202                seen.push(d.caret);
9203            }
9204            seen
9205        };
9206        assert_eq!(
9207            stops_at(1),
9208            stops_at(5),
9209            "reserving rows must not add stops"
9210        );
9211    }
9212
9213    #[test]
9214    fn insert_link_repoints_the_link_at_a_bare_caret() {
9215        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
9216        d.caret = 3; // in the link's text, nothing selected
9217        d.insert_link("http://y.dev");
9218        assert_eq!(d.source, "[word](http://y.dev)\n");
9219        assert_eq!(d.selected_text(), Some("word"));
9220    }
9221
9222    #[test]
9223    fn insert_link_on_an_empty_range_autolinks_a_url() {
9224        // A link with no text of its own is an autolink, and twig spells it —
9225        // `<…>` is the canonical form and needs no text typed into it, so the
9226        // caret lands after it rather than selecting a finished link.
9227        let mut d = doc_with("link_empty", "\n");
9228        d.caret = 0;
9229        d.insert_link("http://x.dev");
9230        assert_eq!(d.source, "<http://x.dev>\n");
9231        assert_eq!(d.selection(), None);
9232        assert_eq!(d.caret, 14);
9233    }
9234
9235    #[test]
9236    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
9237        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
9238        // in Markdown, so a destination that can't autolink doubles as the text
9239        // instead — which is then selected, ready to be typed over.
9240        let mut d = doc_with("link_rel", "\n");
9241        d.caret = 0;
9242        d.insert_link("./notes.md");
9243        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
9244        assert_eq!(d.selection(), Some((1, 11)));
9245        d.insert("Notes");
9246        assert_eq!(d.source, "[Notes](./notes.md)\n");
9247    }
9248
9249    #[test]
9250    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
9251        // The autolink's text is its URL, so re-pointing replaces the whole
9252        // node — the caret must not splice a second link inside the first.
9253        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
9254        d.caret = 10;
9255        d.insert_link("https://y.dev");
9256        assert_eq!(d.source, "see <https://y.dev> ok\n");
9257    }
9258
9259    #[test]
9260    fn code_language_reads_and_edits_through_the_fence() {
9261        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
9262        d.caret = 10; // inside the code body
9263        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
9264        assert!(d.caret_in_fenced_code());
9265
9266        d.set_code_language("python");
9267        assert!(
9268            d.source.starts_with("```python\n"),
9269            "source: {:?}",
9270            d.source
9271        );
9272        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
9273
9274        // Clearing it leaves a bare fence and no label.
9275        d.set_code_language("");
9276        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
9277        assert_eq!(d.code_language_at_caret(), None);
9278
9279        // A caret outside any code block edits nothing.
9280        let mut p = doc_with("code_lang_none", "just prose\n");
9281        assert!(!p.caret_in_fenced_code());
9282        p.set_code_language("rust");
9283        assert_eq!(p.source, "just prose\n");
9284    }
9285
9286    #[test]
9287    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
9288        // Markdown's info string ends at whitespace, so `two words` would write
9289        // a fence that reads back with a different language than the one asked
9290        // for. twig refuses it; leaf reports that and leaves the source alone.
9291        // The old splice trimmed the ends and wrote whatever was left.
9292        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
9293        d.caret = 10;
9294        d.set_code_language("two words");
9295        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
9296        assert!(d.status.is_some(), "the refusal should be reported");
9297        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
9298    }
9299
9300    #[test]
9301    fn link_destination_at_caret_reads_both_spellings() {
9302        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
9303        d.caret = 5;
9304        assert_eq!(
9305            d.link_destination_at_caret().as_deref(),
9306            Some("https://x.dev")
9307        );
9308        d.caret = 0;
9309        assert_eq!(d.link_destination_at_caret(), None);
9310
9311        // An autolink has no `destination`; its text is the URL.
9312        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
9313        a.caret = 10;
9314        assert_eq!(
9315            a.link_destination_at_caret().as_deref(),
9316            Some("https://x.dev")
9317        );
9318        a.caret = 21;
9319        assert_eq!(a.link_destination_at_caret(), None);
9320    }
9321
9322    #[test]
9323    fn locate_finds_the_block_a_declared_id_names() {
9324        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
9325        // verse. The locator has to land on the *verse*, which is the whole
9326        // reason a link carries one.
9327        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
9328                   {#v2}\nYea, I make a record in the language of my father.\n";
9329        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9330        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
9331        assert_eq!(
9332            d.source[v2.start..v2.end].trim_end(),
9333            "Yea, I make a record in the language of my father."
9334        );
9335        // The attribute line is not part of it: `start` is a place to put a
9336        // caret, and `{#v2}` is markup the caret has no business landing in.
9337        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
9338        assert_eq!(d.locate("v99"), None);
9339    }
9340
9341    #[test]
9342    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
9343        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
9344        // Markdown heading is literal text. So `#the-second-part` can only be
9345        // the heading's own words, which is the rule every Markdown renderer
9346        // already follows and therefore the one a link was authored against.
9347        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
9348        let mut d = doc_with("locate_md", src);
9349        let hit = d.locate("the-second-part").expect("the heading's slug");
9350        assert!(d.source[hit.start..].starts_with("## The Second Part"));
9351        // Bounded by the next heading that isn't under it, so a peek shows the
9352        // section rather than only its title.
9353        assert_eq!(
9354            &d.source[hit.start..hit.end],
9355            "## The Second Part\n\nbody\n\n"
9356        );
9357
9358        // A subsection does not end its parent: `# Title` runs to `## Third`'s
9359        // sibling only because there is no other `#`, so it covers the lot.
9360        let title = d.locate("title").expect("the top heading");
9361        assert_eq!(title.end, d.source.len());
9362    }
9363
9364    #[test]
9365    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
9366        // djot mints `Some-Heading-Here`; a link to it is written
9367        // `#some-heading-here` by nearly everything that writes links. Both
9368        // spellings are one question.
9369        let src = "## Some Heading Here\n\nbody\n";
9370        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9371        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
9372        let slugged = d.locate("some-heading-here").expect("the link's spelling");
9373        assert_eq!(exact, slugged);
9374        // The section, not the heading line — there is more to show than a title.
9375        assert_eq!(&d.source[exact.start..exact.end], src);
9376    }
9377
9378    #[test]
9379    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
9380        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
9381        assert_eq!(d.locate(""), None);
9382        assert_eq!(d.locate("   "), None);
9383        // All punctuation: it names nothing, and must not be read as "match the
9384        // first heading whose slug is also empty".
9385        assert_eq!(d.locate("!!!"), None);
9386    }
9387
9388    #[test]
9389    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
9390        // The document's mistake, and the answer every other anchor
9391        // implementation gives — the alternative is for a link to mean whichever
9392        // of the two a walk happened to reach first.
9393        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
9394        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9395        let hit = d.locate("dup").expect("the first `{#dup}`");
9396        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
9397    }
9398
9399    #[test]
9400    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
9401        // The button's whole job: a reference where the caret was, a definition
9402        // to give it meaning, and the caret waiting in the empty note so the
9403        // next keystroke is the note's first word.
9404        let mut d = doc_with("fn_insert", "A claim and more.\n");
9405        d.caret = 7; // just past "A claim"
9406        d.insert_footnote();
9407        assert!(
9408            d.source.starts_with("A claim[^1] and more."),
9409            "{:?}",
9410            d.source
9411        );
9412        assert!(
9413            d.source.contains("[^1]:"),
9414            "the definition too: {:?}",
9415            d.source
9416        );
9417        assert_eq!(d.status, None);
9418
9419        let reference = d.source.find("[^1]").unwrap();
9420        let note = d
9421            .footnote_at(reference + 2)
9422            .expect("the reference just written");
9423        assert_eq!(note.label, "1");
9424        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
9425        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
9426        // …and typing there is typing into the note, not near it.
9427        d.insert("the note");
9428        assert_eq!(
9429            d.footnote_at(reference + 2).and_then(|f| f.text),
9430            Some("the note".to_string())
9431        );
9432    }
9433
9434    #[test]
9435    fn insert_footnote_numbers_past_the_notes_already_written() {
9436        // A second press must not hand back a label somebody else is using: twig
9437        // reuses a defined label rather than appending a rival definition, so a
9438        // repeat of `1` would quietly point the new reference at the old note.
9439        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
9440        d.caret = 7; // past `[^1]`, before " two."
9441        d.insert_footnote();
9442        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
9443        assert_eq!(d.source.matches("[^2]:").count(), 1);
9444    }
9445
9446    #[test]
9447    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
9448        // `[^2]` with no definition is still a 2 that means something to whoever
9449        // wrote it — stepping over it would mint a note for their reference. A
9450        // word label takes no number, so it blocks none.
9451        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
9452        d.caret = d.source.find(" c").unwrap();
9453        d.insert_footnote();
9454        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
9455        assert!(
9456            d.source.starts_with("a[^2] b[^why][^1] c"),
9457            "{:?}",
9458            d.source
9459        );
9460    }
9461
9462    #[test]
9463    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
9464        // A reference annotates the words before it. Consuming the selection —
9465        // which is what an insert normally does — would delete the very claim
9466        // the author selected in order to footnote.
9467        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
9468        d.anchor = Some(2);
9469        d.caret = 7; // "claim" selected
9470        d.insert_footnote();
9471        assert!(
9472            d.source.starts_with("A claim[^1] and more."),
9473            "{:?}",
9474            d.source
9475        );
9476    }
9477
9478    #[test]
9479    fn a_note_just_written_still_knows_where_its_reference_is() {
9480        // The authoring loop in one test: press the button, type the note, ask to
9481        // go back. The caret ends at the note's last byte — which is the *end* of
9482        // the definition's span, the one offset the query used to exclude — so
9483        // this is where the round trip either works or doesn't.
9484        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
9485        d.caret = 7;
9486        d.insert_footnote();
9487        d.insert("the note");
9488        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
9489        let back = d
9490            .footnote_definition_at_caret()
9491            .expect("still in the note we just typed");
9492        assert_eq!(back.label, "1");
9493        // …and following it lands on the reference's label, where a reader's
9494        // return leg lands.
9495        assert_eq!(back.offset, Some(9));
9496        assert_eq!(&d.source[9..10], "1");
9497    }
9498
9499    #[test]
9500    fn insert_footnote_takes_one_undo_for_both_halves() {
9501        // twig writes the pair as a single edit; the point of that is here.
9502        let before = "A claim and more.\n";
9503        let mut d = doc_with("fn_insert_undo", before);
9504        d.caret = 7;
9505        d.insert_footnote();
9506        assert_ne!(d.source, before);
9507        d.undo();
9508        assert_eq!(d.source, before, "one undo takes back both halves");
9509    }
9510
9511    #[test]
9512    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
9513        // HTML is authorable — it spells the inline marks — and has no footnote.
9514        // The refusal says so rather than writing brackets that would render as
9515        // brackets.
9516        let src = "<p>A claim.</p>\n";
9517        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
9518        assert!(!Capabilities::of(Format::Html).footnote);
9519        d.caret = 5;
9520        d.insert_footnote();
9521        assert_eq!(d.source, src, "nothing written");
9522        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
9523    }
9524
9525    #[test]
9526    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
9527        // The empty body is the one place this could go wrong: the definition
9528        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
9529        // byte early would draw up in the paragraph above the note it belongs to.
9530        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
9531        d.place_caret(7, false);
9532        d.insert_footnote();
9533        d.build_visual(80); // the frame a frontend draws after the edit
9534        assert_eq!(
9535            d.vmap.snap_to_stop(d.caret),
9536            d.caret,
9537            "the caret sits on a stop"
9538        );
9539        let (row, _) = d.caret_pos();
9540        assert!(
9541            drawn_rows(&d)[row].contains("[1]"),
9542            "the caret is on the note's row, not above it: {:?}",
9543            drawn_rows(&d)
9544        );
9545    }
9546
9547    #[test]
9548    fn footnote_at_caret_resolves_a_reference_to_its_note() {
9549        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
9550        // blank line, as one has to.
9551        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
9552        d.caret = 9;
9553        let f = d
9554            .footnote_at_caret()
9555            .expect("the caret stands in a reference");
9556        assert_eq!(f.label, "1");
9557        assert_eq!(f.text.as_deref(), Some("the note"));
9558        // The offset points at the note's first word, not at the definition's
9559        // `[` — the marker is decoration with no caret stop on it.
9560        assert_eq!(f.offset, Some(29));
9561        assert_eq!(&d.source[29..37], "the note");
9562        // …and `end` closes the range, so a frontend can ask which rendered rows
9563        // the note occupies rather than re-deriving them from the text.
9564        assert_eq!(f.end, Some(37));
9565        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
9566    }
9567
9568    /// Two definitions in a row: each is its own note, and neither reaches into
9569    /// the other.
9570    ///
9571    /// A djot definition's span used to run past the blank line into the first
9572    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
9573    /// offsets named the *next* note's rows too, showing a reader two footnotes
9574    /// when they had asked about one. twig 3.1 ends the span after the block's
9575    /// own last line; the test outlives the workaround leaf carried for it.
9576    #[test]
9577    fn footnote_at_stops_a_note_at_the_definition_after_it() {
9578        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
9579        for format in [Format::Markdown, Format::Djot] {
9580            let mut d = Doc::from_source(src.to_string(), format).unwrap();
9581            d.caret = 7;
9582            let f = d.footnote_at_caret().expect("a reference");
9583            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
9584            assert_eq!(
9585                &src[f.offset.unwrap()..f.end.unwrap()],
9586                "first note.",
9587                "in {format:?}"
9588            );
9589        }
9590    }
9591
9592    /// The other side of that boundary: a blank line *inside* a definition is
9593    /// interior to it, and the note keeps its second paragraph.
9594    ///
9595    /// This is what the old body scan cost. It stopped at the first line not
9596    /// indented under the note — a blank line is not — so a two-paragraph note
9597    /// came back as its first paragraph, and "go to note" framed half of it.
9598    /// Reading the span twig gives is both simpler and right.
9599    #[test]
9600    fn footnote_at_keeps_a_notes_second_paragraph() {
9601        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
9602        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9603        d.caret = 7;
9604        let f = d.footnote_at_caret().expect("a reference");
9605        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
9606        // And it stops there — `After.` is the next block, not more note.
9607        assert_eq!(
9608            &src[f.offset.unwrap()..f.end.unwrap()],
9609            f.text.as_deref().unwrap()
9610        );
9611        assert!(!f.text.as_deref().unwrap().contains("After"));
9612    }
9613
9614    #[test]
9615    fn footnote_at_bounds_a_note_whose_body_is_empty() {
9616        // `[^1]:` with nothing after it. The range is empty rather than
9617        // inverted, and still points inside the definition — which is what keeps
9618        // a frontend's row lookup from walking off into the block above.
9619        let src = "A claim[^1].\n\n[^1]:\n";
9620        let mut d = doc_with("fn_empty_body", src);
9621        d.caret = 9;
9622        let f = d.footnote_at_caret().expect("a reference");
9623        assert_eq!(f.text.as_deref(), Some(""));
9624        assert_eq!(f.offset, f.end, "an empty note is an empty range");
9625        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
9626    }
9627
9628    #[test]
9629    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
9630        let mut d = doc_with(
9631            "fn_at_caret_none",
9632            "A claim[^1] and more.\n\n[^1]: the note\n",
9633        );
9634        d.caret = 2; // in the prose
9635        assert_eq!(d.footnote_at_caret(), None);
9636    }
9637
9638    #[test]
9639    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
9640        // The two are deliberately separate: a reference names a note in this
9641        // document, a link names somewhere to leave for, and answering one with
9642        // the other is what made a reference click do nothing at all.
9643        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
9644        d.caret = 3; // the `1` of `[^1]`
9645        assert!(d.footnote_at_caret().is_some());
9646        assert_eq!(
9647            d.link_destination_at_caret(),
9648            None,
9649            "a reference is not a link"
9650        );
9651
9652        d.caret = 10; // inside the link's label
9653        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
9654        assert_eq!(
9655            d.link_destination_at_caret().as_deref(),
9656            Some("https://x.dev")
9657        );
9658    }
9659
9660    #[test]
9661    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
9662        // A `[^99]` the document never defines is a real state — a note deleted
9663        // out from under its reference — and the label is what lets a frontend
9664        // say so. `None` here would be indistinguishable from "not on a
9665        // reference", which is the wrong thing to tell a reader.
9666        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
9667        d.caret = 9;
9668        let f = d
9669            .footnote_at_caret()
9670            .expect("the reference is still a reference");
9671        assert_eq!(f.label, "99");
9672        assert_eq!(f.text, None);
9673        assert_eq!(f.offset, None);
9674    }
9675
9676    #[test]
9677    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
9678        // Labels are not always numbers, and a note's body runs past its first
9679        // line — the indented continuation belongs to the note, so it comes back
9680        // with it (source bytes, verbatim, as documented).
9681        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
9682        let mut d = doc_with("fn_word_label", src);
9683        d.caret = 6;
9684        let f = d
9685            .footnote_at_caret()
9686            .expect("the caret stands in a reference");
9687        assert_eq!(f.label, "note");
9688        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
9689    }
9690
9691    #[test]
9692    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
9693        // The point of the offset form: a pointer hovering a reference asks what
9694        // note it names, and must not drag the caret along to ask.
9695        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
9696        d.caret = 0;
9697        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
9698        assert_eq!(f.label, "1");
9699        assert_eq!(f.text.as_deref(), Some("the note"));
9700        assert_eq!(d.caret, 0, "asking must not move the caret");
9701        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
9702    }
9703
9704    #[test]
9705    fn footnote_definition_at_caret_points_back_at_the_reference() {
9706        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
9707        // the caret can rest on — is at 9.
9708        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
9709        d.caret = 30; // inside the note's body
9710        let f = d
9711            .footnote_definition_at_caret()
9712            .expect("the caret stands in a definition");
9713        assert_eq!(f.label, "1");
9714        assert_eq!(f.offset, Some(9));
9715        assert_eq!(&d.source[7..11], "[^1]");
9716    }
9717
9718    #[test]
9719    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
9720        // The two legs have to meet: wherever `footnote_at` sends the caret, the
9721        // definition query must answer for — otherwise arriving at a note leaves
9722        // the reader somewhere the way back isn't offered.
9723        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
9724        let mut d = doc_with("fn_def_marker", src);
9725        let landed = d.footnote_at(9).unwrap().offset.unwrap();
9726        assert_eq!(
9727            d.footnote_definition_at(landed).and_then(|f| f.offset),
9728            Some(9),
9729            "the note a reference sends you to offers the way back"
9730        );
9731    }
9732
9733    #[test]
9734    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
9735        // The two queries answer for disjoint places, which is what lets one
9736        // gesture mean "down to the note" in one and "back up" in the other
9737        // without either having to remember which way the reader is going.
9738        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
9739        d.caret = 2; // prose
9740        assert_eq!(d.footnote_definition_at_caret(), None);
9741        d.caret = 9; // the reference
9742        assert_eq!(d.footnote_definition_at_caret(), None);
9743        assert!(
9744            d.footnote_at_caret().is_some(),
9745            "which is the reference's own query"
9746        );
9747    }
9748
9749    #[test]
9750    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
9751        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
9752        // note", which is false and leaves a frontend unable to explain why the
9753        // way back is missing.
9754        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
9755        let mut d = doc_with("fn_def_orphan", src);
9756        d.caret = src.find("orphan").unwrap();
9757        let f = d
9758            .footnote_definition_at_caret()
9759            .expect("an orphan is still a definition");
9760        assert_eq!(f.label, "2");
9761        assert_eq!(f.offset, None);
9762    }
9763
9764    #[test]
9765    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
9766        // One label, cited twice. The first is where the reader most likely came
9767        // from, and the only answer that doesn't depend on how they got here.
9768        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
9769        let mut d = doc_with("fn_def_repeat", src);
9770        d.caret = src.find("the note").unwrap();
9771        let f = d.footnote_definition_at_caret().expect("a definition");
9772        assert_eq!(
9773            f.offset,
9774            Some(5),
9775            "the first `[^a]`'s label, not the second's"
9776        );
9777        assert_eq!(&src[3..7], "[^a]");
9778    }
9779
9780    #[test]
9781    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
9782        // Down and back up, each leg found from the document rather than from a
9783        // memory of the other — so it still works for a reader who scrolled to
9784        // the notes instead of jumping there.
9785        //
9786        // `place_caret` rather than assigning `caret`, because that is what a
9787        // frontend calls: it snaps to a real caret stop, and a jump that lands
9788        // on a byte the caret can't rest on would arrive somewhere the return
9789        // leg no longer answers for. `build_map` first, since snapping is a
9790        // no-op until the map exists — which is exactly how this went unnoticed
9791        // when the offsets pointed at the `[^` markers.
9792        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
9793        d.build_map(None);
9794        d.place_caret(9, false);
9795        let down = d
9796            .footnote_at_caret()
9797            .expect("a reference")
9798            .offset
9799            .expect("a note");
9800        d.place_caret(down, false);
9801        let up = d
9802            .footnote_definition_at_caret()
9803            .expect("a definition")
9804            .offset
9805            .expect("a reference");
9806        d.place_caret(up, false);
9807        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
9808        assert_eq!(
9809            d.footnote_at_caret().expect("back on the reference").label,
9810            "1"
9811        );
9812    }
9813
9814    #[test]
9815    fn insert_link_hands_the_destination_to_twig_raw() {
9816        // Escaping is twig's, and format-specific: Markdown ends a destination
9817        // at the first space and needs the `<…>` form, where djot would read
9818        // those angle brackets as part of the URL.
9819        let mut d = doc_with("link_space", "word\n");
9820        d.anchor = Some(0);
9821        d.caret = 4;
9822        d.insert_link("a b");
9823        assert_eq!(d.source, "[word](<a b>)\n");
9824    }
9825
9826    #[test]
9827    fn insert_link_reports_a_destination_no_format_can_carry() {
9828        let mut d = doc_with("link_bad", "word\n");
9829        d.anchor = Some(0);
9830        d.caret = 4;
9831        d.insert_link("a\nb");
9832        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
9833        assert!(
9834            d.status.is_some(),
9835            "InvalidArgument should reach the status line"
9836        );
9837        assert!(!d.dirty);
9838    }
9839
9840    #[test]
9841    fn insert_link_works_in_wysiwyg_view() {
9842        let mut d = wysiwyg_doc("link_wys", "word here\n");
9843        d.anchor = Some(0);
9844        d.caret = 4;
9845        d.insert_link("http://x.dev");
9846        assert_eq!(d.source, "[word](http://x.dev) here\n");
9847        assert_eq!(d.selected_text(), Some("word"));
9848        // The map the caret has to keep riding is rebuilt each frame; motion
9849        // over the fresh one must still land on a real stop (the debug_assert).
9850        d.build_visual(80);
9851        d.move_right(false);
9852        d.move_left(false);
9853    }
9854
9855    #[test]
9856    fn click_maps_a_row_col_to_a_byte_offset() {
9857        let mut d = doc_with("click", "ab\ncd\n");
9858        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
9859        assert_eq!(d.caret, 4);
9860    }
9861
9862    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
9863    // stop just as the `(row, col)` click path does, so the caret can never come
9864    // to rest in the blank gap between two paragraphs — where it would draw in one
9865    // place and type in another.
9866    #[test]
9867    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
9868        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
9869        // caret stop (stops are 0,1,3,4).
9870        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
9871        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
9872        d.place_caret(2, false);
9873        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9874        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
9875    }
9876
9877    #[test]
9878    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
9879        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
9880        d.place_caret(0, false); // anchor at the start of "A"
9881        d.place_caret(2, true); // drag into the gap
9882        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9883        let (s, e) = d.selection().expect("a selection");
9884        assert!(
9885            d.vmap.is_stop(s) && d.vmap.is_stop(e),
9886            "selection {s}..{e} off a stop"
9887        );
9888    }
9889
9890    #[test]
9891    fn place_caret_on_a_real_stop_is_left_untouched() {
9892        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
9893        d.place_caret(3, false); // the start of "B" — a genuine stop
9894        assert_eq!(d.caret, 3);
9895    }
9896
9897    // An *empty paragraph* (two blank lines, an intentional blank line the user
9898    // opened) is a real caret stop, unlike the gap — a click into it must stay.
9899    #[test]
9900    fn place_caret_rests_in_an_empty_paragraph() {
9901        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
9902        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
9903        assert!(d.vmap.is_stop(empty));
9904        d.place_caret(empty, false);
9905        assert_eq!(d.caret, empty);
9906    }
9907
9908    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
9909    // a drag over the word `bold` ends there, and a caret placed there stays.
9910    #[test]
9911    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
9912        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
9913        let mut d = wysiwyg_doc("place_mark_end", src);
9914        let start = src.find("bold").unwrap();
9915        d.place_caret(start, false);
9916        d.place_caret(start + 4, true);
9917        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
9918        d.toggle(InlineKind::Strong);
9919        assert_eq!(d.source, src.replace("**bold**", "bold"));
9920    }
9921
9922    #[test]
9923    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
9924        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
9925        d.caret = 7; // before the `d`
9926        d.move_right(false);
9927        assert_eq!(d.caret, 8, "onto the end of the bold");
9928        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9929        d.move_right(false);
9930        assert_eq!(d.caret, 10, "past the closing `**`");
9931        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9932        d.move_left(false);
9933        assert_eq!(d.caret, 8);
9934        d.move_left(false);
9935        assert_eq!(d.caret, 7);
9936        // Typing at the inner home extends the bold.
9937        d.caret = 8;
9938        d.insert("!");
9939        assert_eq!(d.source, "a **bold!** b");
9940    }
9941
9942    #[test]
9943    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
9944        // The splice path shifts the home with the block it is in, and the
9945        // re-rendered block finds its own again.
9946        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
9947        d.build_visual_unwrapped();
9948        d.edit(0, 0, "zz");
9949        d.build_visual_unwrapped();
9950        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
9951        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
9952        let at = d.source.find("bold").unwrap();
9953        d.edit(at, at, "very ");
9954        d.build_visual_unwrapped();
9955        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
9956        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
9957    }
9958
9959    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
9960        doc_in(View::Wysiwyg, name, body)
9961    }
9962
9963    /// How many list items the source actually parses into — the check that a
9964    /// marker Leaf wrote is a marker the format agrees is one.
9965    fn list_items(doc: &mut Doc) -> usize {
9966        doc.editor
9967            .nodes()
9968            .unwrap()
9969            .iter()
9970            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
9971            .count()
9972    }
9973
9974    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
9975    /// incremental (`build_spliced` / `build_cached`) path must always match.
9976    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
9977        reference_map_revealing(source, None)
9978    }
9979
9980    /// [`reference_map`] with a reveal line — the ground truth for the
9981    /// `MarkupMode::Full` builds, where the map is a function of the caret's
9982    /// line as well as the text.
9983    fn reference_map_revealing(
9984        source: &str,
9985        reveal: Option<Range<usize>>,
9986    ) -> crate::wysiwyg::VisualMap {
9987        // The same parse `Doc` uses. With twig's plain defaults instead, the two
9988        // sides disagree on what the *document* is before the renderer is even
9989        // reached — a bare `:word` is a text directive to one and prose to the
9990        // other — and the mismatch reads as a splice bug that isn't one.
9991        let mut ed =
9992            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
9993        let nodes = ed.nodes().unwrap();
9994        crate::wysiwyg::build(
9995            &nodes,
9996            source,
9997            None,
9998            false,
9999            &std::collections::HashMap::new(),
10000            reveal,
10001        )
10002    }
10003
10004    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
10005        if a.rows.len() != b.rows.len() {
10006            return true;
10007        }
10008        for (ra, rb) in a.rows.iter().zip(&b.rows) {
10009            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
10010                return true;
10011            }
10012            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
10013                if ga.ch != gb.ch || ga.src != gb.src {
10014                    return true;
10015                }
10016            }
10017        }
10018        false
10019    }
10020
10021    #[test]
10022    fn incremental_build_matches_a_fresh_build_across_edits() {
10023        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
10024        // fast path, gated on twig's `dirty_range`) or falls back to
10025        // `build_cached`. After each edit the map must be byte-identical to a
10026        // from-scratch build — this is the correctness net under the splice.
10027        let docs = [
10028            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
10029            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
10030            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
10031            // A footnote definition is a root beside `doc`, merged back into the
10032            // top-level list by `wysiwyg::top_blocks`. The random edits below
10033            // make and unmake definitions as they go (a deleted `:` turns one
10034            // back into a paragraph, and vice versa), which is exactly the
10035            // structural churn the splice path has to notice and bail out of.
10036            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
10037            // A comment is a top-level block that draws no rows — a layout entry
10038            // at zero rows either side of blocks that do. The edits below type
10039            // into the blocks around it (a splice past a hidden block), and
10040            // break the comment open into prose and back (a structural change).
10041            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
10042            // Link reference definitions: a hidden block that an edit can turn
10043            // into a paragraph (a deleted `:`) and back, and whose own bytes an
10044            // edit can land in.
10045            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
10046        ];
10047        // A deterministic mix: mostly single characters (which stay inside one
10048        // block → splice), plus edits that reshape structure (a paragraph break,
10049        // a heading marker, a code fence → fallback), so both paths are exercised.
10050        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
10051        for src in docs {
10052            let mut d = wysiwyg_doc("diff", src);
10053            d.build_visual_unwrapped();
10054            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
10055
10056            for step in 0..60usize {
10057                let len = d.source.len();
10058                let raw = (step * 13 + 5) % (len + 1);
10059                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
10060                let pre = d.source.clone();
10061                let action;
10062                if step % 3 == 0 && pos < len {
10063                    let end = (pos + 1..=len)
10064                        .find(|&i| d.source.is_char_boundary(i))
10065                        .unwrap();
10066                    action = format!("delete [{pos},{end})");
10067                    d.edit(pos, end, "");
10068                } else {
10069                    let ins = inserts[step % inserts.len()];
10070                    action = format!("insert {ins:?} @ {pos}");
10071                    d.edit(pos, pos, ins);
10072                }
10073                d.build_visual_unwrapped();
10074                if maps_differ(&d.vmap, &reference_map(&d.source)) {
10075                    panic!(
10076                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
10077                        d.source
10078                    );
10079                }
10080            }
10081        }
10082    }
10083
10084    /// A frontend is handed [`Doc::vmap`] and may present it differently:
10085    /// leaf-ratatui splices blank filler rows under an oversized heading so the
10086    /// raster it paints there has somewhere to stand, and leaves them in the map
10087    /// because the caret and the mouse both read it between frames. The splice
10088    /// path addresses that map by *row index*, against the block layout the last
10089    /// build recorded — so handed a map with rows in it that no block owns, it
10090    /// laid the re-rendered block over one of the fillers and carried the rows
10091    /// the block really occupied into the suffix. One stranded copy of the
10092    /// edited line, and everything below it a row further down, per keystroke.
10093    ///
10094    /// A map that isn't the one the layout describes is a map this path can't
10095    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
10096    #[test]
10097    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
10098        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
10099        d.build_visual_unwrapped();
10100
10101        // Stand in for the heading filler rows: two blank rows past the heading
10102        // that no block accounts for. Cloning a real row keeps every field
10103        // plausible — it is the row *count* the splice can't survive.
10104        let filler = d.vmap.rows[0].clone();
10105        d.vmap.rows.insert(1, filler.clone());
10106        d.vmap.rows.insert(1, filler);
10107
10108        // An edit inside the last block: the single-block case the splice path
10109        // is for, and the one the frontend hits on every keystroke.
10110        let at = d.source.len() - 1;
10111        d.edit(at, at, "!");
10112        d.build_visual_unwrapped();
10113
10114        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
10115    }
10116
10117    #[test]
10118    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
10119        // The same correctness net as `incremental_build_matches_a_fresh_build_
10120        // across_edits`, under `MarkupMode::Full` — where the map depends on
10121        // the caret's *line* as well as the text, so the two caches have a new
10122        // way to be wrong. Both are exercised: the block cache can hand back
10123        // rows built for a line that is no longer the revealed one, and the
10124        // splice path can reuse a suffix that still has yesterday's line raw.
10125        //
10126        // Caret motion is interleaved with the edits deliberately, because a
10127        // caret that only ever moved with the edit would never cross a line
10128        // without also dirtying it — the case where a stale reveal survives.
10129        let docs = [
10130            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
10131            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
10132        ];
10133        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
10134        for src in docs {
10135            let mut d = wysiwyg_doc("reveal_diff", src);
10136            d.set_markup_mode(MarkupMode::Full);
10137
10138            for step in 0..60usize {
10139                let len = d.source.len();
10140                let raw = (step * 13 + 5) % (len + 1);
10141                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
10142                let pre = d.source.clone();
10143                let action;
10144                if step % 3 == 0 && pos < len {
10145                    let end = (pos + 1..=len)
10146                        .find(|&i| d.source.is_char_boundary(i))
10147                        .unwrap();
10148                    action = format!("delete [{pos},{end})");
10149                    d.edit(pos, end, "");
10150                } else {
10151                    let ins = inserts[step % inserts.len()];
10152                    action = format!("insert {ins:?} @ {pos}");
10153                    d.edit(pos, pos, ins);
10154                }
10155                // Walk the caret somewhere else in the document, independently
10156                // of where the edit landed.
10157                let want = (step * 29 + 11) % (d.source.len() + 1);
10158                d.caret = (want..=d.source.len())
10159                    .find(|&i| d.source.is_char_boundary(i))
10160                    .unwrap();
10161                d.build_visual_unwrapped();
10162
10163                let want = reference_map_revealing(&d.source, d.reveal_line());
10164                if maps_differ(&d.vmap, &want) {
10165                    panic!(
10166                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
10167                        d.caret, d.source
10168                    );
10169                }
10170            }
10171        }
10172    }
10173
10174    #[test]
10175    fn caret_motion_across_lines_rebuilds_only_under_full() {
10176        // The cache-key change has to earn its keep in both directions: `Full`
10177        // must rebuild when the caret changes line (or the reveal would never
10178        // move), and the hidden modes must *not* (or every arrow key would pay
10179        // for a feature they don't use). The existing `cache_motion` test pins
10180        // the second for the default mode; this pins the pair against a mode
10181        // change alone.
10182        let body = "*one* here\n\n*two* there\n";
10183
10184        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
10185        full.set_markup_mode(MarkupMode::Full);
10186        caret_at(&mut full, "one");
10187        let before = full.revision();
10188        caret_at(&mut full, "two");
10189        assert_eq!(full.revision(), before, "motion is not an edit");
10190        assert!(
10191            drawn_rows(&full).iter().any(|r| r == "*two* there"),
10192            "the map followed the caret: {:?}",
10193            drawn_rows(&full)
10194        );
10195
10196        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
10197        caret_at(&mut hidden, "one");
10198        let key = hidden.vmap_key.clone();
10199        caret_at(&mut hidden, "two");
10200        assert_eq!(
10201            hidden.vmap_key, key,
10202            "a hidden mode rebuilds nothing on motion"
10203        );
10204    }
10205
10206    #[test]
10207    fn wysiwyg_down_crosses_a_paragraph_boundary() {
10208        // Regression: the blank separator row used to share the previous
10209        // paragraph's end offset, so Down got pinned at the boundary (while Up
10210        // still crossed). Both directions must step through it symmetrically.
10211        //
10212        // It's now stepped *over* rather than onto: the blank line between two
10213        // paragraphs is the boundary being drawn, not a line of the document, so
10214        // one press of Down crosses it. The goal column survives the crossing —
10215        // col 3 at the end of "abc" is col 3 at the end of "def".
10216        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
10217        d.caret = 3; // end of "abc" (row 0)
10218        d.move_down(false);
10219        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
10220        assert_eq!(d.caret, 8); // end of "def", col 3 kept
10221        d.move_up(false);
10222        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
10223        assert_eq!(d.caret, 3);
10224    }
10225
10226    #[test]
10227    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
10228        // The second Up and the second Down here run off the ends of the
10229        // document, which is no longer a place a press is swallowed: they carry
10230        // the caret to the start and the end of the text. The claim in the
10231        // middle — that a Down retraces the Up that crossed the paragraph gap —
10232        // is the one this test is for, and it is asserted where it is made.
10233        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
10234        d.caret = 5; // start of "def"
10235        let start = d.caret_pos();
10236        d.move_up(false);
10237        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
10238        d.move_up(false);
10239        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
10240        d.move_down(false);
10241        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
10242        d.move_down(false);
10243        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
10244    }
10245
10246    #[test]
10247    fn wysiwyg_new_paragraph_shows_before_typing() {
10248        // Regression: two Enters at the end of a paragraph produced trailing
10249        // newlines with no AST node, so the caret appeared stuck on the old line
10250        // until a character was typed. It must ride down onto the new line now.
10251        let mut d = doc_with("wys_newpara", "abc\n");
10252        d.view = View::Wysiwyg;
10253        d.caret = 3;
10254        d.insert("\n");
10255        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
10256        assert_eq!(d.source, "abc\n\n\n");
10257        d.build_visual(80);
10258        let (row, _) = d.caret_pos();
10259        assert!(
10260            row >= 2,
10261            "caret should have moved down to the new line, got row {row}"
10262        );
10263        assert!(
10264            d.vmap.num_rows() >= 3,
10265            "the blank lines should render as rows"
10266        );
10267    }
10268
10269    #[test]
10270    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
10271        // The reported bug: Enter at the end of a paragraph that has another
10272        // paragraph below put the caret at the *start of the next paragraph* —
10273        // the empty paragraph it opened had no row, so the caret snapped onto
10274        // "World". It must now sit on its own empty line, with a blank spacer
10275        // above it (the paragraph gap).
10276        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
10277        d.caret = 5; // end of "Hello"
10278        d.newline();
10279        d.build_visual(80);
10280        let (row, col) = d.caret_pos();
10281        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
10282        assert_eq!(
10283            d.vmap.row_width(row),
10284            0,
10285            "caret's row must be empty, not 'World'"
10286        );
10287        assert!(
10288            row >= 2,
10289            "a blank spacer row should sit above the caret, got row {row}"
10290        );
10291        // The row above the caret is a real (empty) gap, and "Hello" stays put.
10292        assert_eq!(
10293            d.vmap.row_width(row - 1),
10294            0,
10295            "the row above the caret is a gap"
10296        );
10297        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
10298        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
10299    }
10300
10301    #[test]
10302    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
10303        // At the document end a single Enter must also show the paragraph gap —
10304        // a blank spacer row above the caret — so the layout already matches how
10305        // it will look once the new paragraph has text.
10306        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
10307        d.caret = 5; // end of "Hello", no trailing newline
10308        d.newline(); // source becomes "Hello\n\n"
10309        d.build_visual(80);
10310        let (row, col) = d.caret_pos();
10311        assert_eq!(col, 0);
10312        assert!(
10313            row >= 2,
10314            "caret should sit below a blank spacer, got row {row}"
10315        );
10316        assert_eq!(
10317            d.vmap.row_width(row - 1),
10318            0,
10319            "the row above the caret is a gap"
10320        );
10321    }
10322
10323    #[test]
10324    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
10325        // The spacer is view-only: typing the new paragraph must not reflow the
10326        // caret onto a different row — the transient view already matched the
10327        // settled one.
10328        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
10329        d.caret = 5;
10330        d.newline();
10331        d.build_visual(80);
10332        let before = d.caret_pos();
10333        d.insert("New");
10334        d.build_visual(80);
10335        let after = d.caret_pos();
10336        assert_eq!(
10337            after.0, before.0,
10338            "typing must not move the caret to another row ({before:?} -> {after:?})"
10339        );
10340    }
10341
10342    #[test]
10343    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
10344        // Return at the end of the block's last line writes an empty line the
10345        // map used to drop, so the caret landed on `after` and the next
10346        // keystroke went into the paragraph below instead of into the code.
10347        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
10348        d.caret = d.source.find("beta").unwrap() + "beta".len();
10349        d.build_visual(80);
10350        let before = d.caret_pos().0;
10351
10352        d.newline();
10353        d.build_visual(80);
10354        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
10355
10356        let (row, col) = d.caret_pos();
10357        assert_eq!(row, before + 1, "the caret moves down one row");
10358        assert_eq!(col, 0, "onto the head of the empty line");
10359        let span = d.vmap.code_blocks[0].rows_span.clone();
10360        assert!(
10361            span.contains(&row),
10362            "caret row {row} is outside the block's rows {span:?}"
10363        );
10364
10365        // The whole point: what is typed next is code.
10366        d.insert("gamma");
10367        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
10368    }
10369
10370    #[test]
10371    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
10372        let fm = "---\ntitle: hi\n---\n";
10373        let body = format!("{fm}# leaf\n\nbody\n");
10374        let mut d = wysiwyg_doc("wys_fm", &body);
10375        // Opening lifts the caret out of the now-hidden frontmatter.
10376        assert_eq!(
10377            d.caret,
10378            fm.len(),
10379            "caret should start at the first real block"
10380        );
10381        // Left at the content start can't step back into frontmatter.
10382        d.move_left(false);
10383        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
10384        // Doc-start lands on the content floor, not offset 0.
10385        d.move_doc_start(false);
10386        assert_eq!(d.caret, fm.len());
10387        // Select-all + copy never include the frontmatter bytes.
10388        d.select_all();
10389        let sel = d.selected_text().unwrap().to_string();
10390        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
10391        assert!(
10392            sel.starts_with("# leaf"),
10393            "selection should begin at content: {sel:?}"
10394        );
10395    }
10396
10397    #[test]
10398    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
10399        // A fresh note is frontmatter and nothing else. With no rendered block
10400        // to floor the caret it opened at offset 0 — before the opening `---` —
10401        // so the first keystroke wrote itself in front of the metadata and the
10402        // file came out as `This---\ntitle: …`.
10403        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
10404        let mut d = wysiwyg_doc("wys_fm_only", fm);
10405        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
10406        // Nothing is rendered, so the caret draws at the origin of an empty view
10407        // — the same place an empty document puts it.
10408        assert_eq!(d.caret_pos(), (0, 0));
10409        d.insert("This");
10410        assert_eq!(d.source, format!("{fm}This"));
10411    }
10412
10413    /// `select_range` is the verb for a range a host already knows the bytes of,
10414    /// so it must not snap — and must still hold every invariant `place_caret`
10415    /// holds, the frontmatter floor above all.
10416    #[test]
10417    fn select_range_takes_the_range_as_given_but_still_floors_it() {
10418        let fm = "---\ntitle: foo\n---\n\n";
10419        let body = format!("{fm}body foo here\n");
10420        let mut d = wysiwyg_doc("wys_select_range", &body);
10421
10422        // The `foo` in the body: taken exactly, not snapped to a caret stop.
10423        let at = body.rfind("foo").unwrap();
10424        d.select_range(at, at + 3);
10425        assert_eq!(d.selection(), Some((at, at + 3)));
10426        assert_eq!(d.selected_text(), Some("foo"));
10427
10428        // The `foo` in the hidden frontmatter: below the floor, so both ends
10429        // come up to it rather than parking the caret in the metadata, where a
10430        // later keystroke would rewrite `title:`.
10431        let hidden = body.find("foo").unwrap();
10432        assert!(hidden < d.vmap.content_start);
10433        d.select_range(hidden, hidden + 3);
10434        assert!(
10435            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
10436            "a range under the floor must not leave the caret in the frontmatter"
10437        );
10438
10439        // Past the end, and mid-character, are both brought back to something
10440        // sliceable rather than panicking the next reader of the range.
10441        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
10442        let mut d = multi;
10443        d.select_range(2, 9_999);
10444        assert_eq!(d.caret, d.source.len());
10445        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
10446        assert!(d.source.is_char_boundary(d.caret));
10447    }
10448
10449    /// The bug `select_range` exists for: a match butting up against a hidden
10450    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
10451    /// the one before the `**`.
10452    #[test]
10453    fn select_range_does_not_snap_off_a_hidden_delimiter() {
10454        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
10455        let at = d.source.find("needle").unwrap();
10456        d.select_range(at, at + 6);
10457        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
10458    }
10459
10460    #[test]
10461    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
10462        // Backspace deletes `prev_boundary..caret` directly; at the first real
10463        // block that boundary is inside the hidden frontmatter, so it must be a
10464        // no-op rather than eating the closing `---`.
10465        let fm = "---\ntitle: hi\n---\n";
10466        let body = format!("{fm}leaf\n");
10467        let mut d = wysiwyg_doc("wys_fm_bs", &body);
10468        assert_eq!(d.caret, fm.len());
10469        d.backspace();
10470        assert_eq!(d.source, body, "backspace must not touch frontmatter");
10471        d.delete_word_back();
10472        assert_eq!(
10473            d.source, body,
10474            "word-delete must not touch frontmatter either"
10475        );
10476    }
10477
10478    #[test]
10479    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
10480        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
10481        // fenced div, really, since core parses these on for every document
10482        // now (`parse_extensions`). The container is a `directive` node, an
10483        // `is_block_container` kind like `block_quote`, so the caret works
10484        // inside its child paragraph exactly as it would inside a quote: typing
10485        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
10486        // untouched.
10487        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
10488        let mut d = wysiwyg_doc("wys_vis", body);
10489        d.caret = body.find("hello").unwrap() + "hello".len();
10490        d.insert("!");
10491        assert_eq!(
10492            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
10493            "typing inside the block edits its content in place"
10494        );
10495        assert!(
10496            d.source.contains(":::vis{.public .family}"),
10497            "opening fence survives"
10498        );
10499        assert!(d.source.contains(":::\nafter"), "closing fence survives");
10500    }
10501
10502    #[test]
10503    fn source_view_still_reaches_frontmatter() {
10504        // The metadata is only *hidden*, never lost: the source view edits and
10505        // selects it in full, and it's always preserved on save.
10506        let fm = "---\ntitle: hi\n---\n";
10507        let body = format!("{fm}# leaf\n");
10508        let mut d = doc_with("src_fm", &body);
10509        d.select_all();
10510        let sel = d.selected_text().unwrap();
10511        assert!(
10512            sel.contains("title"),
10513            "source view should select everything"
10514        );
10515        d.move_doc_start(false);
10516        assert_eq!(d.caret, 0, "source view can reach offset 0");
10517    }
10518
10519    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
10520
10521    #[test]
10522    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
10523        // The border and padding between two cells all share one source offset,
10524        // so a column-stepping caret would sit on `│` and then stall there
10525        // forever. Right must step: end of "Name" -> start of "Qty".
10526        let mut d = wysiwyg_doc("tbl_right", TABLE);
10527        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
10528        d.move_right(false);
10529        assert_eq!(
10530            d.caret,
10531            TABLE.find("Qty").unwrap(),
10532            "should land in the next cell"
10533        );
10534        let (r, c) = d.caret_pos();
10535        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
10536    }
10537
10538    #[test]
10539    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
10540        let mut d = wysiwyg_doc("tbl_left", TABLE);
10541        d.caret = TABLE.find("Qty").unwrap();
10542        d.move_left(false);
10543        assert_eq!(
10544            d.caret,
10545            TABLE.find("Name").unwrap() + 4,
10546            "end of the previous cell"
10547        );
10548    }
10549
10550    #[test]
10551    fn wysiwyg_down_steps_over_a_table_rule() {
10552        // Between the header and the first body row sits a `├───┼───┤` rule.
10553        // It's drawn but holds no caret, so one Down must reach "Pear".
10554        let mut d = wysiwyg_doc("tbl_down", TABLE);
10555        d.caret = TABLE.find("Name").unwrap();
10556        d.move_down(false);
10557        assert_eq!(
10558            d.caret,
10559            TABLE.find("Pear").unwrap(),
10560            "one Down reaches the body row"
10561        );
10562        d.move_down(false);
10563        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
10564    }
10565
10566    #[test]
10567    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
10568        let mut d = wysiwyg_doc("tbl_tab", TABLE);
10569        d.caret = TABLE.find("Name").unwrap();
10570        // A hop lands with the destination cell's whole content selected, the
10571        // caret at its end — so typing replaces the cell like a form field.
10572        assert!(d.cell_hop(true));
10573        assert_eq!(
10574            d.selected_text(),
10575            Some("Qty"),
10576            "the target cell comes up selected"
10577        );
10578        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
10579        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
10580        assert_eq!(d.selected_text(), Some("Pear"));
10581        assert!(d.cell_hop(false));
10582        assert_eq!(d.selected_text(), Some("Qty"));
10583    }
10584
10585    #[test]
10586    fn tab_outside_a_table_is_not_a_cell_hop() {
10587        // `cell_hop` reports false so the frontend can indent as usual.
10588        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
10589        d.caret = 4;
10590        assert!(!d.cell_hop(true));
10591        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
10592    }
10593
10594    #[test]
10595    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
10596        let mut d = wysiwyg_doc("tbl_edge", TABLE);
10597        d.caret = TABLE.rfind("12").unwrap(); // the final cell
10598        assert!(!d.cell_hop(true), "no cell after the last one");
10599        d.caret = TABLE.find("Name").unwrap();
10600        assert!(!d.cell_hop(false), "no cell before the first one");
10601    }
10602
10603    #[test]
10604    fn wysiwyg_vertical_cell_motion_holds_the_column() {
10605        // Down/Up step to the cell above/below in the *same column*, not back to
10606        // the top-left the way a naive row/col motion over the picture would.
10607        let mut d = wysiwyg_doc("tbl_vert", TABLE);
10608        d.caret = TABLE.find("Qty").unwrap();
10609        // Each vertical hop selects the destination cell, holding the column.
10610        assert!(d.cell_move_vertical(true));
10611        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
10612        assert!(d.cell_move_vertical(true));
10613        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
10614        assert!(!d.cell_move_vertical(true), "no row below the last");
10615        assert!(d.cell_move_vertical(false));
10616        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
10617        assert!(d.cell_move_vertical(false));
10618        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
10619        assert!(!d.cell_move_vertical(false), "no row above the header");
10620    }
10621
10622    #[test]
10623    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
10624        let mut d = wysiwyg_doc("tbl_grow", TABLE);
10625        d.caret = TABLE.rfind("12").unwrap();
10626        let rows_before = d.source.matches('\n').count();
10627        assert!(d.cell_tab(true), "acts as a table key");
10628        assert_eq!(
10629            d.source.matches('\n').count(),
10630            rows_before + 1,
10631            "a fresh row was appended"
10632        );
10633        assert!(d.caret_in_table(), "the caret entered the new row");
10634        // The caret sits in the new row's first cell — past the old last cell.
10635        assert!(d.caret > TABLE.rfind("12").unwrap());
10636    }
10637
10638    #[test]
10639    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
10640        let mut d = wysiwyg_doc("tbl_ret", TABLE);
10641        d.caret = TABLE.find("Name").unwrap();
10642        assert!(d.cell_return(), "acts as a table key");
10643        assert_eq!(
10644            d.selected_text(),
10645            Some("Pear"),
10646            "Return drops one cell, selecting it"
10647        );
10648        // From the last row, Return appends a row and enters it.
10649        d.caret = TABLE.rfind("Fig").unwrap();
10650        let rows_before = d.source.matches('\n').count();
10651        assert!(d.cell_return());
10652        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
10653        assert!(d.caret_in_table());
10654    }
10655
10656    #[test]
10657    fn return_and_tab_outside_a_table_decline() {
10658        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
10659        d.caret = 4;
10660        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
10661        assert!(!d.cell_tab(true), "no table: the frontend indents");
10662        assert!(
10663            !d.cell_line_break(),
10664            "no table: the frontend breaks the line"
10665        );
10666    }
10667
10668    #[test]
10669    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
10670        let mut d = wysiwyg_doc("tbl_break", TABLE);
10671        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
10672        assert!(d.cell_line_break(), "acts as a table key");
10673        assert!(
10674            d.source.contains("Pear<br>"),
10675            "spelled as an inline <br>: {}",
10676            d.source
10677        );
10678        assert!(d.caret_in_table(), "still in the cell, past the break");
10679        // The break renders as a real line: the "Pear" cell now draws two lines,
10680        // so the table's picture is one row taller than a single-line table.
10681        d.build_visual(80);
10682        let table = &d.vmap.tables[0];
10683        let cell = &table.grid[1].cells[0]; // first body row, first column
10684        assert!(
10685            cell.glyphs.iter().any(|g| g.ch == '\n'),
10686            "the cell carries the break as a newline glyph for the frontend to split"
10687        );
10688    }
10689
10690    #[test]
10691    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
10692        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
10693        // back as structure — the whole point of routing through insert_line_break
10694        // instead of splicing raw `<br>` bytes.
10695        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
10696        d.caret = TABLE.find("Pear").unwrap() + 4;
10697        assert!(d.cell_line_break());
10698        let kinds: Vec<Kind> = d
10699            .editor
10700            .nodes()
10701            .unwrap()
10702            .iter()
10703            .map(|n| n.kind.clone())
10704            .collect();
10705        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
10706        assert!(
10707            !kinds.contains(&Kind::RawInline),
10708            "still raw HTML: {kinds:?}"
10709        );
10710    }
10711
10712    #[test]
10713    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
10714        // The `<br>` draws as one newline glyph, so Backspace over it must take
10715        // all four bytes — a one-byte delete would strand a visible `<br` in the
10716        // cell (the reported bug).
10717        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
10718        d.caret = TABLE.find("Pear").unwrap() + 4;
10719        assert!(d.cell_line_break());
10720        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
10721        d.backspace(); // caret sits just past the break
10722        assert!(
10723            !d.source.contains("<br"),
10724            "no half-deleted <br left: {}",
10725            d.source
10726        );
10727        assert!(
10728            d.source.contains("| Pear |"),
10729            "the cell is back to one line: {}",
10730            d.source
10731        );
10732    }
10733
10734    #[test]
10735    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
10736        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
10737        d.caret = TABLE.find("Pear").unwrap() + 4;
10738        assert!(d.cell_line_break());
10739        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
10740        d.delete_forward();
10741        assert!(
10742            !d.source.contains("<br"),
10743            "no half-deleted <br: {}",
10744            d.source
10745        );
10746        assert!(
10747            d.source.contains("| Pear |"),
10748            "cell back to one line: {}",
10749            d.source
10750        );
10751    }
10752
10753    #[test]
10754    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
10755        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
10756        // still consumed (a real newline would split the one-line row), but the
10757        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
10758        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
10759        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10760        d.caret = src.find("Pear").unwrap() + 4;
10761        assert!(d.caret_in_table(), "caret should be inside the djot table");
10762        assert!(
10763            d.cell_line_break(),
10764            "the key is consumed, not passed to the frontend"
10765        );
10766        assert_eq!(d.source, src, "the djot cell is left untouched");
10767        assert!(
10768            !d.source.contains("<br>"),
10769            "no non-idiomatic <br> spliced into djot"
10770        );
10771        assert!(
10772            d.status.is_some(),
10773            "the refusal is surfaced on the status line"
10774        );
10775    }
10776
10777    #[test]
10778    fn typing_in_a_cell_edits_that_cell() {
10779        // Editing comes free once offsets map correctly: the caret is a source
10780        // offset, so a normal splice lands inside the pipe table.
10781        let mut d = wysiwyg_doc("tbl_type", TABLE);
10782        d.caret = TABLE.find("Pear").unwrap() + 4;
10783        d.insert("s");
10784        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
10785    }
10786
10787    #[test]
10788    fn motion_and_delete_treat_an_emoji_as_one_character() {
10789        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
10790        // bytes, several codepoints. Right-arrow must clear it in one step, and
10791        // backspace must remove the whole cluster, not a stray joiner.
10792        let family = "👨‍👩‍👧";
10793        let mut d = doc_with("emoji", &format!("a{family}b\n"));
10794        d.caret = 1; // just after 'a', before the emoji
10795        d.move_right(false);
10796        assert_eq!(
10797            d.caret,
10798            1 + family.len(),
10799            "one step clears the whole cluster"
10800        );
10801        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
10802
10803        d.backspace(); // delete the emoji as a unit
10804        assert_eq!(d.source, "ab\n");
10805        assert_eq!(d.caret, 1);
10806    }
10807
10808    #[test]
10809    fn motion_handles_a_combining_accent_as_one_character() {
10810        // "e" + U+0301 (combining acute) renders as one é.
10811        let mut d = doc_with("combining", "e\u{0301}x\n");
10812        d.caret = 0;
10813        d.move_right(false);
10814        assert_eq!(
10815            d.caret,
10816            "e\u{0301}".len(),
10817            "steps past base + combining mark"
10818        );
10819    }
10820
10821    #[test]
10822    fn undo_then_redo_round_trips_an_edit() {
10823        let mut d = doc_with("undo", "hello\n");
10824        d.caret = 5;
10825        d.insert("!");
10826        assert_eq!(d.source, "hello!\n");
10827        d.undo();
10828        assert_eq!(d.source, "hello\n");
10829        assert_eq!(d.caret, 5, "undo restores the caret");
10830        d.redo();
10831        assert_eq!(d.source, "hello!\n");
10832    }
10833
10834    #[test]
10835    fn a_run_of_typing_undoes_as_one_step() {
10836        let mut d = doc_with("coalesce", "\n");
10837        d.caret = 0;
10838        d.insert("a");
10839        d.insert("b");
10840        d.insert("c");
10841        assert_eq!(d.source, "abc\n");
10842        d.undo(); // the whole typed run, not just "c"
10843        assert_eq!(d.source, "\n");
10844        d.undo(); // nothing left — the run was one step
10845        assert_eq!(d.source, "\n");
10846        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10847    }
10848
10849    // ── IME composition ──────────────────────────────────────────────────────
10850
10851    #[test]
10852    fn a_composition_run_undoes_as_one_step() {
10853        let mut d = doc_with("compose", "\n");
10854        d.caret = 0;
10855        // What an IME does: each step replaces the last one's provisional bytes.
10856        d.edit_composing(0, 0, "k");
10857        d.edit_composing(0, 1, "か");
10858        d.edit_composing(0, 3, "かん");
10859        d.edit_composing(0, 6, "感"); // the commit
10860        d.end_composition();
10861        assert_eq!(d.source, "感\n");
10862        d.undo(); // the whole composition, not its last keystroke
10863        assert_eq!(d.source, "\n");
10864        assert_eq!(d.status.as_deref(), None, "the run was a single step");
10865    }
10866
10867    #[test]
10868    fn two_compositions_are_two_undo_steps() {
10869        let mut d = doc_with("compose_two", "\n");
10870        d.caret = 0;
10871        d.edit_composing(0, 0, "か");
10872        d.edit_composing(0, 3, "蚊");
10873        d.end_composition();
10874        d.edit_composing(3, 3, "き");
10875        d.edit_composing(3, 6, "木");
10876        d.end_composition();
10877        assert_eq!(d.source, "蚊木\n");
10878        d.undo();
10879        assert_eq!(d.source, "蚊\n", "only the second composition");
10880        d.undo();
10881        assert_eq!(d.source, "\n");
10882    }
10883
10884    #[test]
10885    fn a_composition_does_not_fold_into_the_typing_around_it() {
10886        let mut d = doc_with("compose_typing", "\n");
10887        d.caret = 0;
10888        d.insert("a");
10889        d.insert("b");
10890        d.edit_composing(2, 2, "か");
10891        d.edit_composing(2, 5, "蚊");
10892        d.end_composition();
10893        d.insert("c");
10894        assert_eq!(d.source, "ab蚊c\n");
10895        d.undo();
10896        assert_eq!(d.source, "ab蚊\n");
10897        d.undo();
10898        assert_eq!(d.source, "ab\n");
10899        d.undo();
10900        assert_eq!(d.source, "\n");
10901    }
10902
10903    #[test]
10904    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
10905        let mut d = doc_with("compose_spurious", "\n");
10906        d.caret = 0;
10907        d.insert("a");
10908        d.end_composition(); // an IME unmarking unprompted
10909        d.insert("b");
10910        assert_eq!(d.source, "ab\n");
10911        d.undo();
10912        assert_eq!(d.source, "\n", "still one typed run");
10913    }
10914
10915    // ── the clipboard's rich flavor ──────────────────────────────────────────
10916
10917    #[test]
10918    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
10919        let mut d = doc_with("sel_inline", "a **bold** c\n");
10920        d.anchor = Some(2);
10921        d.caret = 10; // `**bold**`, inside the paragraph
10922        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
10923    }
10924
10925    #[test]
10926    fn a_whole_block_selection_keeps_its_paragraph() {
10927        let mut d = doc_with("sel_block", "a **bold** c\n");
10928        d.anchor = Some(0);
10929        d.caret = 12; // the entire paragraph
10930        assert_eq!(
10931            d.selection_html().as_deref(),
10932            Some("<p>a <strong>bold</strong> c</p>")
10933        );
10934    }
10935
10936    #[test]
10937    fn a_multi_block_selection_keeps_its_structure() {
10938        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
10939        d.select_all();
10940        let html = d.selection_html().expect("renders");
10941        assert!(html.contains("<p>para</p>"), "{html:?}");
10942        assert!(html.contains("<li>one</li>"), "{html:?}");
10943    }
10944
10945    #[test]
10946    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
10947        // The fragment `Head` is a paragraph standalone; the *document* says it
10948        // sits inside one block, so the wrapper is an artifact either way.
10949        let mut d = doc_with("sel_heading", "# Head line\n");
10950        d.anchor = Some(2);
10951        d.caret = 6;
10952        assert_eq!(d.selection_html().as_deref(), Some("Head"));
10953    }
10954
10955    #[test]
10956    fn no_selection_publishes_no_html() {
10957        let mut d = doc_with("sel_none", "a b\n");
10958        d.caret = 1;
10959        assert_eq!(d.selection_html(), None);
10960    }
10961
10962    #[test]
10963    fn pasting_html_converts_it_and_is_one_undo_step() {
10964        let mut d = doc_with("paste_html", "x\n");
10965        d.caret = 1;
10966        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
10967        assert_eq!(d.source, "xa **b** c\n");
10968        d.undo();
10969        assert_eq!(d.source, "x\n", "the whole paste, in one step");
10970    }
10971
10972    #[test]
10973    fn pasting_html_replaces_the_selection() {
10974        let mut d = doc_with("paste_html_sel", "keep drop\n");
10975        d.anchor = Some(5);
10976        d.caret = 9;
10977        assert!(d.paste_html("<em>new</em>"));
10978        assert_eq!(d.source, "keep *new*\n");
10979    }
10980
10981    #[test]
10982    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
10983        let mut d = doc_with("paste_html_bad", "x\n");
10984        d.caret = 1;
10985        // twig builds no table from HTML; raw `<table>` in prose is worse than
10986        // the plain flavor the caller still holds.
10987        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
10988        assert_eq!(d.source, "x\n", "declined edits nothing");
10989    }
10990
10991    #[test]
10992    fn copy_then_paste_round_trips_through_the_html_flavor() {
10993        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
10994        d.select_all();
10995        let html = d.selection_html().expect("renders");
10996        let mut into = doc_with("clip_round_dst", "\n");
10997        into.caret = 0;
10998        assert!(into.paste_html(&html));
10999        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
11000    }
11001
11002    #[test]
11003    fn moving_the_caret_starts_a_new_undo_group() {
11004        let mut d = doc_with("break", "\n");
11005        d.caret = 0;
11006        d.insert("a");
11007        d.insert("b"); // "ab\n", caret at 2
11008        d.move_left(false); // breaks the run
11009        d.insert("X"); // "aXb\n"
11010        assert_eq!(d.source, "aXb\n");
11011        d.undo();
11012        assert_eq!(
11013            d.source, "ab\n",
11014            "first undo removes only the post-move insert"
11015        );
11016        d.undo();
11017        assert_eq!(d.source, "\n", "second undo removes the earlier run");
11018    }
11019
11020    #[test]
11021    fn undo_reverses_a_format_toggle() {
11022        let mut d = doc_with("fmt_undo", "a word b\n");
11023        d.anchor = Some(2);
11024        d.caret = 6;
11025        d.toggle(InlineKind::Strong);
11026        assert_eq!(d.source, "a **word** b\n");
11027        d.undo();
11028        assert_eq!(d.source, "a word b\n");
11029    }
11030
11031    #[test]
11032    fn undo_back_to_the_saved_state_clears_dirty() {
11033        let mut d = doc_with("dirty_undo", "hello\n");
11034        assert!(!d.dirty);
11035        d.caret = 5;
11036        d.insert("!");
11037        assert!(d.dirty);
11038        d.undo();
11039        assert!(
11040            !d.dirty,
11041            "undoing to the saved source is not a modification"
11042        );
11043    }
11044
11045    #[test]
11046    fn a_new_edit_invalidates_redo() {
11047        let mut d = doc_with("redo_inv", "\n");
11048        d.caret = 0;
11049        d.insert("a");
11050        d.undo();
11051        d.insert("b"); // diverges — the redo of "a" is now gone
11052        d.redo();
11053        assert_eq!(d.source, "b\n");
11054    }
11055
11056    #[test]
11057    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
11058        let mut d = doc_with("can_undo", "hello\n");
11059        assert!(
11060            !d.can_undo() && !d.can_redo(),
11061            "a fresh document has no history"
11062        );
11063        d.caret = 5;
11064        d.insert("!");
11065        assert!(
11066            d.can_undo() && !d.can_redo(),
11067            "an edit is a step to take back"
11068        );
11069        d.undo();
11070        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
11071        d.redo();
11072        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
11073        d.undo();
11074        d.insert("?");
11075        assert!(
11076            d.can_undo() && !d.can_redo(),
11077            "a fresh edit ends the redo chain"
11078        );
11079        // A coalesced run over-counts steps — the bound is what a menu needs,
11080        // and it reconciles the moment twig reports the history empty.
11081        d.insert("a");
11082        d.insert("b");
11083        while d.can_undo() {
11084            d.undo();
11085        }
11086        assert_eq!(d.source, "hello\n");
11087        assert!(!d.can_undo());
11088        // A reading surface has nothing to undo, whatever the history holds.
11089        d.redo();
11090        d.set_read_only(true);
11091        assert!(!d.can_undo() && !d.can_redo());
11092    }
11093
11094    #[test]
11095    fn undo_on_empty_history_is_a_no_op() {
11096        let mut d = doc_with("undo_empty", "hi\n");
11097        d.undo();
11098        assert_eq!(d.source, "hi\n");
11099        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
11100    }
11101
11102    #[test]
11103    fn a_one_character_paste_is_its_own_undo_step() {
11104        for view in [View::Source, View::Wysiwyg] {
11105            let mut d = doc_in(view, "paste_step", "ab\n");
11106            d.caret = 0;
11107            d.insert("x");
11108            d.insert("y"); // a run of typing
11109            d.paste("z"); // one character, but pasted — not part of that run
11110            assert_eq!(d.source, "xyzab\n");
11111            d.undo();
11112            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
11113            assert_eq!(d.caret, 2, "and hands back the caret it found");
11114            d.undo();
11115            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
11116        }
11117    }
11118
11119    #[test]
11120    fn the_same_character_typed_still_joins_the_run() {
11121        // The other half of the pair: `z` is a keystroke here and a paste above,
11122        // and the two undo differently. Nothing about the *string* says which —
11123        // which is why provenance has to come from the door the caller uses.
11124        for view in [View::Source, View::Wysiwyg] {
11125            let mut d = doc_in(view, "typed_run", "ab\n");
11126            d.caret = 0;
11127            d.insert("x");
11128            d.insert("y");
11129            d.insert("z");
11130            d.undo();
11131            assert_eq!(d.source, "ab\n", "one run, one step");
11132        }
11133    }
11134
11135    #[test]
11136    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
11137        for view in [View::Source, View::Wysiwyg] {
11138            let mut d = doc_in(view, "undo_caret", "hello world\n");
11139            d.caret = 11; // standing at the end of "world", away from the edit
11140            d.edit(0, 5, "goodbye");
11141            assert_eq!(d.source, "goodbye world\n");
11142            d.undo();
11143            assert_eq!(d.source, "hello world\n");
11144            // The undone edit ends at offset 5; the user was at 11.
11145            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
11146        }
11147    }
11148
11149    #[test]
11150    fn undo_restores_the_selection_the_edit_replaced() {
11151        for view in [View::Source, View::Wysiwyg] {
11152            let mut d = doc_in(view, "undo_sel", "a word b\n");
11153            d.anchor = Some(2);
11154            d.caret = 6; // "word" selected
11155            d.insert("X");
11156            assert_eq!(d.source, "a X b\n");
11157            d.undo();
11158            assert_eq!(d.source, "a word b\n");
11159            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
11160        }
11161    }
11162
11163    #[test]
11164    fn redo_restores_the_caret_the_edit_left_behind() {
11165        for view in [View::Source, View::Wysiwyg] {
11166            let mut d = doc_in(view, "redo_caret", "hello world\n");
11167            d.caret = 11;
11168            d.edit(0, 5, "goodbye");
11169            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
11170            d.undo();
11171            d.redo();
11172            assert_eq!(d.source, "goodbye world\n");
11173            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
11174        }
11175    }
11176
11177    #[test]
11178    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
11179        for view in [View::Source, View::Wysiwyg] {
11180            let mut d = doc_in(view, "run_caret", "hi\n");
11181            d.caret = 2;
11182            d.insert("a");
11183            d.insert("b");
11184            d.insert("c");
11185            assert_eq!(d.source, "hiabc\n");
11186            d.undo();
11187            assert_eq!(d.source, "hi\n");
11188            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
11189            d.redo();
11190            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
11191        }
11192    }
11193
11194    #[test]
11195    fn undo_restores_the_caret_across_a_format_toggle() {
11196        // A toggle reaches twig without going through `splice`, so it has to
11197        // record its own step — miss it and every stack depth below it is off by
11198        // one, and undo starts handing back another edit's caret.
11199        for view in [View::Source, View::Wysiwyg] {
11200            let mut d = doc_in(view, "fmt_caret", "a word b\n");
11201            d.caret = 8;
11202            d.anchor = Some(2);
11203            d.caret = 6;
11204            d.toggle(InlineKind::Strong);
11205            assert_eq!(d.source, "a **word** b\n");
11206            d.undo();
11207            assert_eq!(d.source, "a word b\n");
11208            assert_eq!(
11209                d.selection(),
11210                Some((2, 6)),
11211                "the toggled selection comes back"
11212            );
11213        }
11214    }
11215
11216    #[test]
11217    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
11218        // The drift that would never announce itself: twig drops its redo stack
11219        // on any fresh edit, so a leaf redo entry that outlives it would restore
11220        // a caret from the timeline that edit abandoned.
11221        for view in [View::Source, View::Wysiwyg] {
11222            let mut d = doc_in(view, "redo_trunc", "hello world\n");
11223            d.caret = 11;
11224            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
11225            d.undo();
11226            assert_eq!(d.caret, 11);
11227            d.caret = 0;
11228            d.insert("X"); // diverges: A's redo is gone from twig
11229            assert_eq!(d.source, "Xhello world\n");
11230
11231            d.redo();
11232            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
11233            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
11234            d.undo();
11235            assert_eq!(d.source, "hello world\n");
11236            assert_eq!(
11237                d.caret, 0,
11238                "the surviving step's caret, not the dropped one"
11239            );
11240        }
11241    }
11242
11243    #[test]
11244    fn indent_and_outdent_move_the_caret_line_with_its_text() {
11245        for view in [View::Source, View::Wysiwyg] {
11246            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
11247            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
11248            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
11249            // Indentation the caret is standing *in* collapses to the line start
11250            // rather than dragging the caret into the text.
11251            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
11252            // A line with none to give back is left exactly as it was.
11253            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
11254            // Less than a full level gives back what it has.
11255            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
11256            // A tab is one level however many spaces it isn't.
11257            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
11258        }
11259    }
11260
11261    #[test]
11262    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
11263        // Why the level is two spaces and not the four both frontends type
11264        // today. Four is markdown's indented-code-block marker, so a Tab on a
11265        // paragraph would silently restyle it as code — a width that changes
11266        // what the document *means* isn't an indent. Pinned because the number
11267        // is the kind of thing a later list-aware pass would reach for.
11268        let mut d = doc_with("indent_kind", "hello\n");
11269        d.caret = 2;
11270        d.indent();
11271        assert_eq!(d.source, "  hello\n");
11272        assert!(
11273            d.nodes().iter().any(|n| n.kind == Kind::Para),
11274            "still prose after a Tab"
11275        );
11276        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
11277
11278        // The four-space level this replaces, for contrast: same text, and twig
11279        // reparses the paragraph into a code block.
11280        let mut wide = doc_with("indent_kind_4", "    hello\n");
11281        wide.build_visual(80);
11282        assert!(
11283            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
11284            "four spaces is a code block, not an indented paragraph"
11285        );
11286    }
11287
11288    #[test]
11289    fn indent_nests_a_list_item_under_its_parent() {
11290        // Tab indents a list item by its own marker width, landing its marker at
11291        // the parent's content column so twig reparses it as a nested list.
11292        for view in [View::Source, View::Wysiwyg] {
11293            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
11294            d.caret = 6; // on the second item
11295            d.indent();
11296            assert_eq!(d.source, "- a\n  - b\n");
11297            let lists = d
11298                .nodes()
11299                .iter()
11300                .filter(|n| n.kind == Kind::BulletList)
11301                .count();
11302            assert_eq!(lists, 2, "the indented item is a nested list");
11303        }
11304    }
11305
11306    #[test]
11307    fn indent_nests_an_ordered_item_at_its_marker_width() {
11308        // An ordered marker `1. ` is three columns wide, so a two-space step
11309        // (which nests a bullet) leaves it flat. Regression: Tab must use the
11310        // marker width, three, so the item actually nests — and the source
11311        // renumbers so the sub-list restarts at 1 and the outer list resumes.
11312        for view in [View::Source, View::Wysiwyg] {
11313            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
11314            d.caret = d.source.find('b').unwrap();
11315            d.indent();
11316            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
11317            let lists = d
11318                .nodes()
11319                .iter()
11320                .filter(|n| n.kind == Kind::OrderedList)
11321                .count();
11322            assert_eq!(lists, 2, "the indented item is a nested ordered list");
11323        }
11324    }
11325
11326    #[test]
11327    fn indent_leaves_a_lists_first_item_put() {
11328        // The first item of a list has no sibling above it to nest under, so Tab
11329        // is a no-op there — the marker stays at column zero rather than being
11330        // shoved into indentation twig can't read as a sub-list.
11331        for view in [View::Source, View::Wysiwyg] {
11332            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
11333            d.caret = 1; // on the FIRST item
11334            d.indent();
11335            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
11336            // The sibling below still nests, proving the guard is per-item.
11337            d.caret = d.source.find('b').unwrap();
11338            d.indent();
11339            assert_eq!(d.source, "- a\n  - b\n");
11340        }
11341    }
11342
11343    #[test]
11344    fn hidden_mode_keeps_typed_markup_literal() {
11345        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
11346        // twig escapes what would open markup, so the source is `\*hi\*` and the
11347        // AST is a plain string. Formatting is the commands' job in this mode.
11348        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
11349        d.insert("*hi*");
11350        assert_eq!(d.source, "\\*hi\\*");
11351        assert!(
11352            d.nodes()
11353                .iter()
11354                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
11355        );
11356    }
11357
11358    #[test]
11359    fn hidden_mode_escapes_a_line_start_block_marker() {
11360        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
11361        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
11362        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
11363        d.insert("# hi");
11364        assert_eq!(d.source, "\\# hi");
11365        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
11366    }
11367
11368    #[test]
11369    fn authoring_modes_keep_typed_markup_live() {
11370        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
11371        // (no escape), the same as source view — escaping is `None`'s alone, and
11372        // it's the axis, not the reveal, that decides.
11373        for (view, mode) in [
11374            (View::Wysiwyg, MarkupMode::Shortcuts),
11375            (View::Wysiwyg, MarkupMode::Full),
11376            (View::Source, MarkupMode::None),
11377        ] {
11378            let mut d = doc_in(view, "live_markup", "");
11379            d.set_markup_mode(mode);
11380            d.insert("*hi*");
11381            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
11382        }
11383    }
11384
11385    #[test]
11386    fn hidden_mode_overwrite_undoes_in_one_step() {
11387        // Typing over a selection escapes the replacement *and* stays a single
11388        // undo — the selection-delete and the literal insert fold together, so
11389        // one undo brings the whole selection back, like a plain overwrite.
11390        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
11391        d.anchor = Some(2);
11392        d.caret = 6; // "word"
11393        d.insert("*");
11394        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
11395        d.undo();
11396        assert_eq!(d.source, "a word b\n");
11397        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
11398    }
11399
11400    #[test]
11401    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
11402        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
11403        // the whole visual character, never stranding the hidden `\`.
11404        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
11405        d.insert("*");
11406        assert_eq!(d.source, "\\*");
11407        d.backspace();
11408        assert_eq!(d.source, "", "the escape backslash went with the *");
11409        // A *literal* backslash (source view, no escape) is an ordinary char.
11410        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
11411        s.caret = 3; // after `b`
11412        s.backspace();
11413        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
11414    }
11415
11416    #[test]
11417    fn hidden_mode_leaves_structural_markup_alone() {
11418        // Enter continues a bullet list by writing a real `- ` marker (an
11419        // `insert_raw`, not the typing path), so Hidden mode's escaping never
11420        // touches it — the list keeps working.
11421        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
11422        d.caret = 6;
11423        d.newline();
11424        d.insert("two");
11425        assert_eq!(d.source, "- item\n- two\n");
11426    }
11427
11428    #[test]
11429    fn markup_mode_defaults_to_none_and_round_trips() {
11430        // Diaryx's default is the clean `None` surface; a markup-fluent
11431        // frontend can climb the ladder, and the choice sticks.
11432        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
11433        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
11434        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
11435            d.set_markup_mode(mode);
11436            assert_eq!(d.markup_mode(), mode);
11437        }
11438    }
11439
11440    #[test]
11441    fn full_mode_reveals_only_the_caret_line() {
11442        // The mode's whole claim: the caret's line shows its raw delimiters and
11443        // every other line stays resolved. Two paragraphs with identical markup
11444        // so the only difference between the rows is where the caret is.
11445        let mut d = doc_in(
11446            View::Wysiwyg,
11447            "reveal_caret_line",
11448            "*one* here\n\n*two* there\n",
11449        );
11450        d.set_markup_mode(MarkupMode::Full);
11451
11452        caret_at(&mut d, "one");
11453        let rows = drawn_rows(&d);
11454        assert!(
11455            rows.iter().any(|r| r == "*one* here"),
11456            "caret's line raw: {rows:?}"
11457        );
11458        assert!(
11459            rows.iter().any(|r| r == "two there"),
11460            "other line resolved: {rows:?}"
11461        );
11462
11463        // Move to the other paragraph: the reveal follows, and the line just
11464        // left goes back to being resolved.
11465        caret_at(&mut d, "two");
11466        let rows = drawn_rows(&d);
11467        assert!(
11468            rows.iter().any(|r| r == "*two* there"),
11469            "caret's line raw: {rows:?}"
11470        );
11471        assert!(
11472            rows.iter().any(|r| r == "one here"),
11473            "left line resolved: {rows:?}"
11474        );
11475    }
11476
11477    #[test]
11478    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
11479        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
11480        // the same treatment as an emphasis's `*`: hidden while the caret is
11481        // elsewhere, shown in full where the caret lands. That falls out of
11482        // `delims` reading the bytes between the mark's span and its content
11483        // span, which is exactly `==🔴 ` and `==`, rather than from a table
11484        // of spellings — so the no-space form `==🟢green==` reveals right too.
11485        let mut d = doc_in(
11486            View::Wysiwyg,
11487            "reveal_coloured_mark",
11488            "a ==🔴 red== one\n\nb ==plain== two\n",
11489        );
11490        d.set_markup_mode(MarkupMode::Full);
11491
11492        caret_at(&mut d, "red");
11493        let rows = drawn_rows(&d);
11494        assert!(
11495            rows.iter().any(|r| r == "a ==🔴 red== one"),
11496            "the caret's line shows the colour it was written with: {rows:?}"
11497        );
11498        assert!(
11499            rows.iter().any(|r| r == "b plain two"),
11500            "and every other line stays resolved: {rows:?}"
11501        );
11502
11503        // Away from it, the emoji goes back to being markup — the reader sees
11504        // the words and the wash.
11505        caret_at(&mut d, "two");
11506        let rows = drawn_rows(&d);
11507        assert!(
11508            rows.iter().any(|r| r == "a red one"),
11509            "resolved again: {rows:?}"
11510        );
11511    }
11512
11513    #[test]
11514    fn hidden_modes_never_reveal_wherever_the_caret_is() {
11515        // The two rungs below `Full` share a rendering: delimiters stay hidden
11516        // even under the caret. `Shortcuts` differing from `None` only in what
11517        // typing does is exactly the point of splitting the axes.
11518        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
11519            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
11520            d.set_markup_mode(mode);
11521            caret_at(&mut d, "one");
11522            let rows = drawn_rows(&d);
11523            assert!(
11524                rows.iter().any(|r| r == "one here"),
11525                "{mode:?} hides: {rows:?}"
11526            );
11527            assert!(
11528                !rows.iter().any(|r| r.contains('*')),
11529                "{mode:?} shows no `*`: {rows:?}"
11530            );
11531        }
11532    }
11533
11534    #[test]
11535    fn revealed_delimiters_are_the_authors_own_spelling() {
11536        // Delimiters are re-read from the source rather than synthesized per
11537        // kind, so a line comes back spelled the way it was written: `_em_` does
11538        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
11539        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
11540        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
11541        d.set_markup_mode(MarkupMode::Full);
11542        caret_at(&mut d, "em");
11543        let rows = drawn_rows(&d);
11544        assert!(
11545            rows.iter().any(|r| r == body.trim_end()),
11546            "the revealed line is its own source: {rows:?}"
11547        );
11548    }
11549
11550    #[test]
11551    fn revealed_heading_shows_its_hashes() {
11552        // The `# ` marker is a block-level prefix, not an inline delimiter, so
11553        // it takes its own path — but it reveals on the same rule.
11554        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
11555        d.set_markup_mode(MarkupMode::Full);
11556
11557        caret_at(&mut d, "Title");
11558        assert!(
11559            drawn_rows(&d).iter().any(|r| r == "# Title"),
11560            "{:?}",
11561            drawn_rows(&d)
11562        );
11563
11564        caret_at(&mut d, "body");
11565        let rows = drawn_rows(&d);
11566        assert!(
11567            rows.iter().any(|r| r == "Title"),
11568            "hashes hidden again: {rows:?}"
11569        );
11570    }
11571
11572    #[test]
11573    fn revealed_delimiters_are_caret_stops() {
11574        // A delimiter that is drawn but can't be reached is worse than one
11575        // that's hidden: the mode exists so the markup can be *edited*. Every
11576        // revealed byte must be somewhere the caret can stand.
11577        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
11578        d.set_markup_mode(MarkupMode::Full);
11579        caret_at(&mut d, "em");
11580        let opener = d.source.find('*').unwrap();
11581        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
11582        assert!(
11583            d.vmap.is_stop(opener + 3),
11584            "the closing `*` is a caret stop"
11585        );
11586    }
11587
11588    #[test]
11589    fn setext_heading_reveals_nothing_across_its_newline() {
11590        // A setext heading's underline is on another line, so it is not the
11591        // caret line's to reveal — and emitting it would inject a `\n` glyph
11592        // that splits the row where the author wrote no break.
11593        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
11594        d.set_markup_mode(MarkupMode::Full);
11595        caret_at(&mut d, "Title");
11596        let rows = drawn_rows(&d);
11597        assert!(
11598            rows.iter().any(|r| r == "Title"),
11599            "title renders alone: {rows:?}"
11600        );
11601        assert!(
11602            !rows.iter().any(|r| r.contains('=')),
11603            "no underline leaks in: {rows:?}"
11604        );
11605    }
11606
11607    #[test]
11608    fn markup_mode_axes_split_the_ladder() {
11609        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
11610        // that authors markup but still hides it, and it's the only rung where
11611        // the two axes disagree.
11612        assert!(!MarkupMode::None.authors());
11613        assert!(!MarkupMode::None.reveals_caret_line());
11614        assert!(MarkupMode::Shortcuts.authors());
11615        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
11616        assert!(MarkupMode::Full.authors());
11617        assert!(MarkupMode::Full.reveals_caret_line());
11618    }
11619
11620    #[test]
11621    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
11622        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
11623        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
11624        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
11625        // nested bullet and `hello` stays prose: the file round-trips instead of
11626        // hiding a heading the user never asked for.
11627        for view in [View::Source, View::Wysiwyg] {
11628            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
11629            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
11630            d.indent();
11631            assert_eq!(d.source, "- hello\n  * \n");
11632            assert!(
11633                d.nodes().iter().all(|n| n.kind != Kind::Heading),
11634                "no heading"
11635            );
11636            // And it's genuinely a nested list, not a flat one.
11637            assert_eq!(
11638                d.nodes()
11639                    .iter()
11640                    .filter(|n| n.kind == Kind::BulletList)
11641                    .count(),
11642                2
11643            );
11644        }
11645    }
11646
11647    #[test]
11648    fn indenting_a_dash_item_with_content_keeps_its_dash() {
11649        // With content, `- x` can't be a setext underline, so there's nothing to
11650        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
11651        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
11652        d.caret = d.source.find('x').unwrap();
11653        d.indent();
11654        assert_eq!(d.source, "- hello\n  - x\n");
11655    }
11656
11657    #[test]
11658    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
11659        // The dash→`*` repair coalesces into the Tab, so a single undo restores
11660        // the whole pre-Tab state rather than stranding a half-collapsed doc.
11661        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
11662        d.caret = d.source.find("- \n").unwrap() + 2;
11663        d.indent();
11664        assert_eq!(d.source, "- hello\n  * \n");
11665        d.undo();
11666        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
11667    }
11668
11669    #[test]
11670    fn indent_leaves_a_nested_lists_first_item_put_too() {
11671        // The guard is about siblings, not depth: the first item of an *inner*
11672        // list (already nested under `a`) still has nothing before it at its own
11673        // level, so Tab can't take it deeper.
11674        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
11675        d.caret = d.source.find('b').unwrap();
11676        d.indent();
11677        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
11678        // But `c` (a sibling of `b`) nests under `b`.
11679        d.caret = d.source.find('c').unwrap();
11680        d.indent();
11681        assert_eq!(d.source, "- a\n  - b\n    - c\n");
11682    }
11683
11684    #[test]
11685    fn backspace_at_a_nested_item_start_outdents_it() {
11686        // Backspace with the caret right after a nested item's marker gives back
11687        // one level of nesting, the mirror of Tab — and renumbers the flattened
11688        // ordered list back to a clean run.
11689        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
11690        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
11691        d.backspace();
11692        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
11693    }
11694
11695    #[test]
11696    fn backspace_at_a_top_level_item_start_strips_the_marker() {
11697        // At the outermost level there's no nesting left to give back, so the same
11698        // keystroke drops the bullet and leaves a plain paragraph.
11699        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
11700        d.caret = d.source.find('b').unwrap(); // right after `- `
11701        d.backspace();
11702        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
11703    }
11704
11705    #[test]
11706    fn backspace_mid_item_still_deletes_a_character() {
11707        // The list behaviour is armed only at the item's content start; anywhere
11708        // else Backspace is the ordinary character delete.
11709        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
11710        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
11711        d.backspace();
11712        assert_eq!(d.source, "- b\n");
11713    }
11714
11715    #[test]
11716    fn backspace_at_a_heading_start_strips_the_marker() {
11717        // The `# ` is markup the rich view hides, so Backspace over it takes the
11718        // whole marker and leaves a paragraph. Deleting a byte of it instead left
11719        // `#Title` — no longer a heading, with the hash now literal text the user
11720        // never typed and has to delete again.
11721        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
11722        d.caret = d.source.find('T').unwrap(); // right after `## `
11723        d.backspace();
11724        assert_eq!(d.source, "Title\n");
11725        assert_eq!(
11726            d.caret, 0,
11727            "the caret stays with the text it was in front of"
11728        );
11729    }
11730
11731    #[test]
11732    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
11733        // Only the heading's own marker goes — the quote (or list) it sits in is
11734        // untouched, exactly as un-heading it should be.
11735        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
11736        d.caret = d.source.find('T').unwrap();
11737        d.backspace();
11738        assert_eq!(d.source, "> Title\n");
11739    }
11740
11741    #[test]
11742    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
11743        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
11744        // behind would surface the same stray hash the marker delete just avoided.
11745        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
11746        d.caret = d.source.find('T').unwrap();
11747        d.backspace();
11748        assert_eq!(d.source, "Title\n");
11749        // And it's one edit: a single undo puts the whole heading back.
11750        d.undo();
11751        assert_eq!(d.source, "# Title #\n");
11752    }
11753
11754    #[test]
11755    fn backspace_mid_heading_still_deletes_a_character() {
11756        // The heading behaviour is armed only at the content's start; anywhere
11757        // else Backspace is the ordinary character delete.
11758        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
11759        d.caret = d.source.find('b').unwrap();
11760        d.backspace();
11761        assert_eq!(d.source, "# b\n");
11762    }
11763
11764    #[test]
11765    fn source_view_backspace_still_edits_the_heading_marker_literally() {
11766        // In source view the `# ` is text on the screen the user is deleting a
11767        // byte of, so it keeps its literal meaning — the same split the list
11768        // ladder and Enter draw between the two views.
11769        let mut d = doc_with("bsp_head_src", "# Title\n");
11770        d.caret = d.source.find('T').unwrap();
11771        d.backspace();
11772        assert_eq!(d.source, "#Title\n");
11773    }
11774
11775    #[test]
11776    fn outdent_unnests_an_ordered_item_in_one_press() {
11777        // Shift+Tab gives back exactly the marker width the indent added, so a
11778        // nested ordered item unnests in a single press, and the flattened list
11779        // renumbers back to a clean 1, 2, 3.
11780        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
11781        d.caret = d.source.find('b').unwrap();
11782        d.outdent();
11783        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
11784        let lists = d
11785            .nodes()
11786            .iter()
11787            .filter(|n| n.kind == Kind::OrderedList)
11788            .count();
11789        assert_eq!(lists, 1, "back to one flat list");
11790    }
11791
11792    #[test]
11793    fn table_insert_row_adds_a_row_below_the_caret() {
11794        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11795        d.caret = d.source.find('1').unwrap(); // in the body row
11796        d.table_insert_row(true);
11797        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
11798    }
11799
11800    #[test]
11801    fn table_insert_and_delete_column_at_the_caret() {
11802        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11803        d.caret = d.source.find('a').unwrap(); // column 0
11804        d.table_insert_column(true); // add a column to the right of `a`
11805        assert_eq!(
11806            d.source,
11807            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
11808        );
11809        d.caret = d.source.find('b').unwrap(); // now the third column
11810        d.table_delete_column();
11811        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
11812    }
11813
11814    // ── ragged formats ───────────────────────────────────────────────────────
11815    // No format spells every gesture. HTML writes the inline marks as a tag pair
11816    // and no heading, list, quote or link; Markdown spells five of the eight
11817    // marks — the highlight only because leaf parses with `highlight`, which is
11818    // why the question is asked with the extensions; djot spells all eight and
11819    // no in-cell break. leaf asks twig per
11820    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
11821    // op discover the fact on its own — one of them didn't.
11822
11823    /// An HTML document in the rich view, ready for a gesture.
11824    fn html_doc(body: &str) -> Doc {
11825        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
11826        d.view = View::Wysiwyg;
11827        d.build_visual(80);
11828        d
11829    }
11830
11831    #[test]
11832    fn a_table_gesture_leaves_an_html_table_alone() {
11833        // The regression this guard exists for. twig's table editor consults no
11834        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
11835        // HTML `<table>` as a *pipe table* and reported success: the whole
11836        // element replaced by `| a | b |`, silently, on one press of a toolbar
11837        // button. Every grid op went the same way.
11838        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
11839        // A table of named operations, which is what it looks like.
11840        #[allow(clippy::type_complexity)]
11841        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
11842            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
11843            ("delete row", &|d: &mut Doc| d.table_delete_row()),
11844            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
11845            ("delete column", &|d: &mut Doc| d.table_delete_column()),
11846            ("align", &|d: &mut Doc| {
11847                d.table_set_alignment(Alignment::Right)
11848            }),
11849            ("move row", &|d: &mut Doc| d.table_move_row(true)),
11850            ("move column", &|d: &mut Doc| d.table_move_column(true)),
11851        ];
11852        for (name, op) in ops {
11853            let mut d = html_doc(src);
11854            d.caret = d.source.find('a').unwrap();
11855            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
11856            op(&mut d);
11857            assert_eq!(d.source, src, "{name} rewrote an HTML table");
11858            assert!(
11859                !d.dirty,
11860                "{name} marked the document dirty without editing it"
11861            );
11862            assert!(d.status.is_some(), "{name} refused without saying why");
11863        }
11864    }
11865
11866    #[test]
11867    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
11868        // A task box is a form control in HTML and a footnote has no native
11869        // spelling at all — the two gestures twig 3.5 still spells nothing
11870        // for, now that a quote, a list, a link and an image print through
11871        // its renderer (see the test below).
11872        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
11873        // A table of named operations, which is what it looks like.
11874        #[allow(clippy::type_complexity)]
11875        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
11876            ("task item", &|d: &mut Doc| d.toggle_task_item()),
11877            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
11878            ("footnote", &|d: &mut Doc| d.insert_footnote()),
11879        ];
11880        for (name, op) in ops {
11881            let mut d = html_doc(src);
11882            let at = d.source.find("Hello").unwrap();
11883            d.caret = at;
11884            d.anchor = Some(at + 5); // a selection, for the ops that want one
11885            op(&mut d);
11886            assert_eq!(d.source, src, "{name} edited an HTML document");
11887            assert!(
11888                !d.dirty,
11889                "{name} marked the document dirty without editing it"
11890            );
11891            let status = d.status.as_deref().unwrap_or("");
11892            assert!(
11893                status.contains("html"),
11894                "{name}: the refusal should name the format, got {status:?}"
11895            );
11896        }
11897    }
11898
11899    #[test]
11900    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
11901        // twig 3.5: where HTML has no marker alphabet it prints the fresh
11902        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
11903        // the paragraph as its item, an `<a>` or `<img>` over the selection.
11904        // Until then every one of these was a refusal; now each is a real
11905        // edit, which is what the toolbar's capability flags say too.
11906        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
11907        #[allow(clippy::type_complexity)]
11908        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
11909            (
11910                "quote",
11911                &|d: &mut Doc| d.toggle_blockquote(),
11912                "<blockquote>",
11913            ),
11914            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
11915            (
11916                "ordered list",
11917                &|d: &mut Doc| d.toggle_list(true),
11918                "<ol>\n<li>",
11919            ),
11920            (
11921                "link",
11922                &|d: &mut Doc| d.insert_link("https://example.dev"),
11923                "<a href=\"https://example.dev\">Hello</a>",
11924            ),
11925            (
11926                "image",
11927                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
11928                "<img alt=\"Hello\" src=\"pic.png\">",
11929            ),
11930        ];
11931        for (name, op, expect) in ops {
11932            let mut d = html_doc(src);
11933            let at = d.source.find("Hello").unwrap();
11934            d.caret = at;
11935            d.anchor = Some(at + 5);
11936            op(&mut d);
11937            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
11938            assert!(d.dirty, "{name}: a real edit");
11939            assert_eq!(
11940                d.status, None,
11941                "{name}: a supported gesture reports nothing"
11942            );
11943        }
11944    }
11945
11946    #[test]
11947    fn html_spells_a_heading_as_its_tag_pair() {
11948        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
11949        // along — the one block gesture whose HTML shape it can write. So ⌘2
11950        // in an HTML document is a real edit, and ⌘0 takes it back.
11951        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
11952        let mut d = html_doc(src);
11953        d.caret = d.source.find("Hello").unwrap();
11954        d.toggle_heading(2);
11955        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
11956        assert!(d.dirty);
11957        assert_eq!(d.status, None, "a supported gesture reports nothing");
11958        d.toggle_heading(2);
11959        assert_eq!(d.source, src, "the same level again is back to a paragraph");
11960    }
11961
11962    #[test]
11963    fn html_spells_the_inline_marks_and_the_rule() {
11964        // The other half, and why one per-document flag stopped being enough:
11965        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
11966        // already emits and the parser reads straight back as the same mark —
11967        // and the rule button writes an `<hr>`. Refusing these on the old
11968        // "HTML is parse-only" reading would now be leaf's own limitation.
11969        let mut d = html_doc("<p>Hello world</p>\n");
11970        let at = d.source.find("world").unwrap();
11971        d.caret = at;
11972        d.anchor = Some(at + 5);
11973        d.toggle(InlineKind::Strong);
11974        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
11975        assert!(d.dirty);
11976        assert_eq!(d.status, None, "a supported gesture reports nothing");
11977
11978        // And off again — the toggle reverses, which is the property that makes
11979        // authoring in HTML worth offering rather than a one-way trip.
11980        d.toggle(InlineKind::Strong);
11981        assert_eq!(d.source, "<p>Hello world</p>\n");
11982
11983        let mut d = html_doc("<p>Hello world</p>\n");
11984        d.caret = d.source.find("world").unwrap();
11985        d.insert_thematic_break();
11986        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
11987    }
11988
11989    #[test]
11990    fn a_mark_the_format_cannot_spell_arms_nothing() {
11991        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
11992        // sticky mark for the next text typed. Guarding only the twig call
11993        // leaves that path live, promising a mark the gesture will not write and
11994        // then swallowing the error inside `insert`.
11995        //
11996        // Markdown carries this, on the superscript now rather than on the
11997        // highlight: `^x^` is text there in any configuration, whereas twig
11998        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
11999        // which every leaf document does.
12000        let mut d = doc_with("mark", "Hello world\n");
12001        d.view = View::Wysiwyg;
12002        d.build_visual(80);
12003        d.caret = d.source.find("world").unwrap();
12004        d.toggle(InlineKind::Superscript);
12005        assert!(d.pending_marks.is_empty(), "no mark should be armed");
12006        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
12007        d.insert("X");
12008        assert_eq!(d.source, "Hello Xworld\n");
12009    }
12010
12011    #[test]
12012    fn markdown_authors_a_highlight_and_a_strikethrough() {
12013        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
12014        // write. `==x==` is authorable because leaf's own `parse_extensions`
12015        // turns `highlight` on — twig will only mint bytes this editor's reparse
12016        // reads back — and `~~x~~` because GFM strikethrough is parsed by
12017        // default, so the refusal there was never right for any leaf document.
12018        for (kind, marked) in [
12019            (InlineKind::Mark, "a ==word== b\n"),
12020            (InlineKind::Delete, "a ~~word~~ b\n"),
12021        ] {
12022            let mut d = doc_with("author_mark", "a word b\n");
12023            d.anchor = Some(2);
12024            d.caret = 6;
12025            d.toggle(kind);
12026            assert_eq!(d.source, marked, "{kind:?}");
12027            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
12028            assert!(d.dirty, "{kind:?}");
12029            // The region stays selected, so the second press reverses it — the
12030            // property that separates authoring from a one-way trip.
12031            d.toggle(kind);
12032            assert_eq!(d.source, "a word b\n", "{kind:?}");
12033        }
12034    }
12035
12036    #[test]
12037    fn an_authored_highlight_reads_back_as_a_mark() {
12038        // The round trip the extension gate exists to protect: what the toggle
12039        // writes, the reparse must read back as a `mark` rather than as two
12040        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
12041        // rebuilt map rather than from the source text.
12042        let mut d = doc_with("mark_roundtrip", "a word b\n");
12043        d.view = View::Wysiwyg;
12044        d.build_visual(80);
12045        d.anchor = Some(2);
12046        d.caret = 6;
12047        d.toggle(InlineKind::Mark);
12048        assert_eq!(d.source, "a ==word== b\n");
12049        d.build_visual(80);
12050        let w = d
12051            .vmap
12052            .rows
12053            .iter()
12054            .flat_map(|r| r.glyphs.iter())
12055            .find(|g| g.ch == 'w')
12056            .expect("the highlighted word");
12057        assert_eq!(w.style.role, crate::Role::Mark(None));
12058    }
12059
12060    #[test]
12061    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
12062        // The three states of one gesture, in the order a palette is pressed:
12063        // an uncoloured highlight takes the prefix, a coloured one has it
12064        // replaced, and `None` takes it away with the space that was part of the
12065        // spelling.
12066        let mut d = doc_with("mark_colour", "a ==word== b\n");
12067        d.caret = d.source.find("word").unwrap();
12068        d.set_mark_color(Some(MarkColor::Red));
12069        assert_eq!(d.source, "a ==🔴 word== b\n");
12070        assert_eq!(d.status, None);
12071        assert!(d.dirty);
12072
12073        d.set_mark_color(Some(MarkColor::Blue));
12074        assert_eq!(d.source, "a ==🔵 word== b\n");
12075
12076        d.set_mark_color(None);
12077        assert_eq!(d.source, "a ==word== b\n");
12078    }
12079
12080    #[test]
12081    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
12082        // The prefix is written *before* the word, so an offset in the word has
12083        // to ride its width — a caret that stayed put would be a caret that
12084        // walked backwards through the text it was standing in.
12085        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
12086        let word = d.source.find("word").unwrap();
12087        d.caret = word + 2; // between `wo` and `rd`
12088        d.set_mark_color(Some(MarkColor::Red));
12089        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
12090
12091        // And back the other way when the prefix goes.
12092        d.set_mark_color(None);
12093        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
12094    }
12095
12096    #[test]
12097    fn the_colour_at_the_caret_is_what_the_palette_lights() {
12098        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
12099        d.caret = d.source.find("red").unwrap();
12100        assert!(d.caret_in_mark());
12101        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
12102
12103        d.caret = d.source.find("plain").unwrap();
12104        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
12105        assert_eq!(d.mark_color_at_caret(), None);
12106
12107        d.caret = d.source.find(" and ").unwrap() + 2;
12108        assert!(!d.caret_in_mark());
12109        assert_eq!(d.mark_color_at_caret(), None);
12110    }
12111
12112    #[test]
12113    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
12114        // The gesture colours a highlight that exists; it does not make one.
12115        // Two presses is the price of a coloured highlight from bare text, and
12116        // the reason is undo — one press that spliced twice would take two
12117        // presses to take back.
12118        let mut d = doc_with("mark_colour_none", "a word b\n");
12119        d.caret = d.source.find("word").unwrap();
12120        d.set_mark_color(Some(MarkColor::Red));
12121        assert_eq!(d.source, "a word b\n");
12122        assert!(d.status.is_some(), "it should say why");
12123        assert!(!d.dirty);
12124
12125        // Clearing where there is nothing to clear is the same refusal, not a
12126        // quiet success — the caret is in no highlight either way.
12127        d.status = None;
12128        d.set_mark_color(None);
12129        assert_eq!(d.source, "a word b\n");
12130        assert!(d.status.is_some());
12131    }
12132
12133    #[test]
12134    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
12135        // twig answers this one *successfully* with a `Change` describing some
12136        // earlier edit, so a caller that trusted the change would jump the caret
12137        // to wherever that was. Core answers it before asking.
12138        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
12139        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
12140        d.caret = d.source.find("word").unwrap();
12141        let (source, caret) = (d.source.clone(), d.caret);
12142        d.set_mark_color(None);
12143        assert_eq!(d.source, source);
12144        assert_eq!(
12145            d.caret, caret,
12146            "the caret must not ride a change that isn't one"
12147        );
12148        assert_eq!(d.status, None, "and it is not an error either");
12149    }
12150
12151    #[test]
12152    fn djot_spells_the_highlight_and_not_its_colour() {
12153        // The reason the palette is its own capability rather than the Highlight
12154        // button's: `{=word=}` is a highlight djot writes happily, and there is
12155        // no djot spelling for a colour on it.
12156        assert!(Capabilities::of(Format::Djot).mark);
12157        assert!(!Capabilities::of(Format::Djot).mark_color);
12158        assert!(Capabilities::of(Format::Markdown).mark_color);
12159
12160        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
12161        d.caret = d.source.find("word").unwrap();
12162        assert!(
12163            d.caret_in_mark(),
12164            "the caret is in a highlight all the same"
12165        );
12166        d.set_mark_color(Some(MarkColor::Red));
12167        assert_eq!(d.source, "a {=word=} b\n");
12168        assert!(
12169            d.status.as_deref().unwrap_or("").contains("djot"),
12170            "and the refusal names the document's format: {:?}",
12171            d.status
12172        );
12173    }
12174
12175    #[test]
12176    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
12177        // The round trip that matters for a palette: the bytes twig writes are
12178        // bytes its own reparse reads back as a colour, so the swatch that was
12179        // pressed is the swatch that lights afterwards.
12180        let mut d = doc_with("mark_colour_undo", "a word b\n");
12181        d.anchor = Some(2);
12182        d.caret = 6;
12183        d.toggle(InlineKind::Mark);
12184        d.caret = d.source.find("word").unwrap();
12185        d.set_mark_color(Some(MarkColor::Green));
12186        assert_eq!(d.source, "a ==🟢 word== b\n");
12187        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
12188
12189        // One splice, one step: the colour comes off and the highlight stays.
12190        d.undo();
12191        assert_eq!(d.source, "a ==word== b\n");
12192        d.undo();
12193        assert_eq!(d.source, "a word b\n");
12194    }
12195
12196    #[test]
12197    fn every_colour_leaf_names_is_one_twig_writes() {
12198        // The two enums are one vocabulary, and this is what says so: each of
12199        // leaf's colours writes an emoji twig's reparse reads back as *that*
12200        // colour, so `twig_mark_color`'s table cannot quietly pair red with
12201        // orange.
12202        for color in MarkColor::ALL {
12203            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
12204            d.caret = d.source.find("word").unwrap();
12205            d.set_mark_color(Some(color));
12206            assert_eq!(d.status, None, "{color:?}");
12207            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
12208        }
12209    }
12210
12211    #[test]
12212    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
12213        // The two presses a coloured highlight is made of, in the state the
12214        // first one leaves: `toggle` selects the whole `==word==` and puts the
12215        // caret one past the closing `==`, which is *not* in the mark. Asking at
12216        // the caret alone would refuse to colour the highlight just written —
12217        // the selection's start is what answers.
12218        let mut d = doc_with("mark_colour_fresh", "a word b\n");
12219        d.anchor = Some(2);
12220        d.caret = 6;
12221        d.toggle(InlineKind::Mark);
12222        assert_eq!(d.source, "a ==word== b\n");
12223        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
12224
12225        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
12226        d.set_mark_color(Some(MarkColor::Yellow));
12227        assert_eq!(d.source, "a ==🟡 word== b\n");
12228        assert_eq!(d.status, None);
12229    }
12230
12231    #[test]
12232    fn one_press_highlights_a_selection_and_colours_it() {
12233        // What a toolbar swatch means over a plain selection, and the undo it
12234        // has to have: one press, one step. Two steps would leave an uncoloured
12235        // highlight behind on the way back, which is a state the author never
12236        // asked for and never saw.
12237        let mut d = doc_with("highlight_one", "a word b\n");
12238        d.anchor = Some(2);
12239        d.caret = 6;
12240        d.highlight(Some(MarkColor::Purple));
12241        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
12242        assert_eq!(d.status, None);
12243
12244        d.undo();
12245        assert_eq!(d.source, "a word b\n", "one press, one undo");
12246    }
12247
12248    #[test]
12249    fn one_press_on_an_existing_highlight_only_recolours_it() {
12250        // The other half: inside a highlight there is nothing to make, so the
12251        // compound is the plain gesture and the text is untouched.
12252        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
12253        d.caret = d.source.find("word").unwrap();
12254        d.highlight(Some(MarkColor::Blue));
12255        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
12256        d.undo();
12257        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
12258    }
12259
12260    #[test]
12261    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
12262        // `None` means "no colour", and over bare text that is the Highlight
12263        // button's own job. The fold must not happen here — there is no second
12264        // splice, and folding would take the *previous* edit into this one.
12265        let mut d = doc_with("highlight_none", "a word b and more\n");
12266        d.caret = d.source.find("more").unwrap() + 4; // after "more"
12267        d.insert("!"); // an earlier edit for a wrong fold to swallow
12268        d.anchor = Some(2);
12269        d.caret = 6;
12270        d.highlight(None);
12271        assert_eq!(d.source, "a ==word== b and more!\n");
12272
12273        d.undo();
12274        assert_eq!(
12275            d.source, "a word b and more!\n",
12276            "only the highlight came off"
12277        );
12278        d.undo();
12279        assert_eq!(
12280            d.source, "a word b and more\n",
12281            "and the edit before it survived"
12282        );
12283    }
12284
12285    #[test]
12286    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
12287        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
12288        // a colour cannot be armed with it — so the compound declines rather
12289        // than leaving half a promise.
12290        let mut d = doc_with("highlight_bare", "a word b\n");
12291        d.caret = 4;
12292        d.highlight(Some(MarkColor::Red));
12293        assert_eq!(d.source, "a word b\n");
12294        assert!(d.pending_marks.is_empty(), "and nothing armed");
12295        assert!(d.status.is_some());
12296    }
12297
12298    #[test]
12299    fn a_read_only_document_takes_no_colour() {
12300        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
12301        d.caret = d.source.find("word").unwrap();
12302        d.set_read_only(true);
12303        d.set_mark_color(Some(MarkColor::Red));
12304        assert_eq!(d.source, "a ==word== b\n");
12305    }
12306
12307    #[test]
12308    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
12309        // The other door into `toggle`: no selection, so nothing reaches twig
12310        // until `insert` realises the armed mark. It is armed now — the guard
12311        // above asks `Doc::supports`, which asks with the extensions — and what
12312        // it writes is the same `==…==`.
12313        let mut d = doc_with("sticky_mark", "xy\n");
12314        d.caret = 1;
12315        d.toggle(InlineKind::Mark);
12316        assert!(d.pending_marks.contains(InlineKind::Mark));
12317        d.insert("Z");
12318        assert_eq!(d.source, "x==Z==y\n");
12319    }
12320
12321    #[test]
12322    fn html_documents_still_take_typed_text() {
12323        // The guard covers *markup* gestures and must not touch plain editing:
12324        // twig's splicer is language-neutral, and typing into an HTML document
12325        // is the thing that does work today.
12326        let mut d = html_doc("<p>Hello world</p>\n");
12327        d.caret = d.source.find("world").unwrap();
12328        d.insert("big ");
12329        assert_eq!(d.source, "<p>Hello big world</p>\n");
12330        assert!(d.dirty);
12331        d.backspace();
12332        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
12333        d.undo();
12334        d.undo();
12335        assert_eq!(d.source, "<p>Hello world</p>\n");
12336    }
12337
12338    #[test]
12339    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
12340        // `authorable` only separates "there is a door in" from "there is not",
12341        // and HTML is on the near side of that line — which is exactly why a
12342        // toolbar must not be built from it.
12343        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
12344        assert!(html.authorable());
12345        assert!(
12346            !Doc::from_source("<r>x</r>".into(), Format::Xml)
12347                .unwrap()
12348                .authorable()
12349        );
12350
12351        let caps = html.capabilities();
12352        assert!(caps.bold && caps.italic && caps.code && caps.mark);
12353        assert!(caps.thematic_break && caps.cell_line_break);
12354        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
12355        // quote, a list, a code block's language, a link and an image — each
12356        // printed as a fresh node where HTML has no marker to rewrite. A task
12357        // box is a form control and a footnote has no spelling, so those two
12358        // are what keeps the record ragged.
12359        assert!(caps.heading && caps.blockquote && caps.bullet_list);
12360        assert!(caps.link && caps.image && caps.code_language);
12361        assert!(!caps.task && !caps.footnote);
12362        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
12363        // twig's table editor would happily re-emit as `| a | b |`.
12364        assert!(!caps.table);
12365
12366        // The two lightweight formats spell everything leaf offers — and still
12367        // differ from each other, which is the other half of why one boolean
12368        // can't serve.
12369        for fmt in [Format::Markdown, Format::Djot] {
12370            let caps = Capabilities::of(fmt);
12371            assert!(
12372                caps.heading && caps.blockquote && caps.ordered_list,
12373                "{fmt:?}"
12374            );
12375            assert!(
12376                caps.task && caps.link && caps.image && caps.table,
12377                "{fmt:?}"
12378            );
12379        }
12380        // Both spell the highlight and the strikethrough: djot natively, and
12381        // Markdown because `Capabilities` asks with `parse_extensions` rather
12382        // than with twig's defaults — `==x==` is text under those, and a mark
12383        // under the `highlight` leaf always parses with.
12384        for fmt in [Format::Markdown, Format::Djot] {
12385            let caps = Capabilities::of(fmt);
12386            assert!(caps.mark && caps.strike, "{fmt:?}");
12387        }
12388        // What still separates them, now that the highlight doesn't: djot has
12389        // no in-cell break, and Markdown spells neither of the scripts.
12390        assert!(Capabilities::of(Format::Djot).superscript);
12391        assert!(!Capabilities::of(Format::Markdown).superscript);
12392        assert!(Capabilities::of(Format::Markdown).cell_line_break);
12393        assert!(!Capabilities::of(Format::Djot).cell_line_break);
12394
12395        // A parse-only format answers no to every one of them, so the coarse
12396        // predicate and the record agree there.
12397        let caps = Capabilities::of(Format::Xml);
12398        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
12399    }
12400
12401    #[test]
12402    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
12403        // The guard exists to name the *document's* format rather than twig's
12404        // internals, so the message has to survive being one leaf writes itself.
12405        // Checked against a gesture twig also refuses, since that is the pair
12406        // most at risk of drifting apart — the task box, once the code
12407        // language stopped being one (twig 3.5).
12408        let mut d = html_doc("<p>Hello</p>\n");
12409        d.caret = d.source.find("Hello").unwrap();
12410        d.toggle_task_item();
12411        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
12412        assert!(!d.dirty);
12413    }
12414
12415    #[test]
12416    fn table_set_alignment_respells_the_delimiter() {
12417        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
12418        d.caret = d.source.find('b').unwrap();
12419        d.table_set_alignment(Alignment::Right);
12420        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
12421    }
12422
12423    #[test]
12424    fn each_empty_table_cell_has_its_own_editable_home() {
12425        // Regression: an empty cell has no twig content_span, so both cells of a
12426        // `|  |  |` row collapsed onto the row's start (before the first `│`).
12427        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
12428        // tell the cells apart. Each empty cell must now have a distinct home
12429        // inside it.
12430        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
12431        let (c0, c1) = {
12432            let cells = &d.vmap.tables[0].grid[1].cells;
12433            (cells[0].start, cells[1].start)
12434        };
12435        assert!(
12436            c0 < c1,
12437            "the two empty cells have distinct homes: {c0} < {c1}"
12438        );
12439        d.caret = c0;
12440        d.insert("x");
12441        assert_eq!(
12442            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
12443            "typed inside the cell"
12444        );
12445    }
12446
12447    #[test]
12448    fn arrows_step_into_each_empty_table_cell() {
12449        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
12450        let (c0, c1) = {
12451            let cells = &d.vmap.tables[0].grid[1].cells;
12452            (cells[0].start, cells[1].start)
12453        };
12454        d.caret = d.source.find('b').unwrap(); // in the header's second cell
12455        let mut seen = std::collections::HashSet::new();
12456        for _ in 0..6 {
12457            d.move_right(false);
12458            seen.insert(d.caret);
12459        }
12460        assert!(
12461            seen.contains(&c0),
12462            "right arrow reaches the first empty cell"
12463        );
12464        assert!(
12465            seen.contains(&c1),
12466            "right arrow reaches the second empty cell"
12467        );
12468    }
12469
12470    #[test]
12471    fn table_op_off_a_table_is_a_no_op_with_a_status() {
12472        let mut d = doc_with("tbl_none", "just text\n");
12473        d.caret = 3;
12474        d.table_insert_row(true);
12475        assert_eq!(d.source, "just text\n", "nothing changed");
12476        assert!(d.status.is_some(), "a status explains why");
12477        assert!(!d.caret_in_table());
12478    }
12479
12480    #[test]
12481    fn enter_in_an_ordered_list_renumbers_the_following_items() {
12482        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
12483        // the renumber pass keeps them sequential, matching what the view draws.
12484        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
12485        d.caret = d.source.find('a').unwrap() + 1; // end of item a
12486        d.newline();
12487        d.insert("x");
12488        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
12489    }
12490
12491    #[test]
12492    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
12493        for view in [View::Source, View::Wysiwyg] {
12494            let mut d = doc_in(view, "outdent_noop", "hello\n");
12495            d.caret = 2;
12496            d.outdent();
12497            assert_eq!(d.source, "hello\n");
12498            assert!(!d.dirty, "a no-op is not a modification");
12499            d.undo();
12500            assert_eq!(
12501                d.status.as_deref(),
12502                Some("nothing to undo"),
12503                "spends no undo step"
12504            );
12505            assert_eq!(d.source, "hello\n");
12506        }
12507    }
12508
12509    #[test]
12510    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
12511        for view in [View::Source, View::Wysiwyg] {
12512            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
12513            d.anchor = Some(0);
12514            d.caret = 7; // through "two"
12515            d.indent();
12516            assert_eq!(
12517                d.source, "  one\n\n  two\n",
12518                "the blank line keeps no trailing pad"
12519            );
12520            // Selected, so a second Tab lands on the same lines rather than on
12521            // whatever the shifted offsets now cover.
12522            assert_eq!(d.selection(), Some((0, 12)));
12523            d.indent();
12524            assert_eq!(d.source, "    one\n\n    two\n");
12525        }
12526    }
12527
12528    #[test]
12529    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
12530        for view in [View::Source, View::Wysiwyg] {
12531            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
12532            d.anchor = Some(0);
12533            d.caret = 15;
12534            d.outdent();
12535            assert_eq!(d.source, "two\none\nnone\n");
12536        }
12537    }
12538
12539    #[test]
12540    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
12541        for view in [View::Source, View::Wysiwyg] {
12542            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
12543            d.anchor = Some(0);
12544            d.caret = 7;
12545            d.indent();
12546            assert_eq!(d.source, "  one\n\n  two\n");
12547            d.undo();
12548            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
12549            assert_eq!(
12550                d.selection(),
12551                Some((0, 7)),
12552                "with the selection it was aimed at"
12553            );
12554            d.redo();
12555            assert_eq!(d.source, "  one\n\n  two\n");
12556            assert_eq!(
12557                d.selection(),
12558                Some((0, 12)),
12559                "redo replays the caret the indent placed, not the one splice left"
12560            );
12561        }
12562    }
12563
12564    #[test]
12565    fn vertical_motion_keeps_the_column() {
12566        let mut d = doc_with("move", "abcd\nef\n");
12567        d.caret = 3; // "abc|d" on row 0, col 3
12568        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
12569        assert_eq!(d.caret, 7); // just after "ef"
12570    }
12571
12572    // ── goal column ──────────────────────────────────────────────────────────
12573
12574    #[test]
12575    fn vertical_motion_goal_column_survives_a_short_line() {
12576        // Regression: re-deriving the column from the clamped position on
12577        // every step permanently forgets it once a short line clamps it.
12578        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
12579        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
12580        assert_eq!(
12581            g("abcd|ef\nxy\nghijkl\n", |d| {
12582                d.move_down(false); // clamps to end of "xy"
12583                d.move_down(false); // restores col 4 on the long line
12584            }),
12585            "abcdef\nxy\nghij|kl\n"
12586        );
12587    }
12588
12589    #[test]
12590    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
12591        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
12592        assert_eq!(d.goal_col, None);
12593        d.caret = 4; // row 0, col 4
12594        d.move_down(false); // clamps into "xy"; goal stays the original col
12595        assert_eq!(d.goal_col, Some(4));
12596        assert_eq!(d.caret_pos(), (1, 2));
12597
12598        // A horizontal motion drops the goal column...
12599        d.move_left(false);
12600        assert_eq!(d.goal_col, None);
12601
12602        // ...so the next vertical motion picks up the *new* column (1), not
12603        // the stale one (4).
12604        d.move_down(false);
12605        assert_eq!(d.goal_col, Some(1));
12606        assert_eq!(d.caret_pos(), (2, 1));
12607    }
12608
12609    #[test]
12610    fn editing_clears_the_goal_column() {
12611        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
12612        d.caret = 4;
12613        d.move_down(false);
12614        assert_eq!(d.goal_col, Some(4));
12615        d.insert("Z");
12616        assert_eq!(d.goal_col, None);
12617    }
12618
12619    #[test]
12620    fn vertical_motion_on_an_empty_document_is_a_no_op() {
12621        let mut d = doc_with("empty_vert", "");
12622        d.move_down(false);
12623        assert_eq!(d.caret, 0);
12624        d.move_up(false);
12625        assert_eq!(d.caret, 0);
12626    }
12627
12628    // ── the document's edges ─────────────────────────────────────────────────
12629
12630    #[test]
12631    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
12632        // The reproduction, and the disagreement: Down on the last line ran to
12633        // the end of the document in the source view — by accident, an
12634        // out-of-range row clamping to the end of the string — and did nothing
12635        // whatever in the view leaf opens in. One rule now, in both.
12636        for (view, tag) in VIEWS {
12637            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
12638            d.caret = 1;
12639            d.move_down(false);
12640            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
12641            d.move_up(false);
12642            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
12643        }
12644    }
12645
12646    #[test]
12647    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
12648        // Down off the bottom is a motion like any other, so it latches a goal
12649        // column — and Up comes back to the column the caret left, not to the
12650        // one the document's end happened to be in.
12651        for (view, tag) in VIEWS {
12652            let gap = if view == View::Source { "\n" } else { "\n\n" };
12653            let src = format!("abcdef{gap}ghijkl");
12654            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
12655            d.caret = 2; // row 0, col 2
12656            d.move_down(false);
12657            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
12658            d.move_down(false);
12659            assert_eq!(
12660                d.caret,
12661                src.len(),
12662                "{tag}: Down off the bottom reaches the end"
12663            );
12664            d.move_up(false);
12665            assert_eq!(
12666                d.caret_pos().1,
12667                2,
12668                "{tag}: Up returns to the column Down left"
12669            );
12670        }
12671    }
12672
12673    #[test]
12674    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
12675        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
12676        // Up that did nothing still armed a goal column, and the next Down aimed
12677        // at a column the caret had never been in.
12678        for (view, tag) in VIEWS {
12679            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
12680            d.caret = 0;
12681            d.move_up(false);
12682            assert_eq!(d.caret, 0, "{tag}: already at the start");
12683            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
12684
12685            d.caret = d.source.len();
12686            d.move_down(false);
12687            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
12688            assert_eq!(
12689                d.goal_col, None,
12690                "{tag}: a no-op Down latched a goal column"
12691            );
12692        }
12693    }
12694
12695    // ── soft wrap ────────────────────────────────────────────────────────────
12696    // Every other test here builds the map at 80 columns, where no fixture is
12697    // long enough to fold. A wrap is where one offset belongs to two rows at
12698    // once, and it broke everything that asks the caret what row it is on.
12699
12700    /// The wrapped fixture these cases share, folded at 12 columns into
12701    /// `one two ` / `three four ` / `five six ` / `seven eight`.
12702    fn wrapped_doc(name: &str) -> Doc {
12703        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
12704        d.build_visual(12);
12705        d
12706    }
12707
12708    #[test]
12709    fn home_and_end_work_from_a_wrapped_row() {
12710        // The reproduction: offset 19 is the `f` of "five", the first character
12711        // of the third row — and also the offset the second row ends at. It
12712        // resolved to the *second* row, so End aimed at a place the caret was
12713        // already in and did nothing, while Home walked backwards onto a row the
12714        // caret had left.
12715        let mut d = wrapped_doc("wrap_home_end");
12716        d.caret = 19;
12717        assert_eq!(
12718            d.caret_pos(),
12719            (2, 0),
12720            "the wrap boundary opens the third row"
12721        );
12722        d.move_end(false);
12723        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
12724        d.move_home(false);
12725        assert_eq!(d.caret, 19, "Home left the row the caret was on");
12726    }
12727
12728    #[test]
12729    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
12730        // The row's end is the last offset that is only ever its own: the offset
12731        // past it opens the row below, and aiming there would send a second
12732        // press on to *that* row's end, and a third to the next — End walking
12733        // down the paragraph rather than sitting where it landed.
12734        let mut d = wrapped_doc("wrap_end_twice");
12735        d.caret = 12; // inside "three", on the second row
12736        d.move_end(false);
12737        assert_eq!(
12738            d.caret, 18,
12739            "the end of `three four`, before the space the wrap ate"
12740        );
12741        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
12742        d.move_end(false);
12743        assert_eq!(d.caret, 18, "a second End moved the caret");
12744        d.move_home(false);
12745        assert_eq!(d.caret, 8, "Home takes the row's own start");
12746    }
12747
12748    #[test]
12749    fn vertical_motion_crosses_a_soft_wrap() {
12750        // Down aimed at the row below's column 0, an offset that resolved *up*
12751        // to the row above's end — so it landed on the offset it already had and
12752        // the caret could never leave a paragraph's first row.
12753        let mut d = wrapped_doc("wrap_down");
12754        d.caret = 0;
12755        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
12756            d.move_down(false);
12757            assert_eq!(d.caret, want, "Down stalled");
12758            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
12759        }
12760        d.move_down(false);
12761        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
12762
12763        // ...and back up, one row per press. The goal column is the end of the
12764        // last row, past every other row's width, so each press clamps to the
12765        // row's own last offset rather than to the one that opens the next.
12766        let mut d = wrapped_doc("wrap_up");
12767        d.caret = 39;
12768        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
12769            d.move_up(false);
12770            assert_eq!(d.caret, want, "Up stalled");
12771            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
12772        }
12773    }
12774
12775    #[test]
12776    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
12777        // The kills take the same line Home and End do, so in WYSIWYG they take
12778        // the visual row — and a soft wrap has no newline in it to delete, so
12779        // nothing is joined by reaching the end of one.
12780        let mut d = wrapped_doc("wrap_kill");
12781        d.caret = 19; // the `f` of "five", opening the third row
12782        d.delete_to_line_end();
12783        // The space the wrap ate goes with the row it was drawn on: sparing it
12784        // would leave "four  seven", two spaces where the row had been.
12785        assert_eq!(d.source, "one two three four seven eight");
12786
12787        // Backwards from the row's last caret position — which is *before* that
12788        // space, so this one survives, being on the far side of the caret.
12789        let mut d = wrapped_doc("wrap_kill_back");
12790        d.caret = 27;
12791        d.delete_to_line_start();
12792        assert_eq!(d.source, "one two three four  seven eight");
12793    }
12794
12795    // ── document start / end ────────────────────────────────────────────────
12796
12797    #[test]
12798    fn move_doc_start_and_end_jump_to_the_edges() {
12799        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
12800        assert_eq!(
12801            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
12802            "|hello\nworld\n"
12803        );
12804        assert_eq!(
12805            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
12806            "hello\nworld\n|"
12807        );
12808        // Already at the edge: a no-op.
12809        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
12810        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
12811    }
12812
12813    #[test]
12814    fn move_doc_start_and_end_extend_the_selection() {
12815        assert_eq!(
12816            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
12817                .move_doc_end(true)),
12818            "hello wor[ld\n|]"
12819        );
12820        assert_eq!(
12821            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
12822                .move_doc_start(true)),
12823            "[|hello wor]ld\n"
12824        );
12825    }
12826
12827    #[test]
12828    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
12829        let mut d = doc_with("empty_edges", "");
12830        d.move_doc_end(false);
12831        assert_eq!(d.caret, 0);
12832        d.move_doc_start(false);
12833        assert_eq!(d.caret, 0);
12834    }
12835
12836    // ── arrow collapses an active selection ─────────────────────────────────
12837
12838    #[test]
12839    fn arrow_collapses_selection_to_its_near_edge() {
12840        let mut d = doc_with("collapse", "hello world\n");
12841
12842        // Forward selection (anchor before caret): Right -> end, Left -> start.
12843        d.anchor = Some(2);
12844        d.caret = 7;
12845        d.move_right(false);
12846        assert_eq!((d.caret, d.anchor), (7, None));
12847
12848        d.anchor = Some(2);
12849        d.caret = 7;
12850        d.move_left(false);
12851        assert_eq!((d.caret, d.anchor), (2, None));
12852
12853        // Backward selection (anchor after caret): edges are the same
12854        // regardless of which end the caret started on.
12855        d.anchor = Some(7);
12856        d.caret = 2;
12857        d.move_right(false);
12858        assert_eq!((d.caret, d.anchor), (7, None));
12859
12860        d.anchor = Some(7);
12861        d.caret = 2;
12862        d.move_left(false);
12863        assert_eq!((d.caret, d.anchor), (2, None));
12864    }
12865
12866    #[test]
12867    fn arrow_with_extend_keeps_growing_the_selection() {
12868        let mut d = doc_with("collapse_extend", "hello world\n");
12869        d.anchor = Some(2);
12870        d.caret = 7;
12871        d.move_right(true); // extend: no collapse, caret steps one further
12872        assert_eq!((d.caret, d.anchor), (8, Some(2)));
12873    }
12874
12875    #[test]
12876    fn arrow_without_a_selection_moves_one_character_as_before() {
12877        let mut d = doc_with("no_collapse", "hello\n");
12878        d.caret = 2;
12879        d.move_right(false);
12880        assert_eq!(d.caret, 3);
12881        d.move_left(false);
12882        assert_eq!(d.caret, 2);
12883    }
12884
12885    /// Press Right until it stops, collecting the offsets walked through. Every
12886    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
12887    /// two stops sharing one source offset can't be moved between, so the caret
12888    /// stalls on the first of them and the walk never reaches the rest.
12889    fn walk_right(d: &mut Doc) -> Vec<usize> {
12890        let mut seen = vec![d.caret];
12891        for _ in 0..2000 {
12892            let before = d.caret;
12893            d.move_right(false);
12894            if d.caret == before {
12895                break;
12896            }
12897            seen.push(d.caret);
12898        }
12899        seen
12900    }
12901
12902    #[test]
12903    fn the_caret_crosses_a_soft_break() {
12904        // A newline inside a paragraph is a `soft_break`, which twig gives no
12905        // span of its own — the space it renders as used to borrow the offset of
12906        // the character before it, and a caret can't move without changing
12907        // offset. Right must walk clean off the end of the first line.
12908        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
12909        d.caret = 0;
12910        let seen = walk_right(&mut d);
12911        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
12912    }
12913
12914    #[test]
12915    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
12916        // The paragraph holds one soft break. Folded (the default) it lays out as
12917        // a single reflowed row; Preserve re-lays it as a row per source line.
12918        // The setter must invalidate the cached map for the change to show, and
12919        // again on the way back — so a round trip returns to the folded layout.
12920        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
12921        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
12922        d.build_visual(80);
12923        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
12924
12925        d.set_line_flow(LineFlow::Preserve);
12926        d.build_visual(80);
12927        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
12928
12929        d.set_line_flow(LineFlow::Fold);
12930        d.build_visual(80);
12931        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
12932    }
12933
12934    #[test]
12935    fn the_caret_still_crosses_a_preserved_soft_break() {
12936        // Preserve renders the soft break as a row boundary rather than a space,
12937        // but the caret must still reach every offset — the break's own offset is
12938        // the first row's end stop, so Right walks clean off the end of line one
12939        // onto line two, exactly as it does when the break is folded.
12940        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
12941        d.set_line_flow(LineFlow::Preserve);
12942        d.build_visual(80);
12943        d.caret = 0;
12944        let seen = walk_right(&mut d);
12945        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
12946    }
12947
12948    #[test]
12949    fn the_caret_walks_a_code_block() {
12950        // Every glyph of a code block used to map to the block's start, so the
12951        // whole block was a single offset and the caret couldn't move inside it.
12952        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
12953        let mut d = wysiwyg_doc("code_walk", src);
12954        d.caret = 0;
12955        let seen = walk_right(&mut d);
12956        // The fences are markup: hidden, and no caret stop. The code between
12957        // them is reached a character at a time.
12958        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
12959        for off in code.clone() {
12960            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
12961        }
12962        assert!(seen.contains(&code.end), "no stop after the last line");
12963    }
12964
12965    #[test]
12966    fn the_caret_walks_an_indented_code_block() {
12967        // An indented block's text has the four-space indent stripped, so it
12968        // isn't a verbatim slice and its lines have to be re-found. The caret
12969        // lands on the code, never in the indent.
12970        let src = "    indented\n    code\n";
12971        let mut d = wysiwyg_doc("indent_code_walk", src);
12972        d.caret = 0;
12973        let seen = walk_right(&mut d);
12974        assert!(seen.contains(&src.find("indented").unwrap()));
12975        assert!(seen.contains(&src.find("code").unwrap()));
12976        assert!(
12977            !seen.contains(&0) || seen[0] == 0,
12978            "the caret starts where it was put"
12979        );
12980        // Nothing in the stripped indent is a stop.
12981        for off in [1, 2, 3] {
12982            assert!(!seen.contains(&off), "landed in the indent at {off}");
12983        }
12984    }
12985
12986    #[test]
12987    fn the_caret_leaves_a_tight_heading() {
12988        // "# H" with text directly under it: the heading row's end and the
12989        // separator row's end are the same offset. Right used to find the
12990        // separator's copy, set the caret to where it already was, and stop.
12991        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
12992        d.caret = 2; // the "H"
12993        let seen = walk_right(&mut d);
12994        assert!(
12995            seen.len() > 2,
12996            "Right stalled at the heading's end: {seen:?}"
12997        );
12998        assert!(
12999            seen.contains(&8),
13000            "never reached the end of \"text\": {seen:?}"
13001        );
13002    }
13003
13004    #[test]
13005    fn the_caret_skips_the_gap_between_two_paragraphs() {
13006        // The blank line between two paragraphs is the boundary itself. The
13007        // caret used to be able to sit on it, and typing there landed in the
13008        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
13009        // soft break, so the text visibly snapped back up.
13010        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
13011        d.caret = 1; // the end of "A"
13012        d.move_right(false);
13013        assert_eq!(d.caret, 3, "Right stopped in the gap");
13014        d.insert("x");
13015        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
13016    }
13017
13018    #[test]
13019    fn down_from_a_paragraph_lands_on_the_next_one() {
13020        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
13021        d.caret = 0;
13022        d.move_down(false);
13023        assert_eq!(d.caret, 3, "Down stopped in the gap");
13024    }
13025
13026    #[test]
13027    fn clicking_the_gap_lands_on_real_text() {
13028        // A click can still *reach* the gap — it's drawn, so it's clickable.
13029        // It has to resolve to somewhere the caret can be.
13030        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
13031        d.click(1, 0, false); // the gap row
13032        assert!(
13033            d.caret == 1 || d.caret == 3,
13034            "click left the caret in the gap at {}",
13035            d.caret
13036        );
13037        d.insert("x");
13038        // Either edge of the boundary is a fair place to land; inside it isn't.
13039        assert!(
13040            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
13041            "click in the gap typed into the boundary: {:?}",
13042            d.source
13043        );
13044    }
13045
13046    #[test]
13047    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
13048        // Enter inserts a paragraph break, which leaves a blank line spare on
13049        // either side of a new one. That middle line is a real empty paragraph:
13050        // the caret lands there, and typing makes a paragraph rather than
13051        // extending a neighbour.
13052        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
13053        d.caret = 1;
13054        d.newline();
13055        assert_eq!(d.source, "A\n\n\n\nB\n");
13056        d.build_visual(80);
13057        let (row, _) = d.caret_pos();
13058        assert!(
13059            d.vmap.row_is_navigable(row),
13060            "the caret landed on a gap row"
13061        );
13062        d.insert("x");
13063        assert_eq!(
13064            d.source, "A\n\nx\n\nB\n",
13065            "the new paragraph merged into a neighbour"
13066        );
13067    }
13068
13069    #[test]
13070    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
13071        let mut d = wysiwyg_doc("gap_eof", "A\n");
13072        d.caret = 1;
13073        d.newline();
13074        d.build_visual(80);
13075        let (row, _) = d.caret_pos();
13076        assert!(
13077            d.vmap.row_is_navigable(row),
13078            "the caret landed on a gap row"
13079        );
13080        d.insert("x");
13081        assert!(
13082            d.source.starts_with("A\n\n") && d.source.contains('x'),
13083            "typing at the end merged into A: {:?}",
13084            d.source
13085        );
13086    }
13087
13088    #[test]
13089    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
13090        // A paragraph broken over two source lines is one paragraph. Selecting
13091        // it must not stop at the newline inside it — that newline is markup the
13092        // rich-text view exists to hide.
13093        let src = "one two\nthree four\n\nnext\n";
13094        let mut d = wysiwyg_doc("triple_para", src);
13095        d.select_block_at(2);
13096        assert_eq!(
13097            d.selected_text(),
13098            Some("one two\nthree four"),
13099            "stopped at the soft break"
13100        );
13101    }
13102
13103    #[test]
13104    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
13105        // The reader scrolls down past the caret's row. Nothing moved the
13106        // caret, so the view must stay where it was put — the old code revealed
13107        // the caret every frame, which dragged the view straight back and made
13108        // the document unscrollable past the caret.
13109        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
13110        d.caret = 0;
13111        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
13112        d.scroll = 4; // the wheel
13113        d.follow_caret(0, 3, 9);
13114        assert_eq!(
13115            d.scroll, 4,
13116            "the wheel was overruled by a caret that never moved"
13117        );
13118    }
13119
13120    #[test]
13121    fn moving_the_caret_brings_the_view_back_to_it() {
13122        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
13123        d.caret = 0;
13124        d.follow_caret(0, 3, 9);
13125        d.scroll = 6; // scrolled away
13126        d.move_right(false); // ...and now the caret moves
13127        let (row, _) = d.caret_pos();
13128        d.follow_caret(row, 3, 9);
13129        assert!(
13130            d.scroll <= row && row < d.scroll + 3,
13131            "caret row {row} off screen at scroll {}",
13132            d.scroll
13133        );
13134    }
13135
13136    #[test]
13137    fn scrolling_stops_at_the_last_row() {
13138        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
13139        d.caret = 0;
13140        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
13141        d.scroll = 999; // the wheel, spun hard
13142        d.follow_caret(0, 3, 3);
13143        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
13144    }
13145
13146    #[test]
13147    fn every_cell_of_a_wide_table_is_reachable() {
13148        // A table whose cells are far wider than the surface: the columns are
13149        // cut to fit and the text wraps inside them, so no cell hangs off the
13150        // right edge where the caret can never go.
13151        let src = "| Ingredient | Notes |\n|---|---|\n\
13152                   | flour milled coarse | sift it twice before folding it in |\n";
13153        let mut d = wysiwyg_doc("wide_table_walk", src);
13154        d.build_visual(30);
13155        d.caret = 0;
13156        let seen = walk_right(&mut d);
13157        for word in ["Ingredient", "Notes", "coarse", "folding"] {
13158            let at = src.find(word).unwrap();
13159            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
13160        }
13161    }
13162
13163    // ── view parity ──────────────────────────────────────────────────────────
13164    // `doc_with` pins the source view, so everything above tests a view users
13165    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
13166    // deletion golden cases through *both*, plus the WYSIWYG cases the two
13167    // can't share: where the source carries markup the rendered text is a
13168    // different string, and the views agreeing would itself be the bug.
13169
13170    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
13171
13172    /// Run `action` in both views on one `|`-marked fixture and assert they
13173    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
13174    /// source verbatim, so the two views are looking at the same text and any
13175    /// disagreement is one of them having lost the plot.
13176    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
13177        let (src, caret) = parse_caret(marked);
13178        let run = |view: View, tag: &str| {
13179            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
13180            d.caret = caret;
13181            action(&mut d);
13182            render_caret(&d)
13183        };
13184        let source = run(VIEWS[0].0, VIEWS[0].1);
13185        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
13186        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
13187        source
13188    }
13189
13190    #[test]
13191    fn word_motion_agrees_across_the_views_on_plain_prose() {
13192        let g = both_views;
13193        assert_eq!(
13194            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
13195            "hello |world"
13196        );
13197        assert_eq!(
13198            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
13199            "|hello world"
13200        );
13201        assert_eq!(
13202            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
13203            "hello| world"
13204        );
13205        assert_eq!(
13206            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
13207            "hello world|"
13208        );
13209        assert_eq!(
13210            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
13211            "foo|.bar"
13212        );
13213        assert_eq!(
13214            g("par_ext", "hello |world", |d| d.move_word_right(true)),
13215            "hello [world|]"
13216        );
13217    }
13218
13219    #[test]
13220    fn word_deletion_agrees_across_the_views_on_plain_prose() {
13221        let g = both_views;
13222        assert_eq!(
13223            g("par_db", "hello world|", |d| d.delete_word_back()),
13224            "hello |"
13225        );
13226        assert_eq!(
13227            g("par_df", "hello |world", |d| d.delete_word_forward()),
13228            "hello |"
13229        );
13230        assert_eq!(
13231            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
13232            "|bar baz"
13233        );
13234        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
13235    }
13236
13237    #[test]
13238    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
13239        let g = both_views;
13240        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
13241        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
13242        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
13243        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
13244    }
13245
13246    #[test]
13247    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
13248        // The reproduction: the stop table was built one stop per `char`, so
13249        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
13250        // source view, which steps by grapheme, can't reach and backspace can't
13251        // survive. The two views must land on the same offset.
13252        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
13253        for (view, tag) in VIEWS {
13254            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
13255            d.caret = 1;
13256            d.move_right(false);
13257            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
13258
13259            // ...and the edit that used to sever a joiner off the front of it.
13260            d.backspace();
13261            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
13262            assert_eq!(d.caret, 1);
13263        }
13264    }
13265
13266    #[test]
13267    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
13268        for (view, tag) in VIEWS {
13269            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
13270            d.caret = 0;
13271            d.move_right(false);
13272            assert_eq!(
13273                d.caret,
13274                "e\u{0301}".len(),
13275                "{tag} stopped on the combining mark"
13276            );
13277        }
13278    }
13279
13280    #[test]
13281    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
13282        // The general form: whatever route the caret takes through a document
13283        // full of clusters, it never lands between the codepoints of one — so no
13284        // motion-then-backspace sequence can leave a dangling joiner behind.
13285        use unicode_segmentation::UnicodeSegmentation;
13286
13287        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
13288        let mut d = wysiwyg_doc("cluster_walk", src);
13289        d.caret = 0;
13290        let boundaries: Vec<usize> = src
13291            .grapheme_indices(true)
13292            .map(|(i, _)| i)
13293            .chain(std::iter::once(src.len()))
13294            .collect();
13295        for off in walk_right(&mut d) {
13296            assert!(
13297                boundaries.contains(&off),
13298                "Right stopped at {off}, inside a grapheme cluster"
13299            );
13300        }
13301    }
13302
13303    #[test]
13304    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
13305        // The reproduction: ⌥→ from inside the opening `**` computed its
13306        // boundary over the raw source and landed on byte 8 — inside the
13307        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
13308        // "bold". The caret drew past the bold word and sat inside it.
13309        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
13310        d.caret = 2;
13311        d.move_word_right(false);
13312        assert!(
13313            d.vmap.is_stop(d.caret),
13314            "landed at {}, not a caret stop",
13315            d.caret
13316        );
13317        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
13318        // The rendered row is "a bold c": column 6 is the space just past "bold",
13319        // and now the caret is really there rather than only drawn there.
13320        assert_eq!(d.caret_pos(), (0, 6));
13321
13322        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
13323        d.move_word_left(false);
13324        assert_eq!(d.caret, 4);
13325        assert_eq!(d.caret_pos(), (0, 2));
13326    }
13327
13328    #[test]
13329    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
13330        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
13331        // inside the closing `**`, and left "a ** c\n" — delimiters with no
13332        // opener. Glyph space covers the word alone, which would leave
13333        // "a **** c": markup wrapped around nothing. The word and the styling
13334        // that was only ever the word's go together.
13335        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
13336        d.caret = 10;
13337        d.delete_word_back();
13338        assert_eq!(d.source, "a  c\n");
13339        assert_eq!(d.caret, 2);
13340
13341        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
13342        d.caret = 4; // the "b"
13343        d.delete_word_forward();
13344        assert_eq!(d.source, "a  c\n");
13345    }
13346
13347    #[test]
13348    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
13349        let src = "a ***bold*** c\n";
13350        let mut d = wysiwyg_doc("wys_word_del_nest", src);
13351        d.caret = src.find(" c").unwrap();
13352        d.delete_word_back();
13353        assert_eq!(
13354            d.source, "a  c\n",
13355            "the emph inside the strong empties it too"
13356        );
13357
13358        let src = "a `code` c\n";
13359        let mut d = wysiwyg_doc("wys_word_del_code", src);
13360        d.caret = src.find(" c").unwrap();
13361        d.delete_word_back();
13362        assert_eq!(d.source, "a  c\n");
13363    }
13364
13365    #[test]
13366    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
13367        // Only an *emptied* node goes. Take one word of two and the `**` still
13368        // has a job to do — over the word that's left, with the space the delete
13369        // pushed against the opening delimiter moved out in front of it, or the
13370        // run would be no run at all (`** words**` is literal asterisks — see
13371        // the mark-edge rule on `splice`).
13372        let src = "a **two words** c\n";
13373        let mut d = wysiwyg_doc("wys_word_del_partial", src);
13374        d.caret = src.find(" words").unwrap();
13375        d.delete_word_back();
13376        assert_eq!(d.source, "a  **words** c\n");
13377    }
13378
13379    #[test]
13380    fn source_view_word_motion_still_walks_the_markup() {
13381        // The other half of the decision: in the source view the `**` are
13382        // characters like any other — they're on the screen, so word motion has
13383        // to stop at them and a word-delete has to leave them behind. Only
13384        // WYSIWYG hides them, so only WYSIWYG steps over them.
13385        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
13386        assert_eq!(
13387            g("src_word_motion", "a |**bold** c\n", |d| d
13388                .move_word_right(false)),
13389            "a **bold|** c\n"
13390        );
13391        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
13392        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
13393        assert_eq!(
13394            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
13395            "a **| c\n"
13396        );
13397    }
13398
13399    #[test]
13400    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
13401        // The single invariant both bugs violated: the caret draws and edits at
13402        // the same place only when it's on a stop. `debug_assert_on_a_stop`
13403        // makes the same claim in-place; this pins it from the outside, over a
13404        // document with every kind of thing the map has to be careful about.
13405        // At two widths: the wide one every other test builds at, where no
13406        // fixture folds, and one narrow enough that they all do. A soft wrap is
13407        // where an offset stops being on exactly one row, and testing only the
13408        // width that never wraps is how the caret came to be pinned at the first
13409        // one Down reached.
13410        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
13411                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
13412        // A table of named operations, which is what it looks like.
13413        #[allow(clippy::type_complexity)]
13414        let motions: [(&str, fn(&mut Doc)); 8] = [
13415            ("right", |d| d.move_right(false)),
13416            ("left", |d| d.move_left(false)),
13417            ("word_right", |d| d.move_word_right(false)),
13418            ("word_left", |d| d.move_word_left(false)),
13419            ("down", |d| d.move_down(false)),
13420            ("up", |d| d.move_up(false)),
13421            ("home", |d| d.move_home(false)),
13422            ("end", |d| d.move_end(false)),
13423        ];
13424        for width in [80, 12] {
13425            let mut d = wysiwyg_doc("stop_invariant", src);
13426            d.build_visual(width);
13427            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
13428            assert!(stops.len() > 20, "fixture should have plenty of stops");
13429            for start in stops {
13430                for (name, motion) in &motions {
13431                    d.caret = start;
13432                    d.anchor = None;
13433                    motion(&mut d);
13434                    assert!(
13435                        d.vmap.is_stop(d.caret),
13436                        "{name} from {start} at width {width} landed at {} — not a caret stop",
13437                        d.caret
13438                    );
13439                }
13440            }
13441        }
13442    }
13443
13444    #[test]
13445    fn no_wysiwyg_motion_is_a_dead_end() {
13446        // Down held to the bottom of a document reaches the bottom, and Up held
13447        // to the top reaches the top — from anywhere, at a width that wraps. The
13448        // invariant above says a motion lands somewhere legal; this one says it
13449        // gets somewhere at all, which is what a caret pinned at a wrap boundary
13450        // was quietly failing to do while every assertion around it held.
13451        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
13452                   - item one two three four five\n\nlast\n";
13453        for width in [80, 12] {
13454            let mut d = wysiwyg_doc("no_dead_end", src);
13455            d.build_visual(width);
13456            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
13457            let (first, last) = (stops[0], stops[stops.len() - 1]);
13458            for &start in &stops {
13459                for (name, motion, want) in [
13460                    (
13461                        "down",
13462                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
13463                        last,
13464                    ),
13465                    ("up", |d: &mut Doc| d.move_up(false), first),
13466                ] {
13467                    d.caret = start;
13468                    d.anchor = None;
13469                    d.goal_col = None;
13470                    // Every row, plus the presses the edges take, plus slack.
13471                    for _ in 0..d.vmap.num_rows() + 4 {
13472                        motion(&mut d);
13473                    }
13474                    assert_eq!(
13475                        d.caret, want,
13476                        "{name} held from {start} at width {width} never arrived"
13477                    );
13478                }
13479            }
13480        }
13481    }
13482    // ── display columns ──────────────────────────────────────────────────────
13483    // A `col` is a terminal cell, not a character. The two are the same number
13484    // for the ASCII the fixtures above are written in, which is how they came
13485    // apart in the first place: `你` is one character drawn in two cells, so a
13486    // column counted in characters names a cell the text isn't in — one earlier
13487    // for every wide character to its left.
13488
13489    #[test]
13490    fn a_wide_character_is_two_columns_wide() {
13491        // The reproduction: `你` is one char and two cells, so the caret just
13492        // past it drew at column 1 — inside the character it had already left.
13493        for (view, tag) in VIEWS {
13494            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
13495            d.caret = "你".len();
13496            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
13497            d.caret = "你好".len();
13498            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
13499        }
13500    }
13501
13502    #[test]
13503    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
13504        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
13505        // two-cell — measuring six cells one at a time, but the character they
13506        // spell is drawn in two. Width belongs to the cluster, not the glyph,
13507        // and the frontends measure it the same way.
13508        let family = "👨‍👩‍👧";
13509        for (view, tag) in VIEWS {
13510            let src = format!("a{family}b\n");
13511            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
13512            d.caret = 1 + family.len();
13513            assert_eq!(
13514                d.caret_pos(),
13515                (0, 3),
13516                "{tag}: 'a' is one cell, the family two"
13517            );
13518        }
13519    }
13520
13521    #[test]
13522    fn both_cells_of_a_wide_character_mean_the_character() {
13523        // Clicking the far half of `好` is still clicking `好`: half a character
13524        // is not a place the caret can be, so it comes to rest at the
13525        // character's start — the column it would have been drawn at anyway.
13526        for (view, tag) in VIEWS {
13527            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
13528            for col in [2, 3] {
13529                d.caret = 0;
13530                d.click(0, col, false);
13531                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
13532                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
13533            }
13534            // Past the last cell is the line's end, as it is for ASCII.
13535            d.click(0, 9, false);
13536            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
13537        }
13538    }
13539
13540    #[test]
13541    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
13542        // The mapping is only a mapping if it inverts: the cell the caret is
13543        // drawn in has to be the cell that brings it back to the same offset.
13544        // Over a fixture where a character may be one cell or two, and one
13545        // codepoint or five.
13546        use unicode_segmentation::UnicodeSegmentation;
13547
13548        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
13549
13550        let mut d = doc_in(View::Source, "roundtrip_source", src);
13551        // Every offset the source view's caret can occupy: it steps by grapheme
13552        // cluster, so those are its boundaries.
13553        for (off, _) in src
13554            .grapheme_indices(true)
13555            .chain(std::iter::once((src.len(), "")))
13556        {
13557            d.caret = off;
13558            let (row, col) = d.caret_pos();
13559            d.click(row, col, false);
13560            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
13561        }
13562
13563        // And in WYSIWYG, where the offsets the caret can occupy are the map's
13564        // stops rather than every boundary.
13565        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
13566        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
13567        assert!(stops.len() > 20, "fixture should have plenty of stops");
13568        for off in stops {
13569            d.caret = off;
13570            let (row, col) = d.caret_pos();
13571            d.click(row, col, false);
13572            assert_eq!(
13573                d.caret, off,
13574                "wysiwyg: {off} → ({row}, {col}) → {}",
13575                d.caret
13576            );
13577        }
13578    }
13579
13580    #[test]
13581    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
13582        // Down from under `世` lands under the glyph in that cell, not two
13583        // characters further along the line. The goal is a column, so a line of
13584        // wide characters and a line of ASCII line up the way they're drawn.
13585        //
13586        // The gap differs by view: a bare newline inside a paragraph is a soft
13587        // break, which WYSIWYG draws as a space on a single row. The views share
13588        // a grid only where the source's lines are the renderer's rows too.
13589        for (view, tag) in VIEWS {
13590            let gap = if view == View::Source { "\n" } else { "\n\n" };
13591            let src = format!("你好世{gap}abcdef\n");
13592            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
13593            d.caret = "你好".len();
13594            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
13595            d.move_down(false);
13596            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
13597            assert!(
13598                d.source[d.caret..].starts_with('e'),
13599                "{tag}: landed on the wrong glyph"
13600            );
13601        }
13602    }
13603
13604    #[test]
13605    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
13606        // Down from column 3 onto `你好`, whose characters start at columns 0
13607        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
13608        // between the cells of one character, so the caret rests on it — and on
13609        // its start, which is the only offset there that is a caret stop.
13610        for (view, tag) in VIEWS {
13611            let gap = if view == View::Source { "\n" } else { "\n\n" };
13612            let src = format!("abcdef{gap}你好\n");
13613            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
13614            let line = src.find('你').unwrap();
13615            d.caret = 3;
13616            d.move_down(false);
13617            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
13618            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
13619        }
13620    }
13621
13622    #[test]
13623    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
13624        // The column the cell's text is laid out in is measured in cells, so the
13625        // caret walking that text has to be too — the two agreeing is the whole
13626        // point of the grid staying square.
13627        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
13628        let at = d.source.find("你").unwrap();
13629        d.caret = at;
13630        let (row, col) = d.caret_pos();
13631        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
13632        // cells further along.
13633        assert_eq!(col, 2, "the cell's first character");
13634        d.move_right(false);
13635        assert_eq!(
13636            d.caret_pos(),
13637            (row, 4),
13638            "`好` is drawn past `你`'s two cells"
13639        );
13640        assert_eq!(d.caret, at + "你".len());
13641    }
13642
13643    // ── active inline marks ───────────────────────────────────────────────────
13644
13645    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
13646    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
13647        let (src, caret) = parse_caret(marked);
13648        let mut d = doc_in(view, name, &src);
13649        d.caret = caret;
13650        d.active_inline_marks().iter().collect()
13651    }
13652
13653    /// The marks over the selection `[start, end)`.
13654    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
13655        let mut d = doc_in(view, name, src);
13656        d.anchor = Some(start);
13657        d.caret = end;
13658        d.active_inline_marks().iter().collect()
13659    }
13660
13661    #[test]
13662    fn a_caret_in_a_mark_reports_it() {
13663        for (view, tag) in VIEWS {
13664            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
13665            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
13666            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
13667            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
13668            // Plain text under no mark lights nothing — the toolbar's resting state.
13669            assert_eq!(m("a| **bold** b"), [], "{tag}");
13670            assert!(m("plain t|ext").is_empty(), "{tag}");
13671        }
13672    }
13673
13674    #[test]
13675    fn nested_marks_all_report() {
13676        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
13677        // the ancestor chain is a chain, and every mark on it is in force.
13678        for (view, tag) in VIEWS {
13679            assert_eq!(
13680                marks(
13681                    view,
13682                    &format!("marks_nested_{tag}"),
13683                    "**bold and *bo|th*** end"
13684                ),
13685                [InlineKind::Strong, InlineKind::Emph],
13686                "{tag}"
13687            );
13688        }
13689    }
13690
13691    #[test]
13692    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
13693        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
13694        // the first byte of its text and the byte after its last — both inside
13695        // the mark's span, both places typing lands inside the bold. The offset
13696        // past the closing delimiter is the next text, and reports nothing.
13697        let src = "a **bold** b";
13698        let inner_start = src.find("bold").unwrap(); // 4
13699        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
13700        for (view, tag) in VIEWS {
13701            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
13702            for off in [2, 3, inner_start, inner_end, 9] {
13703                d.caret = off;
13704                assert!(
13705                    d.active_inline_marks().contains(InlineKind::Strong),
13706                    "{tag}: offset {off} is inside the strong span"
13707                );
13708            }
13709            for off in [0, 1, 10, 11, 12] {
13710                d.caret = off;
13711                assert!(
13712                    !d.active_inline_marks().contains(InlineKind::Strong),
13713                    "{tag}: offset {off} is outside the strong run"
13714                );
13715            }
13716        }
13717    }
13718
13719    #[test]
13720    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
13721        // Regression: twig resolves an offset that is one node's end and the
13722        // next one's start to the node that *starts* there, so `**bold**|\n`
13723        // isn't bold. With nothing following there's no tie to break and the
13724        // chain still ended at the mark, which made a trailing `\n` — not the
13725        // text — decide whether the caret after a bold word reported bold. It's
13726        // the offset past the mark either way, and typing there is plain either
13727        // way. A blank document typed into is exactly this shape.
13728        for (view, tag) in VIEWS {
13729            let m = |name: String, marked| marks(view, &name, marked);
13730            assert_eq!(
13731                m(format!("marks_eob_{tag}"), "**bold**|"),
13732                [],
13733                "{tag}: no trailing newline"
13734            );
13735            assert_eq!(
13736                m(format!("marks_eol_{tag}"), "**bold**|\n"),
13737                [],
13738                "{tag}: with one"
13739            );
13740            // And the last offset that *is* in the mark still is.
13741            assert_eq!(
13742                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
13743                [InlineKind::Strong],
13744                "{tag}"
13745            );
13746        }
13747    }
13748
13749    #[test]
13750    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
13751        let src = "a **bold** b";
13752        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
13753        for (view, tag) in VIEWS {
13754            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
13755            // The whole bold word, and a slice of it.
13756            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
13757            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
13758            // Ending exactly at the closing delimiter's start is still all-bold:
13759            // an exclusive end sits *past* the last selected character, so the
13760            // question is asked of the character, not the boundary.
13761            assert_eq!(
13762                m(b, d_ + 2),
13763                [InlineKind::Strong],
13764                "{tag}: through the close"
13765            );
13766            // Half in, half out: Bold lit here would claim a press turns it off.
13767            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
13768            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
13769        }
13770    }
13771
13772    #[test]
13773    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
13774        // Both ends are bold, but the space between them isn't — two runs are two
13775        // nodes, which is exactly what the node id catches and a kind-only
13776        // comparison would not.
13777        let src = "**one** **two**";
13778        for (view, tag) in VIEWS {
13779            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
13780            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
13781        }
13782    }
13783
13784    #[test]
13785    fn marks_read_the_document_as_it_is_edited() {
13786        // The point of asking twig every frame instead of caching: the answer has
13787        // to follow the toggle that changed it.
13788        let mut d = wysiwyg_doc("marks_live", "one two\n");
13789        d.anchor = Some(0);
13790        d.caret = 3;
13791        assert!(d.active_inline_marks().is_empty(), "plain to start");
13792        d.toggle(InlineKind::Strong);
13793        assert_eq!(d.source, "**one** two\n");
13794        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
13795        assert!(d.active_inline_marks().contains(InlineKind::Strong));
13796        d.toggle(InlineKind::Strong);
13797        assert!(d.active_inline_marks().is_empty(), "and off again");
13798    }
13799
13800    #[test]
13801    fn a_link_is_not_an_inline_mark() {
13802        // `link`/`str` are inline nodes, but nothing on the inline toolbar
13803        // toggles them — a set with a "link mark" in it would have no button.
13804        for (view, tag) in VIEWS {
13805            assert_eq!(
13806                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
13807                [],
13808                "{tag}"
13809            );
13810        }
13811    }
13812
13813    // ── blank documents ───────────────────────────────────────────────────────
13814
13815    #[test]
13816    fn a_blank_document_is_untitled_empty_and_markdown() {
13817        let mut d = Doc::blank().unwrap();
13818        assert!(d.is_untitled());
13819        assert_eq!(d.path, PathBuf::new());
13820        assert_eq!(
13821            d.file_name(),
13822            "untitled",
13823            "the header has to show something"
13824        );
13825        assert_eq!(d.format_name(), "markdown");
13826        assert_eq!(d.source, "");
13827        assert!(!d.dirty, "nothing typed yet is nothing to lose");
13828        assert_eq!(d.disk_state(), DiskState::Untitled);
13829        // And it's a document you can be in: the default view renders it.
13830        d.build_visual(80);
13831        assert_eq!(d.caret, 0);
13832    }
13833
13834    #[test]
13835    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
13836        let mut d = Doc::blank().unwrap();
13837        d.insert("hello");
13838        assert!(d.dirty);
13839        d.save();
13840        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
13841        assert!(d.dirty, "it must not come away believing it saved");
13842        assert!(d.is_untitled(), "and it still has no file");
13843    }
13844
13845    #[test]
13846    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
13847        let p = temp_path("blank_save_as");
13848        let mut d = Doc::blank().unwrap();
13849        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
13850        // be kept literal (`\#`); this test is about save-as, not escaping (which
13851        // has its own test), so it types nothing that escaping would touch.
13852        d.insert("hi");
13853        d.save_as(p.clone());
13854        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
13855        assert!(!d.is_untitled());
13856        assert!(!d.dirty);
13857        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
13858        assert_eq!(
13859            d.disk_state(),
13860            DiskState::Unchanged,
13861            "the watermark is stamped"
13862        );
13863        // And ⌘S is a plain save from here on.
13864        d.insert("!");
13865        d.save();
13866        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
13867        let _ = std::fs::remove_file(&p);
13868    }
13869
13870    // ── a file that isn't there yet ───────────────────────────────────────────
13871
13872    /// A unique path in the temp dir with the given extension, guaranteed not to
13873    /// exist — what `leaf notes.md` is handed when the file has never been made.
13874    fn missing_path(name: &str, ext: &str) -> PathBuf {
13875        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13876        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13877        let mut p = std::env::temp_dir();
13878        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
13879        let _ = std::fs::remove_file(&p);
13880        p
13881    }
13882
13883    #[test]
13884    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
13885        let p = missing_path("named", "md");
13886        let mut d = Doc::open_or_create(p.clone()).unwrap();
13887
13888        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
13889        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
13890        assert!(
13891            !d.is_untitled(),
13892            "it has the name the user asked for — ^S must not detour to Save As"
13893        );
13894        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
13895        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
13896        assert!(!p.exists(), "and opening it wrote nothing");
13897        // And it's a document you can be in.
13898        d.build_visual(80);
13899        assert_eq!(d.caret, 0);
13900    }
13901
13902    #[test]
13903    fn a_new_file_is_created_by_its_first_save() {
13904        let p = missing_path("first_save", "md");
13905        let mut d = Doc::open_or_create(p.clone()).unwrap();
13906        d.insert("hello\n");
13907        assert!(d.dirty);
13908        d.save();
13909
13910        assert_eq!(
13911            std::fs::read_to_string(&p).unwrap(),
13912            "hello\n",
13913            "a plain ^S wrote it — no Save As, no name to invent"
13914        );
13915        assert!(!d.dirty);
13916        assert_eq!(d.disk_state(), DiskState::Unchanged);
13917        let _ = std::fs::remove_file(&p);
13918    }
13919
13920    #[test]
13921    fn a_new_file_takes_its_format_from_the_extension() {
13922        // The one thing `blank` can't do: with no name it has to assume Markdown,
13923        // and typing djot into a Markdown parse is the wrong buffer.
13924        let dj = missing_path("format", "dj");
13925        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
13926        let md = missing_path("format", "md");
13927        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
13928    }
13929
13930    #[test]
13931    fn a_new_file_reports_itself_missing_until_it_is_saved() {
13932        // Not `Untitled` — that's the answer for a document with no path, and it
13933        // would tell a frontend there is nothing a save could collide with. Here
13934        // there is a path, and the file simply isn't at it yet.
13935        let p = missing_path("disk_state", "md");
13936        let mut d = Doc::open_or_create(p.clone()).unwrap();
13937        assert_eq!(d.disk_state(), DiskState::Missing);
13938
13939        // Somebody else creates it while the buffer is open: that's an overwrite
13940        // the frontend has to be able to prompt about, exactly as for an opened
13941        // file. Their bytes, not ours, so `Changed`.
13942        std::fs::write(&p, "theirs\n").unwrap();
13943        assert_eq!(d.disk_state(), DiskState::Changed);
13944
13945        // Saving makes the file ours and re-stamps the watermark.
13946        d.insert("ours\n");
13947        d.save();
13948        assert_eq!(d.disk_state(), DiskState::Unchanged);
13949        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
13950        let _ = std::fs::remove_file(&p);
13951    }
13952
13953    #[test]
13954    fn open_or_create_still_opens_a_file_that_is_there() {
13955        let d = doc_with("open_or_create_existing", "body\n");
13956        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
13957        assert_eq!(reopened.source, "body\n");
13958        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
13959    }
13960
13961    #[test]
13962    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
13963        // A mistyped flag or a stray argument must not become a buffer promising
13964        // to save somewhere — the same refusal `open` gives a real file.
13965        let mut p = std::env::temp_dir();
13966        p.push("leaf_test_new_bad_ext.wat");
13967        assert!(Doc::open_or_create(p).is_err());
13968        let mut none = std::env::temp_dir();
13969        none.push("leaf_test_new_no_ext");
13970        assert!(Doc::open_or_create(none).is_err());
13971    }
13972
13973    #[test]
13974    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
13975        // Opening reads nothing, so there is nothing to fail on yet; the write is
13976        // where it fails, and it says so rather than claiming a save.
13977        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
13978        let mut d = Doc::open_or_create(p).unwrap();
13979        d.insert("x");
13980        d.save();
13981        assert!(
13982            d.status.as_deref().unwrap().starts_with("save failed:"),
13983            "got {:?}",
13984            d.status
13985        );
13986        assert!(d.dirty, "it must not come away believing it saved");
13987    }
13988
13989    // ── save as ───────────────────────────────────────────────────────────────
13990
13991    /// A unique path in the temp dir that no fixture wrote — a Save As target.
13992    fn temp_path(name: &str) -> PathBuf {
13993        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13994        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13995        let mut p = std::env::temp_dir();
13996        p.push(format!("leaf_test_target_{name}_{seq}.md"));
13997        let _ = std::fs::remove_file(&p);
13998        p
13999    }
14000
14001    #[test]
14002    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
14003        let mut d = doc_with("save_as_move", "original\n");
14004        let old = d.path.clone();
14005        let new = temp_path("save_as_move");
14006        d.insert("edited: ");
14007        d.save_as(new.clone());
14008
14009        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
14010        assert_eq!(
14011            std::fs::read_to_string(&old).unwrap(),
14012            "original\n",
14013            "Save As doesn't touch the file it came from"
14014        );
14015        assert_eq!(d.path, new, "the document moved");
14016        assert!(!d.dirty);
14017        assert_eq!(
14018            d.status.as_deref(),
14019            Some(&*format!("saved {}", d.file_name()))
14020        );
14021
14022        // Every later save follows it, which is the whole difference from a copy.
14023        d.caret = 0;
14024        d.insert("re-");
14025        d.save();
14026        assert_eq!(
14027            std::fs::read_to_string(&new).unwrap(),
14028            "re-edited: original\n"
14029        );
14030        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
14031        let _ = std::fs::remove_file(&new);
14032    }
14033
14034    #[test]
14035    fn save_as_overwrites_an_existing_target() {
14036        // The picker already asked; asking again down here is the same question
14037        // twice, and the second one has no way to be answered.
14038        let new = temp_path("save_as_over");
14039        std::fs::write(&new, "theirs\n").unwrap();
14040        let mut d = doc_with("save_as_over", "ours\n");
14041        d.save_as(new.clone());
14042        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
14043        let _ = std::fs::remove_file(&new);
14044    }
14045
14046    #[test]
14047    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
14048        let mut d = doc_with("save_as_fail", "body\n");
14049        let old = d.path.clone();
14050        d.insert("x");
14051        // A directory that doesn't exist: the write can't land.
14052        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
14053        d.save_as(bad);
14054
14055        assert_eq!(
14056            d.path, old,
14057            "the document must not move to a file that isn't there"
14058        );
14059        assert!(d.dirty, "and must not believe it saved");
14060        assert!(
14061            d.status.as_deref().unwrap().starts_with("save failed:"),
14062            "the same failure a plain save reports, got {:?}",
14063            d.status
14064        );
14065        // The original is still the document's file, and still saveable.
14066        d.save();
14067        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
14068        assert!(!d.dirty);
14069    }
14070
14071    #[test]
14072    fn save_as_renames_without_reparsing_the_format() {
14073        // `.dj` on the name doesn't make the buffer djot: it was parsed as
14074        // Markdown and still is, and saying otherwise would be a conversion the
14075        // user never asked for (and an undo history thrown away to do it).
14076        let mut d = doc_with("save_as_format", "**b**\n");
14077        let mut new = temp_path("save_as_format");
14078        new.set_extension("dj");
14079        d.save_as(new.clone());
14080        assert_eq!(d.format_name(), "markdown");
14081        let _ = std::fs::remove_file(&new);
14082    }
14083
14084    // ── external change / reload ──────────────────────────────────────────────
14085
14086    #[test]
14087    fn an_untouched_file_reports_unchanged() {
14088        let mut d = doc_with("disk_clean", "body\n");
14089        assert_eq!(d.disk_state(), DiskState::Unchanged);
14090        // Editing the buffer is not editing the file.
14091        d.insert("x");
14092        assert_eq!(d.disk_state(), DiskState::Unchanged);
14093        assert!(d.dirty);
14094        // Saving re-stamps the watermark rather than reporting our own bytes back.
14095        d.save();
14096        assert_eq!(d.disk_state(), DiskState::Unchanged);
14097    }
14098
14099    #[test]
14100    fn a_file_written_underneath_reports_changed() {
14101        let mut d = doc_with("disk_changed", "body\n");
14102        std::fs::write(&d.path, "someone else\n").unwrap();
14103        assert_eq!(d.disk_state(), DiskState::Changed);
14104        // Dirty *and* changed is the clobber: both halves are readable, and
14105        // leaf-core takes neither side.
14106        d.insert("x");
14107        assert!(d.dirty && d.disk_state() == DiskState::Changed);
14108        // Saving anyway is allowed — the frontend asked, or chose not to.
14109        d.save();
14110        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
14111        assert_eq!(d.disk_state(), DiskState::Unchanged);
14112    }
14113
14114    #[test]
14115    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
14116        // The hash is what makes this honest: the file was written (a fresh
14117        // mtime), and nothing about the document is stale.
14118        let d = doc_with("disk_same_bytes", "body\n");
14119        std::fs::write(&d.path, "body\n").unwrap();
14120        assert_eq!(d.disk_state(), DiskState::Unchanged);
14121    }
14122
14123    #[test]
14124    fn a_deleted_file_reports_missing() {
14125        let mut d = doc_with("disk_missing", "body\n");
14126        std::fs::remove_file(&d.path).unwrap();
14127        assert_eq!(d.disk_state(), DiskState::Missing);
14128        // A save recreates it, and the document is whole again.
14129        d.save();
14130        assert_eq!(d.disk_state(), DiskState::Unchanged);
14131        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
14132    }
14133
14134    #[test]
14135    fn reload_replaces_the_document_with_the_file() {
14136        for (view, tag) in VIEWS {
14137            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
14138            d.insert("edited ");
14139            assert!(d.dirty);
14140            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
14141            d.reload();
14142
14143            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
14144            assert!(!d.dirty, "{tag}: the file is what we have");
14145            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
14146            assert_eq!(
14147                d.status.as_deref(),
14148                Some(&*format!("reloaded {}", d.file_name()))
14149            );
14150            // The reloaded tree is live, not the old parse.
14151            d.caret = d.source.find("three").unwrap();
14152            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
14153        }
14154    }
14155
14156    #[test]
14157    fn reload_clamps_the_caret_and_drops_the_selection() {
14158        let mut d = doc_with("reload_caret", "a long first line\n");
14159        d.caret = 12;
14160        d.anchor = Some(4);
14161        std::fs::write(&d.path, "short\n").unwrap();
14162        d.reload();
14163        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
14164        assert_eq!(
14165            d.anchor, None,
14166            "a selection over bytes that changed is a lie"
14167        );
14168        assert!(d.selection().is_none());
14169
14170        // A caret the file still has room for stays put.
14171        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
14172        d.caret = 2;
14173        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
14174        d.reload();
14175        assert_eq!(d.caret, 2);
14176    }
14177
14178    /// A silent reload is something that happened *to* a reader — a formatter,
14179    /// a `git checkout` — so it has to be undoable like anything else that
14180    /// changes the document, and undoable as one step rather than as however
14181    /// many the file happens to differ by.
14182    #[test]
14183    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
14184        let mut d = doc_with("reload_undo", "body\n");
14185        d.insert("x");
14186        assert_eq!(d.source, "xbody\n");
14187        std::fs::write(&d.path, "replaced\n").unwrap();
14188        d.reload();
14189        assert_eq!(d.source, "replaced\n");
14190        assert!(!d.dirty, "a reload lands clean");
14191
14192        // One ^Z takes the whole swap off, and hands back the unsaved work it
14193        // replaced — which is unsaved again, because the file no longer says it.
14194        d.undo();
14195        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
14196        assert!(d.dirty, "and what it comes back to is unsaved");
14197        // …and the history under it is still there.
14198        d.undo();
14199        assert_eq!(
14200            d.source, "body\n",
14201            "the typing before the reload undoes too"
14202        );
14203        // Redo walks back up through the reload.
14204        d.redo();
14205        d.redo();
14206        assert_eq!(d.source, "replaced\n");
14207    }
14208
14209    /// A file rewritten with the bytes it already had is not an edit, so it
14210    /// must not leave an undo step behind for something nobody did.
14211    #[test]
14212    fn reloading_identical_bytes_pushes_no_undo_step() {
14213        let mut d = doc_with("reload_same", "body\n");
14214        d.insert("x");
14215        std::fs::write(&d.path, "xbody\n").unwrap();
14216        d.reload();
14217        assert_eq!(d.source, "xbody\n");
14218        assert!(!d.dirty, "the file now says what the buffer does");
14219        d.undo();
14220        assert_eq!(
14221            d.source, "body\n",
14222            "one step back is the typing, not a no-op"
14223        );
14224    }
14225
14226    #[test]
14227    fn a_reload_that_cant_read_leaves_the_document_alone() {
14228        let mut d = doc_with("reload_gone", "body\n");
14229        d.insert("x");
14230        std::fs::remove_file(&d.path).unwrap();
14231        d.reload();
14232        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
14233        assert!(d.dirty);
14234        assert!(
14235            d.status.as_deref().unwrap().starts_with("reload failed:"),
14236            "{:?}",
14237            d.status
14238        );
14239
14240        // And an untitled document has nothing to reload from.
14241        let mut d = Doc::blank().unwrap();
14242        d.insert("typed");
14243        d.reload();
14244        assert_eq!(d.source, "typed");
14245        assert_eq!(d.status.as_deref(), Some("no file to reload"));
14246    }
14247
14248    #[test]
14249    fn a_read_only_document_refuses_every_door() {
14250        let mut d = doc_with("readonly", "one two three\n");
14251        d.insert("x");
14252        assert!(d.dirty, "writable first, so the undo step exists");
14253        d.set_read_only(true);
14254        let before = d.source.clone();
14255        d.insert("y");
14256        d.backspace();
14257        d.undo();
14258        d.redo();
14259        assert_eq!(d.source, before, "no door moved a byte");
14260        d.set_read_only(false);
14261        d.undo();
14262        assert_ne!(d.source, before, "off again, the same doors work");
14263    }
14264
14265    /// The doors that go to twig's own verbs rather than through the splice.
14266    /// Typed text in the rendered view under the default markup mode is the
14267    /// everyday one — it is what a keystroke in leaf-web or the Apple views
14268    /// becomes — and it walked straight past the gate.
14269    #[test]
14270    fn a_read_only_document_refuses_the_doors_around_the_splice() {
14271        let mut d = wysiwyg_doc(
14272            "readonly-doors",
14273            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
14274        );
14275        d.set_markup_mode(MarkupMode::None);
14276        d.set_read_only(true);
14277        let before = d.source.clone();
14278        d.place_caret(3, false);
14279        d.insert("y");
14280        d.insert_link("https://example.com");
14281        d.insert_image("a.png", "alt");
14282        d.insert_thematic_break();
14283        d.insert_footnote();
14284        d.place_caret(0, false);
14285        d.place_caret(3, true);
14286        d.toggle(InlineKind::Strong);
14287        d.toggle_heading(2);
14288        d.set_block(BlockKind::Paragraph);
14289        d.toggle_list(false);
14290        d.toggle_blockquote();
14291        d.toggle_task_item();
14292        d.newline();
14293        d.indent();
14294        d.set_code_language("rust");
14295        let in_cell = d.source.find("| c").unwrap() + 2;
14296        d.place_caret(in_cell, false);
14297        assert!(d.caret_in_table(), "the caret is in the grid");
14298        assert!(!d.cell_line_break(), "the cell break reports the refusal");
14299        assert_eq!(d.source, before, "no door moved a byte");
14300        assert!(!d.dirty, "nothing to save");
14301        d.set_read_only(false);
14302        d.place_caret(3, false);
14303        d.insert("y");
14304        assert_ne!(d.source, before, "off again, the same doors work");
14305    }
14306
14307    #[test]
14308    fn a_selection_quote_carries_its_context_on_char_boundaries() {
14309        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
14310        let start = d.source.find("exact").unwrap();
14311        d.place_caret(start, false);
14312        d.place_caret(start + "exact".len(), true);
14313        let q = d.selection_quote(3).unwrap();
14314        assert_eq!(q.exact, "exact");
14315        assert_eq!(
14316            q.prefix, "你好 ",
14317            "chars, not bytes — the multibyte pair counts as two"
14318        );
14319        assert_eq!(q.suffix, " 世界");
14320        assert_eq!(&d.source[q.start..q.end], "exact");
14321        // At the edges the context clips rather than erring.
14322        d.place_caret(0, false);
14323        d.place_caret(6, true);
14324        let q = d.selection_quote(40).unwrap();
14325        assert_eq!(q.prefix, "");
14326        assert_eq!(q.exact, "before");
14327        // No selection is no quote.
14328        d.place_caret(0, false);
14329        assert!(d.selection_quote(3).is_none());
14330    }
14331
14332    #[test]
14333    fn highlights_are_kept_sorted_and_answer_point_queries() {
14334        let mut d = doc_with("hl", "one two three\n");
14335        d.set_highlights(vec![
14336            Highlight {
14337                start: 8,
14338                end: 13,
14339                id: "b".into(),
14340                color: None,
14341                marker: None,
14342            },
14343            Highlight {
14344                start: 0,
14345                end: 3,
14346                id: "a".into(),
14347                color: Some("#ffe066".into()),
14348                marker: None,
14349            },
14350            Highlight {
14351                start: 5,
14352                end: 5,
14353                id: "empty".into(),
14354                color: None,
14355                marker: None,
14356            },
14357        ]);
14358        assert_eq!(
14359            d.highlights()
14360                .iter()
14361                .map(|h| h.id.as_str())
14362                .collect::<Vec<_>>(),
14363            ["a", "b"],
14364            "sorted by start, the empty range dropped"
14365        );
14366        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
14367        assert_eq!(d.highlight_at(3), None, "end is exclusive");
14368        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
14369        d.set_highlights(Vec::new());
14370        assert!(d.highlights().is_empty(), "a replace is a replace");
14371    }
14372
14373    /// `Highlight::covering` and the cursor over it are what both painters ask
14374    /// per glyph, so they have to answer the same as the scan they replaced —
14375    /// including in the gaps, which is where most glyphs are.
14376    #[test]
14377    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
14378        let hl = |start: usize, end: usize, id: &str| Highlight {
14379            start,
14380            end,
14381            id: id.into(),
14382            color: None,
14383            marker: None,
14384        };
14385        // Disjoint, as search hits are: in a range, in a gap, and past the end.
14386        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
14387        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
14388        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
14389        assert_eq!(
14390            Highlight::covering(&hits, 105),
14391            None,
14392            "a gap covers nothing"
14393        );
14394        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
14395        assert_eq!(Highlight::covering(&hits, 9_999), None);
14396        assert_eq!(Highlight::covering(&[], 0), None);
14397
14398        // Nested: first by start, so a hit inside an annotation still resolves
14399        // to the annotation — and the range that stops short doesn't mask it.
14400        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
14401        assert_eq!(
14402            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
14403            Some("outer")
14404        );
14405        assert_eq!(
14406            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
14407            Some("outer")
14408        );
14409    }
14410
14411    /// The cursor is an optimisation, so the only thing worth asserting is that
14412    /// it is not also a change of answer — at every offset, over a list with a
14413    /// nest in it, walked forwards and then backwards.
14414    #[test]
14415    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
14416        let hl = |start: usize, end: usize, id: &str| Highlight {
14417            start,
14418            end,
14419            id: id.into(),
14420            color: None,
14421            marker: None,
14422        };
14423        let mut list = vec![
14424            hl(0, 20, "outer"),
14425            hl(5, 10, "inner"),
14426            hl(30, 33, "hit"),
14427            hl(40, 43, "hit"),
14428        ];
14429        list.sort_by_key(|h| (h.start, h.end));
14430
14431        let mut cursor = HighlightCursor::new(&list);
14432        for offset in 0..50 {
14433            assert_eq!(
14434                cursor.at(offset).map(|h| h.id.as_str()),
14435                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
14436                "cursor disagrees at {offset}"
14437            );
14438        }
14439        // Backwards: the cursor re-seats rather than answering from where it
14440        // had got to, so a painter that revisits a row is still told the truth.
14441        for offset in (0..50).rev() {
14442            assert_eq!(
14443                cursor.at(offset).map(|h| h.id.as_str()),
14444                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
14445                "cursor disagrees walking back at {offset}"
14446            );
14447        }
14448    }
14449}