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::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
44
45/// Which view the body shows.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum View {
48    /// The raw document with a caret in source bytes.
49    Source,
50    /// Markup resolved to real styles, caret riding the rendered glyphs.
51    Wysiwyg,
52}
53
54/// How much of the source markup the WYSIWYG view exposes — a per-editor
55/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
56/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
57/// terms, and every rung below is about *delimiters*, whatever grammar spells
58/// them. The examples are Markdown only because that is what most documents are.
59///
60/// A single ladder over two underlying axes, because only three of their four
61/// combinations are coherent:
62///
63/// | | authoring off | authoring on |
64/// |---|---|---|
65/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
66/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
67///
68/// The empty quadrant would show delimiters on the caret's line and then escape
69/// the ones you type — a surface that displays a syntax it refuses to accept.
70/// Someone who wants to read raw markup without authoring it has
71/// [`View::Source`], which is the better tool for it.
72///
73/// The two axes are read separately by the code that cares — see
74/// [`reveals_caret_line`](Self::reveals_caret_line) and
75/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
76/// as a ladder.
77#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
78pub enum MarkupMode {
79    /// Delimiters stay hidden even on the caret's line, and typed syntax stays
80    /// literal — twig escapes anything that would open markup, so formatting
81    /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
82    /// reading surface for people who don't write markup by hand; the default,
83    /// and what Diaryx ships.
84    #[default]
85    None,
86    /// Delimiters stay hidden, but typing them authors real markup: `*x*`
87    /// becomes italic and the asterisks disappear into the styling
88    /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
89    /// clean surface back once it has been applied.
90    Shortcuts,
91    /// The caret's line shows its raw markup while every other line renders
92    /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
93    /// — for people fluent in the document's grammar who want to see and edit
94    /// the delimiters they type.
95    Full,
96}
97
98impl MarkupMode {
99    /// Whether the rich view shows raw delimiters on the line holding the caret.
100    /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
101    /// WYSIWYG builder.
102    pub fn reveals_caret_line(self) -> bool {
103        matches!(self, MarkupMode::Full)
104    }
105
106    /// Whether typed markup characters author real formatting. The editing axis
107    /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
108    pub fn authors(self) -> bool {
109        !matches!(self, MarkupMode::None)
110    }
111}
112
113/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
114/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
115/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
116/// either flow. The renderer consults it when it lays a block's inline content
117/// into visual rows.
118#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
119pub enum LineFlow {
120    /// A soft break folds into a space and the paragraph reflows to the
121    /// viewport width — flowing prose, where the source's line wrapping is
122    /// insignificant. The default, and what Diaryx ships.
123    #[default]
124    Fold,
125    /// A soft break renders as a line break exactly where it was written, so
126    /// the author's source line structure shows on screen unchanged — the mode
127    /// for people who lay out their prose deliberately (one sentence or clause
128    /// per line, semantic line breaks). The break is still a soft break in the
129    /// source; only its rendering changes.
130    Preserve,
131}
132
133/// What the file behind a document looks like right now, against the bytes leaf
134/// last read from it or wrote to it — the question a frontend asks before it
135/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
136/// happen) or when its window regains focus. See [`Doc::disk_state`].
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum DiskState {
139    /// The file holds exactly the bytes leaf last read or wrote.
140    Unchanged,
141    /// Someone else wrote the file since. Saving overwrites their work; see
142    /// [`Doc::reload`] for the other direction.
143    Changed,
144    /// The file is gone — deleted or renamed away. A save recreates it.
145    Missing,
146    /// There is a path, but the file couldn't be read (permissions, a directory
147    /// in the way): leaf can't tell, and won't guess.
148    Unreadable,
149    /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
150    /// changed under a document that was never on disk.
151    Untitled,
152}
153
154/// The inline marks in force at a point in the document — what a toolbar
155/// lights up. A `Copy` bitset rather than a `HashSet`, because
156/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
157/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
158#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
159pub struct InlineMarks(u8);
160
161impl InlineMarks {
162    /// Every kind, in the order [`InlineMarks::iter`] yields them.
163    const ALL: [InlineKind; 8] = [
164        InlineKind::Strong,
165        InlineKind::Emph,
166        InlineKind::Verbatim,
167        InlineKind::Mark,
168        InlineKind::Superscript,
169        InlineKind::Subscript,
170        InlineKind::Insert,
171        InlineKind::Delete,
172    ];
173
174    pub const fn empty() -> Self {
175        InlineMarks(0)
176    }
177
178    /// Private: the set is an *answer*, and adding a mark to it doesn't mark
179    /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
180    fn insert(&mut self, kind: InlineKind) {
181        self.0 |= Self::bit(kind);
182    }
183
184    /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
185    fn flip(&mut self, kind: InlineKind) {
186        self.0 ^= Self::bit(kind);
187    }
188
189    /// The symmetric difference: which marks differ between the two sets. Used
190    /// to resolve the marks already in force at the caret against the pending
191    /// delta — a bit set in the delta flips the base mark for the next keystroke.
192    fn xor(self, other: InlineMarks) -> InlineMarks {
193        InlineMarks(self.0 ^ other.0)
194    }
195
196    /// Whether `kind` is in force — the toolbar's "is Bold active?".
197    pub fn contains(self, kind: InlineKind) -> bool {
198        self.0 & Self::bit(kind) != 0
199    }
200
201    pub fn is_empty(self) -> bool {
202        self.0 == 0
203    }
204
205    /// The marks in force, for a frontend that renders whatever is on rather
206    /// than asking after a fixed list.
207    pub fn iter(self) -> impl Iterator<Item = InlineKind> {
208        Self::ALL.into_iter().filter(move |&k| self.contains(k))
209    }
210
211    fn bit(kind: InlineKind) -> u8 {
212        1 << match kind {
213            InlineKind::Strong => 0,
214            InlineKind::Emph => 1,
215            InlineKind::Verbatim => 2,
216            InlineKind::Mark => 3,
217            InlineKind::Superscript => 4,
218            InlineKind::Subscript => 5,
219            InlineKind::Insert => 6,
220            InlineKind::Delete => 7,
221        }
222    }
223}
224
225impl FromIterator<InlineKind> for InlineMarks {
226    fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
227        let mut m = InlineMarks::empty();
228        for k in iter {
229            m.insert(k);
230        }
231        m
232    }
233}
234
235/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
236/// into one undo step (a run of typed characters undoes together); `Other` never
237/// coalesces, so a paste, format toggle, or block change is always its own step.
238#[derive(Clone, Copy, PartialEq, Eq)]
239enum EditKind {
240    Insert,
241    Delete,
242    /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
243    /// rather than `Insert`'s because a composition is not typing: each step
244    /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
245    /// though no two steps insert the same bytes, and it must not fold into the
246    /// typed characters on either side of it.
247    Compose,
248    Other,
249}
250
251/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
252/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
253/// the caret), `Forward` is Delete (one starting at it).
254#[derive(Clone, Copy)]
255enum BreakEdge {
256    Backward,
257    Forward,
258}
259
260/// A re-spelling of one inline mark run, held ready in case the edit about to
261/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
262/// Every offset in it is in the coordinates the document will have *after* the
263/// plain edit, since that is when it may be applied.
264struct MarkEdgeFix {
265    /// The run's kind, and an offset inside what was its content: together they
266    /// answer "did the plain edit actually break this mark?" — the question that
267    /// decides whether any of this is applied at all.
268    kind: InlineKind,
269    probe: usize,
270    /// The byte range to re-spell (the run's delimiters included) and its new
271    /// spelling, with the edge whitespace moved outside the delimiters.
272    start: usize,
273    end: usize,
274    text: String,
275    /// Where the caret belongs afterwards — the same place on screen it would
276    /// have had, which is now on the other side of a delimiter.
277    caret: usize,
278    /// The marks in force for text typed at that caret. The caret can land
279    /// outside a run it was inside, and the marks have to survive the move or
280    /// the toolbar goes dark mid-word.
281    want: InlineMarks,
282}
283
284/// The caret and selection at one moment — the part of a history step twig's
285/// `Change` cannot carry, because the caret is leaf's state and twig only knows
286/// about bytes. leaf serializes it into the opaque per-state blob twig now
287/// stores in its own undo history (see `record_caret`), so undo and redo hand
288/// back the caret that matches the source they restore.
289#[derive(Clone, Copy)]
290struct CaretState {
291    caret: usize,
292    anchor: Option<usize>,
293}
294
295impl CaretState {
296    /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
297    /// then an anchor-present flag and the anchor. twig copies these bytes and
298    /// never reads them.
299    fn to_blob(self) -> [u8; 17] {
300        let mut b = [0u8; 17];
301        b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
302        if let Some(a) = self.anchor {
303            b[8] = 1;
304            b[9..].copy_from_slice(&(a as u64).to_le_bytes());
305        }
306        b
307    }
308
309    /// Recover a state from twig's blob, or `None` when it is empty or the wrong
310    /// length — a state twig restored that never had a caret set on it, which
311    /// leaves the caller to fall back to the edit site.
312    fn from_blob(b: &[u8]) -> Option<Self> {
313        let b: &[u8; 17] = b.try_into().ok()?;
314        let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
315        let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
316        Some(CaretState { caret, anchor })
317    }
318}
319
320/// A footnote reference and the note it names — the answer to
321/// [`Doc::footnote_at`].
322///
323/// The two `Option`s move together: a reference whose definition is missing has
324/// neither a body to show nor a place to jump to, and one that resolved has
325/// both.
326#[derive(Clone, PartialEq, Eq, Debug)]
327pub struct FootnoteRef {
328    /// The reference's label — the `1` of `[^1]`, with neither the `^` that
329    /// spells it a footnote nor the brackets around it.
330    pub label: String,
331    /// The note's body as source bytes (see
332    /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
333    /// document defines no `[^label]:` to read one from.
334    pub text: Option<String>,
335    /// Where the note's *body* starts, for a "go to note" that moves the caret
336    /// there. `None` alongside a `None` `text`.
337    ///
338    /// The body rather than the definition, because this is an offset to put a
339    /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
340    /// aiming at the definition's first byte snaps to the nearest real stop,
341    /// which is up in the paragraph above the note. It is also simply where a
342    /// reader following a reference wants to land: at the note's first word,
343    /// ready to read or amend it.
344    pub offset: Option<usize>,
345    /// Where the note's body ends, exclusive — so a frontend can ask which
346    /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
347    ///
348    /// The rows are the note with its markup resolved: `see *later*` reaches a
349    /// frontend as an italic run, not as asterisks. `text` is the source bytes
350    /// and stays the honest answer for anything that wants the note as written
351    /// (a search index, a copy); this pair of offsets is for anything that wants
352    /// it as *read*. `None` alongside a `None` `offset`.
353    pub end: Option<usize>,
354}
355
356/// A footnote definition and the reference that sends a reader to it — the
357/// answer to [`Doc::footnote_definition_at`], and the other half of the round
358/// trip [`FootnoteRef`] starts.
359///
360/// A note is a place a reader *arrives*, so the useful thing to know while
361/// standing in one is the way back. Without this the jump to a note is a
362/// one-way door: the definitions sit at the foot of the document, so returning
363/// by hand means scrolling back up and finding the sentence again.
364#[derive(Clone, PartialEq, Eq, Debug)]
365pub struct FootnoteDef {
366    /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
367    /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
368    pub label: String,
369    /// Where the reference's *label* is, for a "back to reference" that moves
370    /// the caret there. `None` for a note nothing refers to — an orphan, which
371    /// is worth being able to say rather than silently doing nothing.
372    ///
373    /// The label rather than the reference's first byte, for
374    /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
375    /// and its label is the only part of it the caret can rest on.
376    ///
377    /// The *first* reference, when a label is cited more than once: a repeated
378    /// citation has no one true home, and the first is both the one a reader
379    /// most likely came from and the only choice that doesn't depend on how
380    /// they got here.
381    pub offset: Option<usize>,
382}
383
384/// Where a locator lands — the answer to [`Doc::locate`].
385///
386/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
387/// document, and a place is a span rather than a point: a reader following one
388/// wants the caret at its first byte, and a reader merely *peeking* at one wants
389/// the block it covers drawn. Both are served by carrying the whole span, and
390/// only one of the two can be recovered from an offset alone.
391#[derive(Clone, PartialEq, Eq, Debug)]
392pub struct Landing {
393    /// The first byte of the block the locator names — where a caret goes.
394    pub start: usize,
395    /// One past its last byte, so a frontend can map the pair through
396    /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
397    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
398    pub end: usize,
399}
400
401/// A selection cited out of the source: the text itself, up to a requested
402/// number of characters either side, and the byte range it came from. See
403/// [`Doc::selection_quote`].
404///
405/// The prefix and suffix are what make the quote *re-findable*: the same text
406/// can occur twice, and a little of what surrounded it is how a later reader —
407/// or the same document after an edit — tells the occurrences apart. The Web
408/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
409/// the name.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct Quote {
412    /// The selected source, verbatim.
413    pub exact: String,
414    /// What immediately preceded it — possibly empty, at the document's start.
415    pub prefix: String,
416    /// What immediately followed it — possibly empty, at the document's end.
417    pub suffix: String,
418    /// Byte offset in the source where the selection begins.
419    pub start: usize,
420    /// Byte offset where it ends (exclusive).
421    pub end: usize,
422}
423
424/// A host-painted range of the source — an annotation's footprint, a search
425/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
426/// glyphs whose source falls inside it) and hands back the `id` when the
427/// reader activates it; what the range *means* is entirely the host's.
428///
429/// Ranges are source bytes, like the caret and the selection, so a host that
430/// anchors quotes against the source ([`Doc::selection_quote`] is the other
431/// half of that loop) can paint what it found without any coordinate
432/// conversion. A range that drifts off the text it meant is the host's to
433/// re-anchor; leaf draws what it is told.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct Highlight {
436    /// Byte offset in the source where the wash begins.
437    pub start: usize,
438    /// Byte offset where it ends (exclusive).
439    pub end: usize,
440    /// The host's name for it, handed back on activation. Opaque to leaf.
441    pub id: String,
442    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
443    /// nothing for the theme's default wash.
444    pub color: Option<String>,
445    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
446    /// a marker gets a small glyph in the margin beside its first line, and
447    /// the glyph — not the wash — is what activates it: the wash is ink, the
448    /// marker is the control, which is what lets a reader put a caret in (or
449    /// copy from) annotated text without a card leaping at them. The name is
450    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
451    /// as a class.
452    pub marker: Option<String>,
453}
454
455impl Highlight {
456    /// The range covering source `offset` in a list [`Doc::set_highlights`]
457    /// sorted, first by start where several overlap — the one place that
458    /// question is answered, for the frontends that paint by asking it as well
459    /// as for [`Doc::highlight_at`].
460    ///
461    /// The list is sorted by `(start, end)`, so the scan can stop at the first
462    /// range starting past `offset` rather than running to the end. A painter
463    /// asking once per glyph wants [`HighlightCursor`] instead; this is the
464    /// one-shot form, for the host asking what the reader just activated.
465    pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
466        highlights
467            .iter()
468            .take_while(|h| h.start <= offset)
469            .find(|h| offset < h.end)
470    }
471}
472
473/// [`Highlight::covering`] for a caller walking the document in order — which
474/// is every painter, since a frontend draws rows top to bottom and glyphs left
475/// to right.
476///
477/// The one-shot form is a scan from the front of the list per glyph, and a
478/// document with two hundred search hits pays that two hundred times a row. A
479/// range that ends at or before an offset can never cover that offset *or any
480/// later one*, so the cursor retires those permanently and each glyph costs the
481/// ranges that actually reach it. The answer is identical to
482/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
483/// the part that was being redone dropped, not a cheaper approximation.
484///
485/// Offsets are expected to arrive non-decreasing. One that goes backwards is
486/// still answered correctly: the cursor re-seats to the front, since a painter
487/// that revisits a row is asking a question the retired ranges may own again.
488pub struct HighlightCursor<'a> {
489    highlights: &'a [Highlight],
490    /// The first range not yet retired.
491    at: usize,
492    /// The last offset asked about, to notice a caller going backwards.
493    last: usize,
494}
495
496impl<'a> HighlightCursor<'a> {
497    pub fn new(highlights: &'a [Highlight]) -> Self {
498        HighlightCursor {
499            highlights,
500            at: 0,
501            last: 0,
502        }
503    }
504
505    /// The range covering `offset`, advancing the cursor past every range that
506    /// can no longer cover anything.
507    pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
508        if offset < self.last {
509            self.at = 0;
510        }
511        self.last = offset;
512        while self
513            .highlights
514            .get(self.at)
515            .is_some_and(|h| h.end <= offset)
516        {
517            self.at += 1;
518        }
519        Highlight::covering(&self.highlights[self.at..], offset)
520    }
521}
522
523pub struct Doc {
524    editor: Editor,
525    pub format: Format,
526    pub path: PathBuf,
527    /// Current source, refreshed from the editor after every successful edit.
528    pub source: String,
529    /// The caret, as a byte offset into `source` (always on a char boundary).
530    pub caret: usize,
531    /// The selection's fixed end, if a selection is active; the moving end is
532    /// the caret. `None` means no selection.
533    pub anchor: Option<usize>,
534    pub dirty: bool,
535    pub status: Option<String>,
536    pub view: View,
537    /// Whether the document refuses to change — a *reading* surface over the
538    /// same rendering, selection, and navigation the editor has.
539    ///
540    /// Enforced here rather than by each frontend hiding its input paths,
541    /// because every mutation funnels through three doors —
542    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
543    /// [`redo`](Self::redo) — and three guarded doors are a guarantee where a
544    /// frontend's suppressed keyboard is a hope. A gated splice reports
545    /// exactly like a rolled-back one, a path every caller already handles.
546    read_only: bool,
547    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
548    /// State like the selection rather than like the text: no edit history,
549    /// no dirty bit, redrawn from whatever the host last set.
550    highlights: Vec<Highlight>,
551    /// How much of the source markup the rich view exposes — a frontend preference (see
552    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
553    /// [`reveal_line`](Self::reveal_line), the editing one by
554    /// [`insert`](Self::insert).
555    markup_mode: MarkupMode,
556    /// Whether soft breaks fold into the reflowed paragraph or render where
557    /// they were written (see [`LineFlow`]) — an independent frontend
558    /// preference the WYSIWYG builder consults when it lays out a block.
559    line_flow: LineFlow,
560    /// The kind of the last edit, for coalescing: twig owns the undo *history*
561    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
562    /// call, so leaf decides when a run continues and tells twig to coalesce.
563    last_edit_kind: Option<EditKind>,
564    /// The inline marks the user has toggled *at a collapsed caret* with no
565    /// selection — "start typing bold here". Held as the XOR delta from the marks
566    /// already in force at [`pending_at`](Self::pending_at): a set bit means
567    /// "flip this kind for the next typed text", so it both turns a mark on where
568    /// none is (type into bold) and off where one already covers the caret (type
569    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
570    /// freshly typed text and then clears it — a mark once realised is carried by
571    /// the caret sitting inside the run, not by this delta.
572    pending_marks: InlineMarks,
573    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
574    /// delta is live only while the caret still stands here with no selection;
575    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
576    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
577    pending_at: Option<usize>,
578    /// The source as of the last open/save — `dirty` is `source != clean_source`,
579    /// so undoing back to the saved state correctly clears the modified flag.
580    clean_source: String,
581    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
582    /// while the document has no file behind it. [`Doc::disk_state`] compares
583    /// the file against this to catch an edit made *outside* leaf before a save
584    /// silently overwrites it — `clean_source` only knows what leaf itself did.
585    ///
586    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
587    /// writes inside one filesystem timestamp tick are indistinguishable, a
588    /// clock that steps backwards (or a writer that restores an mtime) hides a
589    /// real change, and a `touch` invents one. The whole point of the watermark
590    /// is to not clobber someone's work, so it reads the bytes and compares what
591    /// is actually there. That costs a file read per question, which is why the
592    /// question is asked on a user event (focus, save) and not every frame.
593    disk_hash: Option<u64>,
594    /// The "sticky" display column vertical motion aims for, in the active
595    /// view's grid. Set on the first `move_up`/`move_down` of a run and
596    /// reused by every subsequent one in that run, so passing through a
597    /// shorter line doesn't permanently forget the original column. Any
598    /// horizontal motion or edit clears it.
599    ///
600    /// A column, not a character index: dropping down a line of `你好` onto one
601    /// of ASCII has to land under the glyph the caret was drawn beneath, which
602    /// is the only thing the user can see to aim by. Where the goal falls inside
603    /// a wide character on the target line, the mapping resolves it to that
604    /// character — the caret lands on it rather than between its cells.
605    goal_col: Option<usize>,
606    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
607    /// and clicks read it to stay in visible space.
608    pub vmap: VisualMap,
609    /// The syntax map for the source view; empty in the WYSIWYG view, which
610    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
611    /// frontend that never calls it paints raw source unstyled, which is what
612    /// every frontend did before this map existed.
613    pub smap: SourceMap,
614    /// The revision `smap` was built from, or `None` before the first build.
615    /// The map is a pure function of the text alone — no width, no caret, no
616    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
617    /// whole key.
618    smap_key: Option<u64>,
619    /// Everything the map is built from, as one number: bumped whenever the
620    /// document's text changes, and never by a motion, a selection, or a save.
621    /// A frontend can hold work against it — see [`Doc::revision`].
622    revision: u64,
623    /// What `vmap` was built from, or `None` before the first build. The map is
624    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
625    /// moved, rebuilding it produces the identical map — see
626    /// [`Doc::build_visual`].
627    ///
628    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
629    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
630    /// text and width alone, and a caret motion still rebuilds nothing.
631    vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
632    /// Per-block row cache backing the incremental rebuild: when the text
633    /// changes, only the top-level blocks whose bytes moved are re-rendered and
634    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
635    /// builds; a pure accelerator, so it's never read for correctness.
636    block_cache: wysiwyg::BlockCache,
637    /// How many visual rows each block image reserves, keyed by its destination —
638    /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
639    /// measured the pictures. Core does no image I/O, so this is the only way it
640    /// learns a picture's height; a destination not in the map reserves the bare
641    /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
642    /// sizes each placeholder, and folded into `vmap_key` so a height change
643    /// rebuilds the map.
644    media_rows: HashMap<String, usize>,
645
646    // View geometry the renderer stamps each frame, so mouse events can map a
647    // screen cell back to a byte offset.
648    pub scroll: usize,
649    pub body_origin: (u16, u16),
650    /// Width of the body rectangle last painted by the frontend. Zero means
651    /// unknown (used by tests or a frontend that has not drawn yet).
652    pub body_width: u16,
653    pub body_height: u16,
654    /// The caret as of the last frame drawn, or `None` before the first.
655    ///
656    /// Scrolling is the viewport's business, not the caret's: the view follows
657    /// the caret when the caret *moves*, but a wheel that doesn't touch the
658    /// caret has to be free to scroll away from it — otherwise the view is
659    /// pinned to the caret and stops dead at the edge of the document you can
660    /// see. Comparing against this is what tells the two apart, and it catches a
661    /// caret set by any route, including a frontend assigning the field itself.
662    pub drawn_caret: Option<usize>,
663}
664
665/// The Markdown extensions every leaf document is parsed with. `html_elements`
666/// and `directives` depart from twig's defaults. `html_elements` promotes
667/// embedded raw HTML (`<img>`, `<picture>`, `<source>`, …) into semantic AST
668/// nodes, so a picture becomes a real `image` node the frontends can frame and
669/// rasterize instead of opaque `raw_block` text. `directives` turns on generic
670/// `:::name{.class}` fenced-div containers (`directive` nodes), which a host
671/// app uses for its own semantics (diaryx's `:::vis{.audience}` visibility
672/// blocks) — core renders any directive as a plain tinted container, agnostic
673/// of `name`. Both flags are inert for non-Markdown formats, so it's safe to
674/// pass them unconditionally. Threading this through every constructor (not
675/// just `open`) keeps `from_source`, `blank`, and `reload` parsing the same
676/// document the same way — twig reparses with these same flags after each edit.
677fn parse_extensions() -> MarkdownExtensions {
678    MarkdownExtensions {
679        html_elements: true,
680        directives: true,
681        ..Default::default()
682    }
683}
684
685/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
686/// mapping twig's error into the `anyhow` context every constructor shares.
687fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
688    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
689}
690
691/// Does `format` spell a table as a **pipe table** — the one grid twig's table
692/// editor knows how to emit?
693///
694/// This is the single capability leaf still has to answer for itself, and the
695/// only hand-maintained format list left in this file. Every other gesture is
696/// [`Format::supports`], which is twig's own answer read across the C ABI — but
697/// twig deliberately leaves the table ops out of that query, because they read
698/// no `Syntax` table at all. They rewrite a grid that is already in the source
699/// and refuse on *position*, never on format. Handed a caret inside an HTML
700/// `<table>`, `table_insert_row` therefore re-emits the whole element as
701/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
702/// `dirty` flag, and nothing downstream able to tell it from a good edit.
703///
704/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
705/// wildcard answers "no" for a format leaf has never heard of: a new twig
706/// language that *does* spell pipe tables loses its grid controls until this
707/// line is updated, which shows up as a missing button. The other default hands
708/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
709fn spells_pipe_tables(format: Format) -> bool {
710    matches!(format, Format::Markdown | Format::Djot)
711}
712
713/// Which of leaf's authoring controls this document's format can actually
714/// spell — one flag per toolbar button, resolved once so a frontend can build
715/// its chrome instead of discovering each refusal on a click.
716///
717/// Every field but [`table`](Self::table) is `Format::supports` on the gesture
718/// the matching [`Doc`] method calls, so this record cannot drift from what the
719/// ops do; `table` is [`spells_pipe_tables`], the one answer twig doesn't
720/// export.
721///
722/// **The formats are ragged, and that is the point.** A single per-document
723/// boolean was enough while the two authorable formats were Markdown and djot
724/// and everything else spelled nothing. HTML is neither: it writes seven of the
725/// eight inline marks as a tag pair, plus `<code>`, `<hr>` and an in-cell
726/// `<br>`, and spells no heading marker, no line prefix, no fence, no task box,
727/// no link — because its versions of those have a different *shape*, not a
728/// different alphabet. So ⌘B works in an HTML document and ⌘1 does not, and no
729/// one flag can say that. Markdown and djot differ from each other too:
730/// `==mark==` is djot-only, and an in-cell `<br>` is Markdown-only.
731#[derive(Clone, Copy, Debug, Eq, PartialEq)]
732pub struct Capabilities {
733    /// ⌘B — `InlineKind::Strong`.
734    pub bold: bool,
735    /// ⌘I — `InlineKind::Emph`.
736    pub italic: bool,
737    /// Inline code — `InlineKind::Verbatim`.
738    pub code: bool,
739    /// Highlight — `InlineKind::Mark`. Djot spells it; Markdown does not.
740    pub mark: bool,
741    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
742    pub underline: bool,
743    /// Strikethrough — `InlineKind::Delete`.
744    pub strike: bool,
745    pub superscript: bool,
746    pub subscript: bool,
747    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
748    pub heading: bool,
749    pub blockquote: bool,
750    pub bullet_list: bool,
751    pub ordered_list: bool,
752    /// The checkbox controls: giving an item a box, and ticking one.
753    pub task: bool,
754    pub link: bool,
755    /// Covers [`Doc::insert_media`] too — see the note there on why the three
756    /// media kinds stand or fall together.
757    pub image: bool,
758    /// The horizontal-rule button. HTML spells this one (`<hr>`).
759    pub thematic_break: bool,
760    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
761    /// the pair; HTML has no footnote of its own, so the button goes away rather
762    /// than writing brackets that would render as brackets.
763    pub footnote: bool,
764    /// Setting a fenced block's language — a control only ever offered with the
765    /// caret already in a fence.
766    pub code_language: bool,
767    /// The grid controls: insert/delete/move a row or column, set a column's
768    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
769    /// question — an HTML `<table>` holds the caret and still can't be edited.
770    pub table: bool,
771    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
772    /// idiomatic in-cell break.
773    pub cell_line_break: bool,
774}
775
776impl Capabilities {
777    /// Resolve every flag for `format`. Pure and cheap — twig computes each from
778    /// a static table — but a frontend that wants to hold them can.
779    pub fn of(format: Format) -> Self {
780        let inline = |k| format.supports(Gesture::ToggleInline(k));
781        let container = |k| format.supports(Gesture::ToggleBlockContainer(k));
782        Self {
783            bold: inline(InlineKind::Strong),
784            italic: inline(InlineKind::Emph),
785            code: inline(InlineKind::Verbatim),
786            mark: inline(InlineKind::Mark),
787            underline: inline(InlineKind::Insert),
788            strike: inline(InlineKind::Delete),
789            superscript: inline(InlineKind::Superscript),
790            subscript: inline(InlineKind::Subscript),
791            heading: format.supports(Gesture::SetBlock),
792            blockquote: container(BlockContainerKind::BlockQuote),
793            bullet_list: container(BlockContainerKind::BulletList),
794            ordered_list: container(BlockContainerKind::OrderedList),
795            // Both halves of the checkbox story, and leaf offers no control that
796            // needs only one: the item gesture mints the box, the checked one
797            // ticks it, and a format spelling a `task_marker` spells both.
798            task: format.supports(Gesture::ToggleTaskItem)
799                && format.supports(Gesture::ToggleTaskChecked),
800            link: format.supports(Gesture::InsertLink),
801            image: format.supports(Gesture::InsertImage),
802            thematic_break: format.supports(Gesture::InsertThematicBreak),
803            footnote: format.supports(Gesture::InsertFootnote),
804            code_language: format.supports(Gesture::SetCodeLanguage),
805            table: spells_pipe_tables(format),
806            cell_line_break: format.supports(Gesture::InsertLineBreak),
807        }
808    }
809}
810
811impl Doc {
812    #[cfg(feature = "fs")]
813    pub fn open(path: PathBuf) -> Result<Self> {
814        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
815        Self::from_disk_bytes(path, bytes)
816    }
817
818    /// An empty document *named* `path`, for a file that isn't there yet — what
819    /// every other terminal editor gives you when you name a file that doesn't
820    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
821    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
822    /// the header shows the name the user asked for.
823    ///
824    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
825    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
826    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
827    /// parse is still an error: a mistyped flag or a stray argument should say
828    /// so, not open a buffer promising to save somewhere.
829    ///
830    /// The watermark is the hash of *no bytes*, not `None`, and that is the
831    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
832    /// answering [`DiskState::Untitled`] for a document that has a path and
833    /// intends to write to it. Hashing `""` instead makes the answers the true
834    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
835    /// recreates it, which is exactly what this is for), and
836    /// [`DiskState::Changed`] if somebody creates it underneath us between
837    /// launch and save, so the frontend's overwrite prompt guards a new file as
838    /// it guards an opened one.
839    ///
840    /// Nothing is written here. A buffer that is never typed into never touches
841    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
842    /// open — the write is where that fails, and it says so then.
843    #[cfg(feature = "fs")]
844    pub fn create(path: PathBuf) -> Result<Self> {
845        Self::from_disk_bytes(path, Vec::new())
846    }
847
848    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
849    /// the call a CLI frontend wants for its path argument.
850    ///
851    /// The decision is made from the failed read itself rather than a `exists()`
852    /// check first, so there is no window between the two for the file to appear
853    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
854    /// a directory in the way is still an error, because pretending those are
855    /// "no file yet" would offer to save over something leaf couldn't read.
856    #[cfg(feature = "fs")]
857    pub fn open_or_create(path: PathBuf) -> Result<Self> {
858        match std::fs::read(&path) {
859            Ok(bytes) => Self::from_disk_bytes(path, bytes),
860            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
861            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
862        }
863    }
864
865    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
866    /// stand in for) the file at `path`, parsed as the format its extension
867    /// names. Keeping the two on one path is what makes a new file's document
868    /// identical in every respect to an opened one but its contents.
869    #[cfg(feature = "fs")]
870    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
871        let format = detect_format(&path)?;
872        let editor = new_editor(&bytes, format)?;
873        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
874        let disk_hash = Some(hash_bytes(source.as_bytes()));
875        // Store the document's *absolute* path. A relative one (`leaf README.md`)
876        // has an empty parent, so a frontend can't resolve a relative image
877        // destination (`![](pic.png)`) against the document's directory and the
878        // picture silently falls back to its text placeholder. `absolute` is
879        // purely lexical — it prefixes the current directory and normalizes, but
880        // reads nothing and resolves no symlinks — so `file_name` and save are
881        // unchanged; it only gives `path.parent()` something to join against.
882        let path = std::path::absolute(&path).unwrap_or(path);
883        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
884    }
885
886    /// Build a document from an in-memory string, the format named explicitly —
887    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
888    /// path and sniffs the format from its extension). A wasm or FFI host, which
889    /// has no path to read, uses this: it hands over bytes it fetched however it
890    /// could, and later persists [`Doc::source`] however it can (a browser
891    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
892    ///
893    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
894    /// true) exactly like a [`Doc::blank`] that has been given content.
895    pub fn from_source(source: String, format: Format) -> Result<Self> {
896        let editor = new_editor(source.as_bytes(), format)?;
897        Ok(Doc::from_parts(
898            editor,
899            format,
900            PathBuf::new(),
901            source,
902            None,
903        ))
904    }
905
906    /// An untitled, empty document — the `+` button and a `leaf` launched with
907    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
908    ///
909    /// It is Markdown, because a format has to be chosen before a name exists to
910    /// read one from: `detect_format` reads the extension and an untitled
911    /// document has neither. Markdown is what leaf's own files are, what its
912    /// block markers are already written for (`insert_block_prefix`), and the
913    /// extension a Save As will overwhelmingly pick — a wrong guess here would
914    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
915    /// *doesn't* revisit this: see [`Doc::save_as`].
916    pub fn blank() -> Result<Self> {
917        let format = Format::Markdown;
918        let editor = new_editor(b"", format)?;
919        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
920        // field two frontends already read; making it an `Option` to say this
921        // would break both). `is_untitled` is the question to ask, not the
922        // representation to copy.
923        Ok(Doc::from_parts(
924            editor,
925            format,
926            PathBuf::new(),
927            String::new(),
928            None,
929        ))
930    }
931
932    /// The fields every constructor agrees on, so `open` and `blank` can't drift
933    /// apart in the ones neither of them has an opinion about.
934    fn from_parts(
935        editor: Editor,
936        format: Format,
937        path: PathBuf,
938        source: String,
939        disk_hash: Option<u64>,
940    ) -> Self {
941        Doc {
942            editor,
943            format,
944            path,
945            disk_hash,
946            clean_source: source.clone(),
947            source,
948            caret: 0,
949            anchor: None,
950            dirty: false,
951            status: None,
952            read_only: false,
953            highlights: Vec::new(),
954            // leaf opens in the rich-text (WYSIWYG) view by default — the
955            // markup-resolved surface is leaf's differentiator. Frontends can
956            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
957            // toggles at runtime.
958            view: View::Wysiwyg,
959            // `None` by default — the clean surface Diaryx ships, with typed
960            // syntax kept literal; a markup-fluent frontend can climb the
961            // ladder to `Shortcuts` or `Full`.
962            markup_mode: MarkupMode::default(),
963            // Fold by default — flowing prose that reflows to the viewport, the
964            // behaviour every frontend had before this preference existed.
965            line_flow: LineFlow::default(),
966            last_edit_kind: None,
967            pending_marks: InlineMarks::empty(),
968            pending_at: None,
969            goal_col: None,
970            vmap: VisualMap::default(),
971            smap: SourceMap::default(),
972            // No map yet — the first `build_source` always builds.
973            smap_key: None,
974            revision: 0,
975            // No map yet — the first `build_visual` always builds.
976            vmap_key: None,
977            block_cache: wysiwyg::BlockCache::default(),
978            media_rows: HashMap::new(),
979            scroll: 0,
980            body_origin: (0, 0),
981            body_width: 0,
982            body_height: 0,
983            drawn_caret: None,
984        }
985    }
986
987    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
988    /// has never been saved. The question a ⌘S handler asks to know it should
989    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
990    /// header asks to know the name it shows is a placeholder.
991    pub fn is_untitled(&self) -> bool {
992        self.path.as_os_str().is_empty()
993    }
994
995    pub fn toggle_view(&mut self) {
996        self.view = match self.view {
997            View::Source => View::Wysiwyg,
998            View::Wysiwyg => View::Source,
999        };
1000        self.scroll = 0;
1001        self.status = None;
1002        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1003        // lift it to the first rendered offset.
1004        self.clamp_caret();
1005    }
1006
1007    /// The current markup-exposure preference (see [`MarkupMode`]).
1008    pub fn markup_mode(&self) -> MarkupMode {
1009        self.markup_mode
1010    }
1011
1012    /// Set the markup-exposure preference. Both of its axes take effect at
1013    /// once: the editing one on the next [`insert`](Self::insert), and the
1014    /// rendering one on the next build — which is why this drops the cached
1015    /// visual map and the per-block render cache, exactly as
1016    /// [`set_line_flow`](Self::set_line_flow) does.
1017    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1018        if self.markup_mode == mode {
1019            return;
1020        }
1021        self.markup_mode = mode;
1022        // Neither cache is keyed on the mode, and moving between `Full` and the
1023        // hidden modes changes every row the caret's line renders to — so
1024        // invalidate both explicitly.
1025        self.vmap_key = None;
1026        self.block_cache = wysiwyg::BlockCache::default();
1027    }
1028
1029    /// The source byte range of the line the caret sits on, when that line
1030    /// should render its raw delimiters — `None` in every mode and view that
1031    /// hides them, which is what the builder reads as "reveal nothing".
1032    ///
1033    /// A *source* line (newline to newline), not a visual row: a wrapped
1034    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1035    /// line across several rows, and revealing half a delimiter pair because the
1036    /// other half wrapped would be worse than revealing neither. The range
1037    /// excludes the terminating newline and is empty-but-present on a blank
1038    /// line, which reveals nothing but still keys the caches correctly.
1039    ///
1040    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1041    /// there is nothing there to reveal.
1042    pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1043        if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1044            return None;
1045        }
1046        Some(source_line_range(&self.source, self.caret))
1047    }
1048
1049    /// The current soft-break flow preference (see [`LineFlow`]).
1050    pub fn line_flow(&self) -> LineFlow {
1051        self.line_flow
1052    }
1053
1054    /// Set the soft-break flow preference. The mode changes how every block lays
1055    /// out, so a change drops the cached visual map and the per-block render
1056    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1057    ///
1058    /// [`build_visual`]: Self::build_visual
1059    pub fn set_line_flow(&mut self, mode: LineFlow) {
1060        if self.line_flow == mode {
1061            return;
1062        }
1063        self.line_flow = mode;
1064        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1065        // so invalidate them explicitly, or the next build would reuse rows laid
1066        // out under the old flow.
1067        self.vmap_key = None;
1068        self.block_cache = wysiwyg::BlockCache::default();
1069    }
1070
1071    pub fn view_name(&self) -> &'static str {
1072        match self.view {
1073            View::Source => "source",
1074            View::Wysiwyg => "wysiwyg",
1075        }
1076    }
1077
1078    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1079    /// (called by the renderer each frame it's in the WYSIWYG view).
1080    /// Build the WYSIWYG map, wrapped at `width` display columns.
1081    ///
1082    /// Cheap to call every frame, which is what both frontends do: the map is a
1083    /// pure function of the document and the wrap width, so a call that would
1084    /// rebuild the same map returns the one already built. Only an edit (or a
1085    /// resize) pays.
1086    ///
1087    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1088    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1089    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1090    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1091    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1092    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1093    pub fn build_visual(&mut self, width: usize) {
1094        self.build_map(Some(width));
1095    }
1096
1097    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1098    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1099    /// than a fixed character column.
1100    pub fn build_visual_unwrapped(&mut self) {
1101        self.build_map(None);
1102    }
1103
1104    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1105    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1106    /// styling for [`View::Wysiwyg`].
1107    ///
1108    /// A frontend calls this before painting raw source. One that doesn't gets
1109    /// an empty map and paints unstyled text, so this is additive: nothing
1110    /// breaks by not calling it.
1111    ///
1112    /// Built at most once per revision, and the revision is the whole key — the
1113    /// map has no width and no caret in it, so it survives every resize, every
1114    /// motion, and every selection change.
1115    ///
1116    /// The builds it does do cost a whole-arena marshal, which is precisely what
1117    /// the WYSIWYG path works to avoid, so this has no incremental path where
1118    /// that one has two. From `cargo run --release -p leaf-core --example
1119    /// bench`, per keystroke, against the WYSIWYG build the source view is
1120    /// *not* doing:
1121    ///
1122    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1123    /// |------:|-------:|--------:|----------------:|-------------------:|
1124    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1125    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1126    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1127    ///
1128    /// Linear, two thirds of it the marshal, and the build itself five to seven
1129    /// times cheaper than the one it stands in for at every size. Comfortable
1130    /// well past any document a person edits in a terminal — a megabyte is where
1131    /// it would want [`Editor::dirty_range`] and the same splice treatment
1132    /// `build_spliced` gives the other map. The door is open; nothing has needed
1133    /// it yet.
1134    pub fn build_source(&mut self) {
1135        if self.smap_key == Some(self.revision) {
1136            return;
1137        }
1138        let nodes = self.nodes();
1139        self.smap = source::build(&nodes, &self.source);
1140        self.smap_key = Some(self.revision);
1141    }
1142
1143    /// Tell the model how many visual rows each block image should reserve, keyed
1144    /// by the image's destination. A terminal frontend calls this once it has
1145    /// decoded and measured its pictures — core does no image I/O, so this is the
1146    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1147    /// placeholder out that tall (the label row plus blank filler rows the
1148    /// frontend paints the raster over). A destination left out of the map falls
1149    /// back to the bare one-row placeholder, which is also what a frontend that
1150    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1151    /// by never calling this.
1152    ///
1153    /// Cheap to call every frame with the same map: only a *change* invalidates
1154    /// the built map (and the block-row cache, since a height isn't part of a
1155    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1156    /// no-op, so a frontend can just hand over its current measurements each frame.
1157    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1158        if self.media_rows == rows {
1159            return;
1160        }
1161        self.media_rows = rows;
1162        // A height lives outside the block's source bytes, so the content-keyed
1163        // block cache would hand back the old-height rows on a hit. Drop it (and
1164        // the splice layout it carries) so the next build re-renders every block
1165        // at the new heights, and force that build by clearing the map key.
1166        self.block_cache = wysiwyg::BlockCache::default();
1167        self.vmap_key = None;
1168    }
1169
1170    /// The revision the document's text is at — bumped by every edit, undo,
1171    /// redo, and reload, and by nothing else. A frontend caches against this to
1172    /// tell a repaint that needs new work from one that doesn't.
1173    ///
1174    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1175    /// lands on the same text two revisions later. Work is only ever rebuilt
1176    /// needlessly, never wrongly reused.
1177    pub fn revision(&self) -> u64 {
1178        self.revision
1179    }
1180
1181    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1182    /// runs on every call: the caret moves without the document changing, and
1183    /// keeping it on a legal stop is this function's job either way.
1184    fn build_map(&mut self, wrap: Option<usize>) {
1185        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1186        // as well as the text, so the line joins the key: moving within a line
1187        // still reuses the map, and crossing into another one rebuilds it. In
1188        // every other mode `reveal_line` is `None` and the key is what it was,
1189        // so caret motion goes on costing nothing.
1190        let reveal = self.reveal_line();
1191        let key = (self.revision, wrap, reveal.clone());
1192        if self.vmap_key.as_ref() != Some(&key) {
1193            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1194            // A subtree is pulled only for the block(s) that actually changed, so
1195            // the FFI marshal shrinks from O(document) to O(edited block).
1196            let top = self.top_blocks();
1197
1198            // Fast path: when twig reports a dirty byte range, try to patch the
1199            // previous map in place — a single-block edit moves the prefix,
1200            // shifts the suffix, and re-renders only one block. `build_spliced`
1201            // returns `None` (and we fall back to the always-correct full rebuild)
1202            // whenever the edit reshaped the block structure, hit a table, or
1203            // there's no previous map to patch.
1204            // Preserve soft breaks as written when the flow preference asks for
1205            // it — the builder renders each as its own visual row instead of
1206            // folding it into the reflowed paragraph.
1207            let preserve_soft = self.line_flow == LineFlow::Preserve;
1208            let spliced = match self.editor.dirty_range() {
1209                Some(dirty) => {
1210                    let prev = std::mem::take(&mut self.vmap);
1211                    let source = &self.source;
1212                    let cache = &mut self.block_cache;
1213                    let media_rows = &self.media_rows;
1214                    let editor = &mut self.editor;
1215                    wysiwyg::build_spliced(
1216                        prev,
1217                        source,
1218                        wrap,
1219                        preserve_soft,
1220                        &top,
1221                        dirty,
1222                        media_rows,
1223                        reveal.clone(),
1224                        cache,
1225                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1226                    )
1227                }
1228                None => None,
1229            };
1230            self.vmap = spliced.unwrap_or_else(|| {
1231                let source = &self.source;
1232                let cache = &mut self.block_cache;
1233                let media_rows = &self.media_rows;
1234                let editor = &mut self.editor;
1235                wysiwyg::build_cached(
1236                    &top,
1237                    source,
1238                    wrap,
1239                    preserve_soft,
1240                    media_rows,
1241                    reveal,
1242                    cache,
1243                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1244                )
1245            });
1246            // Acknowledge the dirty range so the next edit's range starts fresh.
1247            self.editor.clear_dirty();
1248            self.vmap_key = Some(key);
1249        }
1250        self.clamp_caret();
1251    }
1252
1253    fn nodes(&mut self) -> Vec<FlatNode> {
1254        self.editor.nodes().unwrap_or_default()
1255    }
1256
1257    /// The document's top-level blocks for the incremental render. See
1258    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1259    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1260        wysiwyg::top_blocks(&mut self.editor)
1261    }
1262
1263    pub fn format_name(&self) -> &'static str {
1264        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1265        // required. It also covers `Asciidoc`, which twig parses but cannot
1266        // serialize — leaf never opens a document in it (see `Doc::open`).
1267        match self.format {
1268            Format::Djot => "djot",
1269            Format::Markdown => "markdown",
1270            Format::Xml => "xml",
1271            Format::Html => "html",
1272            _ => "unknown",
1273        }
1274    }
1275
1276    /// Whether this document's format offers *any* door in — `false` only for a
1277    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1278    /// a frontend may as well open the file read-only.
1279    ///
1280    /// This is a much weaker claim than the name suggests, and driving per-button
1281    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1282    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1283    /// while a heading, a quote, a list, a task box, a link and a code fence all
1284    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1285    /// [`supports`](Self::supports) — per control.
1286    pub fn authorable(&self) -> bool {
1287        self.format.is_authorable()
1288    }
1289
1290    /// Whether this document's format can spell `gesture`, which is twig's own
1291    /// answer rather than a copy of it: `Format::supports` reads the same
1292    /// `Syntax` table the `Editor` method consults before refusing.
1293    ///
1294    /// It is a fact about the *format*, not about the caret. `true` does not
1295    /// promise the gesture succeeds where it is standing — a link over a table
1296    /// border still fails — only that it will not fail with
1297    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1298    /// will work here".
1299    pub fn supports(&self, gesture: Gesture) -> bool {
1300        self.format.supports(gesture)
1301    }
1302
1303    /// Every control's enabled state in one read — what a toolbar builds itself
1304    /// from when a document opens or its format changes. See [`Capabilities`].
1305    pub fn capabilities(&self) -> Capabilities {
1306        Capabilities::of(self.format)
1307    }
1308
1309    /// Refuse a gesture this document's format cannot spell, saying so in the
1310    /// status line. `true` means the caller must return without calling twig.
1311    ///
1312    /// Most of these refusals duplicate one twig would make anyway, and they are
1313    /// made here regardless because a message naming the *document's* format
1314    /// reads better than one naming twig's internals. Two of them are not
1315    /// duplicates and are the reason this is a guard rather than an error
1316    /// translation:
1317    ///
1318    /// - The table family (see [`table_op`](Self::table_op)) consults no
1319    ///   `Syntax` table, so twig does not refuse it at all.
1320    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1321    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1322    ///   could not keep.
1323    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1324        self.refuse_unless(what, self.supports(gesture))
1325    }
1326
1327    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1328    /// answers itself — today only [`spells_pipe_tables`].
1329    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1330        if supported {
1331            return false;
1332        }
1333        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1334        true
1335    }
1336
1337    /// The name to show for this document. An untitled one has no file to name
1338    /// it, and both frontends put this straight on screen — an empty path
1339    /// renders as an empty header, so it says so instead.
1340    pub fn file_name(&self) -> String {
1341        if self.is_untitled() {
1342            return "untitled".into();
1343        }
1344        self.path
1345            .file_name()
1346            .map(|s| s.to_string_lossy().into_owned())
1347            .unwrap_or_else(|| self.path.display().to_string())
1348    }
1349
1350    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1351    /// caret and anchor coincide (an empty selection is no selection).
1352    pub fn selection(&self) -> Option<(usize, usize)> {
1353        self.anchor
1354            .map(|a| (a.min(self.caret), a.max(self.caret)))
1355            .filter(|(s, e)| s != e)
1356    }
1357
1358    /// The selected text, or `None` when there's no selection — the source
1359    /// slice a copy/cut hands to the system clipboard.
1360    pub fn selected_text(&self) -> Option<&str> {
1361        self.selection().map(|(s, e)| &self.source[s..e])
1362    }
1363
1364    /// The selection as a quote with a little of what surrounds it — the shape
1365    /// a host that cites, annotates, or searches for a passage wants, cut from
1366    /// the **source** rather than from anything rendered, so the quote is
1367    /// findable in the document again by plain string search.
1368    ///
1369    /// `context` is a count of characters (not bytes) on each side, clipped at
1370    /// the document's edges; the slices land on char boundaries by
1371    /// construction. `None` when nothing is selected.
1372    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1373        let (start, end) = self.selection()?;
1374        let mut before = start;
1375        for _ in 0..context {
1376            match self.source[..before].chars().next_back() {
1377                Some(c) => before -= c.len_utf8(),
1378                None => break,
1379            }
1380        }
1381        let mut after = end;
1382        for _ in 0..context {
1383            match self.source[after..].chars().next() {
1384                Some(c) => after += c.len_utf8(),
1385                None => break,
1386            }
1387        }
1388        Some(Quote {
1389            exact: self.source[start..end].to_string(),
1390            prefix: self.source[before..start].to_string(),
1391            suffix: self.source[end..after].to_string(),
1392            start,
1393            end,
1394        })
1395    }
1396
1397    /// Whether the document refuses to change — see the field.
1398    pub fn read_only(&self) -> bool {
1399        self.read_only
1400    }
1401
1402    /// Turn the read-only gate on or off. A frontend preference like
1403    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1404    /// itself changes, only what may be done to it from here on.
1405    pub fn set_read_only(&mut self, on: bool) {
1406        self.read_only = on;
1407    }
1408
1409    /// The host-painted ranges, sorted by start — see [`Highlight`].
1410    pub fn highlights(&self) -> &[Highlight] {
1411        &self.highlights
1412    }
1413
1414    /// Replace the host-painted ranges wholesale. The whole set each time,
1415    /// rather than add/remove verbs: the host owns the list (it derives it
1416    /// from its own state — annotations, search hits), and a replace can
1417    /// never leave the two disagreeing about what should be on screen.
1418    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1419        highlights.retain(|h| h.start < h.end);
1420        highlights.sort_by_key(|h| (h.start, h.end));
1421        self.highlights = highlights;
1422    }
1423
1424    /// The highlight covering source `offset`, if one does — first by start
1425    /// when several overlap, which makes overlapping washes resolvable rather
1426    /// than undefined. What a frontend asks when the reader activates a spot.
1427    ///
1428    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1429    /// asking the same question per glyph, against a slice they were handed
1430    /// rather than against a `Doc`, and one answer for both is what keeps a
1431    /// wash and an activation agreeing about which range a spot is in.
1432    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1433        Highlight::covering(&self.highlights, offset)
1434    }
1435
1436    /// The AST breadcrumb at the caret (root → deepest), e.g.
1437    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1438    pub fn breadcrumb(&mut self) -> String {
1439        match self.editor.ancestors_at(self.caret) {
1440            Ok(chain) => chain
1441                .iter()
1442                .map(|m| m.kind.as_str())
1443                .collect::<Vec<_>>()
1444                .join(" › "),
1445            Err(_) => String::new(),
1446        }
1447    }
1448
1449    // ── editing ──────────────────────────────────────────────────────────────
1450
1451    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1452    /// after it. The public form of the internal splice — a pixel frontend that
1453    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1454    /// edits through this, the same twig `edit_range` the caret ops use.
1455    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1456        self.splice(start, end, text, EditKind::Other);
1457    }
1458
1459    /// Insert typed `text` at the caret, replacing the selection if there is one.
1460    /// A single typed character coalesces with the run of typing before it; a
1461    /// newline or a multi-character insert is its own undo step.
1462    ///
1463    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1464    pub fn insert(&mut self, text: &str) {
1465        // Typing against a block picture would dissolve it — see
1466        // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1467        // what the caret was standing beside stays a picture.
1468        self.open_paragraph_at_block_media(text);
1469        // Armed sticky marks (⌘b with no selection) turn the next typed text
1470        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1471        // the exception: it takes no mark of its own and keeps the delta armed
1472        // for the character behind it — see `insert_space_with_marks`.
1473        let pending = self.pending_here();
1474        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1475            if text.trim().is_empty() {
1476                self.insert_space_with_marks(self.caret, text, pending);
1477            } else {
1478                self.insert_with_marks(self.caret, text, pending);
1479            }
1480            return;
1481        }
1482        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1483        // anything that would open markup, so a Diaryx user never mints
1484        // formatting by keyboard (it comes from commands instead). The other two
1485        // rungs of the ladder author markup from what you type, which is the
1486        // whole difference between them and this one. Only in the rendered view
1487        // (source view is for typing raw markup) and only where the format has a
1488        // literal spelling at all: escaping is a backslash before a byte from the
1489        // format's own alphabet, and a format with no such alphabet (HTML escapes
1490        // with entities, XML spells nothing) would have `\&` written into it,
1491        // which is two literal characters and not an escape. Marks (⌘b) still
1492        // format — that path returned above; and leaf's own structural inserts go
1493        // through `insert_raw`, never here, so a list marker or quote gutter is
1494        // written as the markup it is.
1495        if !self.markup_mode.authors()
1496            && self.view == View::Wysiwyg
1497            && !text.is_empty()
1498            && self.supports(Gesture::InsertLiteral)
1499        {
1500            self.insert_literal_typed(text);
1501            return;
1502        }
1503        self.insert_raw(text);
1504    }
1505
1506    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1507    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1508    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1509    /// markup by design and must not be escaped.
1510    fn insert_raw(&mut self, text: &str) {
1511        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1512        self.splice(s, e, text, typed_edit_kind(text));
1513    }
1514
1515    /// Open a paragraph for text about to be inserted at one of a block media's
1516    /// two caret stops, and leave the caret standing in it.
1517    ///
1518    /// A block image is a paragraph whose entire content is the picture, and the
1519    /// caret's only homes on it are in front of it and just past it (see
1520    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1521    /// *that* paragraph — and a paragraph holding anything besides the image is
1522    /// no longer a block image but a line of text with an inline one in it. The
1523    /// frontend that was painting a photo there paints a text run instead; the
1524    /// picture is still in the file, and nothing said a word. Those two offsets
1525    /// are also exactly where a click on the picture lands, so the whole accident
1526    /// is one tap and one keystroke.
1527    ///
1528    /// So the break goes in first and the text lands in the new empty paragraph —
1529    /// what pressing Return before typing would have done, which is a habit no
1530    /// one should have to learn from losing a photo. A no-op everywhere else, and
1531    /// over a selection (which is replaced, not joined into).
1532    ///
1533    /// A picture inside a quote or a list leaves its container, because `\n\n`
1534    /// ends the block. The alternative is worse: the `\n> ` / next-item
1535    /// continuation [`newline`](Self::newline) writes stays in the same
1536    /// *paragraph*, which is the thing being prevented.
1537    ///
1538    /// Only in the rendered view. Source view is for typing raw markup, where
1539    /// putting a character against an image is exactly what it looks like.
1540    fn open_paragraph_at_block_media(&mut self, text: &str) {
1541        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1542            return;
1543        }
1544        if self.selection().is_some() {
1545            return;
1546        }
1547        // The map may be a revision behind (nothing has drawn since the last
1548        // edit), and this asks it about offsets — a stale answer would splice a
1549        // break into the wrong place. Free when it is already current, which it
1550        // is whenever a frontend drew a frame between keystrokes.
1551        self.rebuild_map();
1552        let at = self.caret;
1553        let Some((side, _)) = self.vmap.block_media_stop(at) else {
1554            return;
1555        };
1556        if !self.splice(at, at, "\n\n", EditKind::Other) {
1557            return;
1558        }
1559        // The break is part of the keystroke, not an edit of its own: leave the
1560        // run marked as typing so the character about to arrive folds into it and
1561        // one undo puts the document back the way it was found. (A paste, or a
1562        // multi-character insert, is `EditKind::Other` and stays its own step —
1563        // as it would have been anywhere else in the document.)
1564        self.last_edit_kind = Some(EditKind::Insert);
1565        if side == MediaStop::Before {
1566            // The break went in above the picture and the caret rode to the end
1567            // of it — which is still hard against the picture. Step back onto the
1568            // blank line it opened, so the text lands above rather than in front.
1569            self.caret = at;
1570        }
1571    }
1572
1573    /// A delete key pressed at one of a block picture's two caret stops, handled
1574    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1575    /// the key was consumed.
1576    ///
1577    /// The caret rests in front of a block image and just past it, never inside
1578    /// its markup — which the rendered view doesn't show. So the byte a delete
1579    /// key nominally takes there is one the writer cannot see, and taking it
1580    /// leaves the picture as broken markup rather than as anything anyone asked
1581    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1582    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1583    /// missing from a document with nobody having touched it — the same
1584    /// dissolution [`open_paragraph_at_block_media`](Self::open_paragraph_at_block_media)
1585    /// prevents from the typing side, and it cost this repository's own test vault
1586    /// a photo before it was found.
1587    ///
1588    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1589    /// when it is behind the caret, Delete when it is in front — which is what
1590    /// every editor does with an embed, and one undo away. The key aimed *away*
1591    /// from it would otherwise delete the paragraph break and merge a neighbour
1592    /// into the picture's own paragraph, which dissolves it just as surely; it
1593    /// steps the caret over the boundary instead and leaves the
1594    /// next press to delete in the block it has reached — the same "first press
1595    /// steps out of the atom, second press deletes" every delete key here gets,
1596    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1597    /// above, and reaches it on the second press rather than taking the break and
1598    /// the picture with it on the first).
1599    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1600        // The map answers about offsets, so it has to be this revision's — see
1601        // the same call in `open_paragraph_at_block_media`.
1602        self.rebuild_map();
1603        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1604            return false;
1605        };
1606        let aimed_at_it = side
1607            == if forward {
1608                MediaStop::Before
1609            } else {
1610                MediaStop::After
1611            };
1612        if !aimed_at_it {
1613            let over = if forward {
1614                self.vmap.stop_after(self.caret)
1615            } else {
1616                self.vmap.stop_before(self.caret)
1617            };
1618            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1619                self.caret = off;
1620                self.anchor = None;
1621                self.goal_col = None;
1622            }
1623            return true;
1624        }
1625        // Take the break that held the picture apart from its neighbour with it,
1626        // so the delete doesn't leave a blank paragraph standing where the
1627        // picture was. The last arm is a picture that is the whole document.
1628        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1629            (span.start - 2, span.end)
1630        } else if self.source[span.end..].starts_with("\n\n") {
1631            (span.start, span.end + 2)
1632        } else {
1633            (span.start, span.end)
1634        };
1635        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1636        true
1637    }
1638
1639    /// The Hidden-mode typing path: replace any selection, then insert `text`
1640    /// escaped so it stays literal. When it replaces a selection the two edits
1641    /// fold into one undo step, so an overwrite undoes atomically (and restores
1642    /// the selection) exactly as a plain one does.
1643    fn insert_literal_typed(&mut self, text: &str) {
1644        let kind = typed_edit_kind(text);
1645        match self.selection() {
1646            Some((s, e)) => {
1647                if !self.splice(s, e, "", EditKind::Other) {
1648                    return;
1649                }
1650                // Typing over a whole marked run takes its delimiters with it
1651                // (the empty content couldn't hold them — see
1652                // `repair_mark_edges`) and leaves its marks armed at the caret.
1653                // The text taking the run's place inherits them, exactly as it
1654                // would have by landing inside a run that survived.
1655                let pending = self.pending_here();
1656                if !pending.is_empty() && !text.trim().is_empty() {
1657                    self.insert_with_marks(self.caret, text, pending);
1658                    return;
1659                }
1660                self.insert_literal_at(self.caret, text, kind, true);
1661            }
1662            None => {
1663                self.insert_literal_at(self.caret, text, kind, false);
1664            }
1665        }
1666    }
1667
1668    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1669    /// at a collapsed caret, but only while the caret still stands where they
1670    /// were armed and nothing is selected. Empty otherwise, so a stale delta
1671    /// never styles text it wasn't meant for.
1672    fn pending_here(&self) -> InlineMarks {
1673        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1674            self.pending_marks
1675        } else {
1676            InlineMarks::empty()
1677        }
1678    }
1679
1680    /// Drop the armed sticky marks — any caret motion, selection, or edit does
1681    /// this, so "start bold here" only ever applies at the exact spot it was
1682    /// asked for.
1683    fn clear_pending(&mut self) {
1684        self.pending_marks = InlineMarks::empty();
1685        self.pending_at = None;
1686    }
1687
1688    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1689    /// force is wrapped around the freshly typed text; a mark the caret already
1690    /// stands inside is *shed* — the text is inserted past the run's end so it
1691    /// lands unmarked ("type normally again"). The caret comes to rest inside any
1692    /// added runs, so continued typing inherits the marks with no re-wrapping,
1693    /// and the delta is cleared: the marks now live in the document, not here.
1694    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1695        let base = self.mark_spans_at(at);
1696        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1697        // Nothing to shed, and a run of exactly these marks standing just behind
1698        // the caret: carry on writing *that* run rather than opening a second
1699        // one beside it.
1700        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1701            return;
1702        }
1703        // Shed the marks we're turning off: step the insertion point past the
1704        // end of each run the caret sits in, so the new text falls outside it.
1705        let mut ins_at = at;
1706        for (kind, span) in &base {
1707            if marks.contains(*kind) {
1708                ins_at = ins_at.max(span.end);
1709            }
1710        }
1711        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1712            return;
1713        }
1714        // The plain splice inserted exactly `text` at `ins_at`; that byte range
1715        // is the content every added mark wraps.
1716        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1717        for kind in marks.iter() {
1718            if !base_set.contains(kind) {
1719                let (ncs, nce) = self.wrap_span(cs, ce, kind);
1720                cs = ncs;
1721                ce = nce;
1722            }
1723        }
1724        self.caret = ce.min(self.source.len());
1725        self.anchor = None;
1726        self.last_edit_kind = None;
1727        // Realised: the marks are in the document now, and the caret sits inside
1728        // them, so there is no delta left to carry. Arm nothing, but remember the
1729        // spot so a *further* toggle before typing starts a clean delta here.
1730        self.pending_marks = InlineMarks::empty();
1731        self.pending_at = Some(self.caret);
1732        self.clamp_caret();
1733        self.record_caret();
1734    }
1735
1736    /// Carry on the marked run just behind `at` — moving its closing delimiters
1737    /// out past the new text — instead of opening a second run of the same marks
1738    /// beside it. Returns whether it did.
1739    ///
1740    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1741    /// A space typed after a bold word steps the caret out of the run, because
1742    /// `**bold **` is not bold; the next character has to step back *in*, or the
1743    /// writer who typed one bold phrase is left with `**bold** **and**` — two
1744    /// runs that read the same to a reader but spell the file in a way nobody
1745    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1746    /// words it isn't marking), and the marks behind it must be exactly the ones
1747    /// armed — a run of *some* other kind is a neighbour, not this phrase.
1748    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1749        if text.is_empty() || text.trim() != text {
1750            return false;
1751        }
1752        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1753        // Walk in through the delimiters stacked at that point, innermost last:
1754        // `***both*** ` closes two runs with one `***`, and rejoining means
1755        // getting behind all of them.
1756        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1757        while let Some((kind, content_end)) = self
1758            .editor
1759            .ancestors_at(prev_boundary(&self.source, cut))
1760            .unwrap_or_default()
1761            .into_iter()
1762            .filter(|m| m.span.end == cut)
1763            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1764        {
1765            if content_end >= cut {
1766                break; // a mark with no closing delimiter to step behind
1767            }
1768            kinds.insert(kind);
1769            cut = content_end;
1770        }
1771        if cut == gap_at || kinds != marks {
1772            return false;
1773        }
1774        // Re-spell the tail: the gap, then the new text, then the delimiters that
1775        // used to close in front of them — read out of the document rather than
1776        // written from a table, so whatever twig spells them with is what moves.
1777        let tail = format!(
1778            "{}{text}{}",
1779            &self.source[gap_at..at],
1780            &self.source[cut..gap_at]
1781        );
1782        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1783            return false;
1784        }
1785        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1786        self.anchor = None;
1787        self.last_edit_kind = None;
1788        self.pending_marks = InlineMarks::empty();
1789        self.pending_at = Some(self.caret);
1790        self.clamp_caret();
1791        self.record_caret();
1792        true
1793    }
1794
1795    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1796    /// never itself wrapped: a mark around a space draws nothing a reader can
1797    /// see, and in Markdown and Djot it draws its own delimiters instead
1798    /// (`** **`). So the space goes in unmarked — outside any run the armed
1799    /// marks are shedding — and the marks stay armed for the character after it,
1800    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1801    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1802        let base = self.mark_spans_at(at);
1803        // What the *next* character carries: the armed delta resolved against the
1804        // marks in force here, which the space must not quietly drop.
1805        let want = base
1806            .iter()
1807            .map(|(k, _)| *k)
1808            .collect::<InlineMarks>()
1809            .xor(marks);
1810        let mut ins_at = at;
1811        for (kind, span) in &base {
1812            if marks.contains(*kind) {
1813                ins_at = ins_at.max(span.end);
1814            }
1815        }
1816        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1817            return;
1818        }
1819        self.rearm(want);
1820        self.record_caret();
1821    }
1822
1823    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1824    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1825    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1826    /// added split evenly around the content — half the growth on each side.
1827    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1828        match self.editor.toggle_inline(s, e, kind) {
1829            Ok(change) => {
1830                self.last_edit_kind = None;
1831                self.refresh();
1832                self.dirty = self.source != self.clean_source;
1833                let added = (change.new.end - change.new.start).saturating_sub(e - s);
1834                let half = added / 2;
1835                (change.new.start + half, change.new.end - half)
1836            }
1837            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1838            // rather than lose the keystroke.
1839            Err(e2) => {
1840                self.status = Some(format!("{kind:?}: {e2}"));
1841                (s, e)
1842            }
1843        }
1844    }
1845
1846    /// The safe offset to splice a block-level break at, given a caret that may
1847    /// sit exactly between an inline mark's content and its own closing
1848    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1849    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1850    /// with nothing following it on the line: the closing `**` renders no
1851    /// glyph of its own, so the caret's "end of line" offset lands right
1852    /// before it). Splicing a paragraph/list/quote break at `off` itself would
1853    /// sever the delimiter from its content, stranding it alone on the new
1854    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1855    /// nested marks closing at the same point (`**_x_**`) all clear together.
1856    /// A no-op everywhere else — mid-run, or past real trailing content, no
1857    /// mark's `content_span` ends exactly at `off`.
1858    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1859        let off = off.min(self.source.len());
1860        self.editor
1861            .ancestors_at(off)
1862            .unwrap_or_default()
1863            .into_iter()
1864            .filter(|m| inline_kind(&m.kind).is_some())
1865            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1866            .map(|m| m.span.end)
1867            .max()
1868            .unwrap_or(off)
1869    }
1870
1871    /// The offset a *delete* aimed at the character before `off` should stop at,
1872    /// when `off` is the start of a run's text and the bytes behind it are that
1873    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1874    /// byte behind the caret at the start of a bold word is not a character the
1875    /// writer can see, let alone one they aimed Backspace at: taking it leaves
1876    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1877    /// delete steps over the whole delimiter to the visible character in front of
1878    /// it instead. Walks out to the *outermost* mark opening there, so
1879    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
1880    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
1881        let off = off.min(self.source.len());
1882        self.editor
1883            .ancestors_at(off)
1884            .unwrap_or_default()
1885            .into_iter()
1886            .filter(|m| inline_kind(&m.kind).is_some())
1887            .filter(|m| {
1888                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
1889            })
1890            .map(|m| m.span.start)
1891            .min()
1892            .unwrap_or(off)
1893    }
1894
1895    /// `off` moved *inside* the run whose closing delimiters end there — the
1896    /// other offset the rich view draws in the same place, since a `**` renders
1897    /// no glyph of its own. `**bold**` has a caret home on each side of its
1898    /// closing delimiter, one column apart on screen and eight bytes and a whole
1899    /// run apart in the file, and a plain ← lands on the outer one whenever a
1900    /// space follows the phrase. The inner one is what the writer is pointing at
1901    /// there: the end of their bold word. Walks in through every mark closing at
1902    /// that point, innermost last, so `***both***` lands inside both. A no-op
1903    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
1904    fn step_inside_close_delims(&mut self, off: usize) -> usize {
1905        let mut off = off.min(self.source.len());
1906        loop {
1907            let inner = self
1908                .editor
1909                .ancestors_at(prev_boundary(&self.source, off))
1910                .unwrap_or_default()
1911                .into_iter()
1912                .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
1913                .filter_map(|m| m.content_span.clone().map(|c| c.end))
1914                .filter(|&end| end < off)
1915                .max();
1916            match inner {
1917                Some(end) => off = end,
1918                None => return off,
1919            }
1920        }
1921    }
1922
1923    /// The mirror at the opening edge: `off` moved inside the run whose
1924    /// delimiters *start* there, onto the first character of its text. See
1925    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
1926    fn step_inside_open_delims(&mut self, off: usize) -> usize {
1927        let mut off = off.min(self.source.len());
1928        loop {
1929            let inner = self
1930                .editor
1931                .ancestors_at(off)
1932                .unwrap_or_default()
1933                .into_iter()
1934                .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
1935                .filter_map(|m| m.content_span.clone().map(|c| c.start))
1936                .filter(|&start| start > off)
1937                .min();
1938            match inner {
1939                Some(start) => off = start,
1940                None => return off,
1941            }
1942        }
1943    }
1944
1945    /// The inline mark kinds whose span covers `off`, each with that span — the
1946    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
1947    /// ids instead. Used to shed a mark by stepping past the end of its run.
1948    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
1949        let off = off.min(self.source.len());
1950        self.editor
1951            .ancestors_at(off)
1952            .unwrap_or_default()
1953            .into_iter()
1954            .filter(|m| off < m.span.end)
1955            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
1956            .collect()
1957    }
1958
1959    /// Insert clipboard `text` at the caret, replacing the selection if there is
1960    /// one — always its own undo step, whatever its length.
1961    ///
1962    /// Provenance is the whole point, and only the caller has it. `insert` reads
1963    /// a lone character as a keystroke and folds it into the run around it,
1964    /// which is right for typing and wrong for a one-character paste: that paste
1965    /// would vanish mid-run on an undo it was never part of, and the characters
1966    /// the user actually typed would go with it. Length can't tell the two
1967    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
1968    /// caller comes through is what says which happened.
1969    pub fn paste(&mut self, text: &str) {
1970        // Pasting against a block picture dissolves it exactly as typing does,
1971        // and for the same reason — see `open_paragraph_at_block_media`.
1972        self.open_paragraph_at_block_media(text);
1973        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1974        self.splice(s, e, text, EditKind::Other);
1975    }
1976
1977    /// Replace `[start, end)` with `text` as one step of an IME composition —
1978    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
1979    /// folds into a single undo.
1980    ///
1981    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
1982    /// dozen calls here, each replacing the last one's provisional bytes, and an
1983    /// undo step per call means undoing a word means pressing ⌘Z until the reading
1984    /// unspools backwards through kana — the intermediate states were never text
1985    /// the user wrote. Only the frontend knows a call is provisional (the bytes
1986    /// look like any other edit), so the door the caller comes through is what
1987    /// says so, exactly as it is for [`paste`](Self::paste) versus
1988    /// [`insert`](Self::insert).
1989    ///
1990    /// Pair with [`end_composition`](Self::end_composition), or the *next*
1991    /// composition folds into this one.
1992    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
1993        self.splice(start, end, text, EditKind::Compose);
1994    }
1995
1996    /// Close the open composition run, so the next one is its own undo step.
1997    /// Call when the IME commits or withdraws a composition.
1998    ///
1999    /// Only clears a *composition* run: a frontend that reports an end it never
2000    /// began (some IMEs unmark unprompted) would otherwise split the run of
2001    /// typing around it into two undo steps for no reason the user can see.
2002    pub fn end_composition(&mut self) {
2003        if self.last_edit_kind == Some(EditKind::Compose) {
2004            self.last_edit_kind = None;
2005        }
2006    }
2007
2008    // ── the clipboard's rich flavor ──────────────────────────────────────────
2009
2010    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2011    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2012    /// nothing is selected, or when the selection doesn't render (the caller
2013    /// still has [`selected_text`](Self::selected_text), which is what to publish
2014    /// as `text/plain` either way).
2015    ///
2016    /// **The fragment is a source substring, and that is the honest limit here.**
2017    /// It's parsed standalone, so a selection whose meaning depends on its
2018    /// surroundings converts as what it literally says rather than what it looks
2019    /// like on screen: half a list item is a paragraph, a row torn out of a table
2020    /// is the text of a row, the `**` of a bold run selected without its closing
2021    /// `**` is two asterisks. Every one of those still *renders* — there's no
2022    /// error to report — it just renders as the fragment and not as the document.
2023    /// Widening the range to whole blocks would publish text the user didn't
2024    /// select, which is a worse lie than a fragment being a fragment; the plain
2025    /// flavor has the same substring, so the two flavors at least agree.
2026    pub fn selection_html(&mut self) -> Option<String> {
2027        let (start, end) = self.selection()?;
2028        let inline = self.selection_is_inline(start, end);
2029        let html = html::render_fragment(&self.source[start..end], self.format)?;
2030        Some(match inline {
2031            true => html::strip_sole_paragraph(html),
2032            false => html,
2033        })
2034    }
2035
2036    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2037    /// format first. Its own undo step, like any [`paste`](Self::paste).
2038    ///
2039    /// Returns whether it landed. `false` means the HTML didn't convert to
2040    /// anything worth pasting — the caller should fall back to the plain flavor
2041    /// rather than treat it as an error. The `html` module has the full list of
2042    /// what that covers: a table twig won't build, markup it doesn't recognise,
2043    /// an empty result.
2044    pub fn paste_html(&mut self, html: &str) -> bool {
2045        match html::parse_fragment(html, self.format) {
2046            Some(source) => {
2047                self.paste(&source);
2048                true
2049            }
2050            None => false,
2051        }
2052    }
2053
2054    /// Does the selection live *inside* a single top-level block?
2055    ///
2056    /// The question [`selection_html`](Self::selection_html) needs and the
2057    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2058    /// whether the user selected one word of a sentence or a whole paragraph, and
2059    /// only the document knows which. Selecting a word and pasting into Docs
2060    /// should extend the line you paste into; selecting the paragraph should make
2061    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2062    /// is an artifact of standalone parsing), and one that covers a whole block —
2063    /// or spans two — keeps its structure.
2064    ///
2065    /// Reads the block from twig rather than guessing from the bytes:
2066    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2067    /// block containing an offset, and two ends inside the same one cannot have
2068    /// crossed a block boundary.
2069    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2070        // The last *character*, not `end - 1`: the selection's end is exclusive
2071        // and may sit mid-codepoint's-worth of bytes past the last char.
2072        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2073            return false;
2074        };
2075        let (Some(head), Some(tail)) =
2076            (self.top_block_span(start), self.top_block_span(start + off))
2077        else {
2078            return false;
2079        };
2080        head == tail && !(start <= head.start && end >= head.end)
2081    }
2082
2083    /// The byte span of the top-level block containing `offset`, or `None` at an
2084    /// offset that belongs to no block (the blank line between two of them).
2085    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2086        self.editor
2087            .ancestors_at(offset)
2088            .ok()?
2089            .get(1)
2090            .map(|m| m.span.clone())
2091    }
2092
2093    // ── indentation ──────────────────────────────────────────────────────────
2094
2095    /// One indent level.
2096    ///
2097    /// Two spaces, not the four both frontends type for Tab today, because in a
2098    /// markdown document four columns isn't a width — it's a *meaning*. Four
2099    /// spaces at the head of a line is markdown's indented-code-block marker, so
2100    /// one Tab on a paragraph would reparse it into code and style it as such;
2101    /// two cannot, and the line stays the prose it was. Two is also exactly
2102    /// where a `- ` bullet's content starts, so an indented line lands under its
2103    /// parent item's text instead of beside it — the column a list-aware indent
2104    /// has to hit anyway, which keeps this width from being relitigated later.
2105    const INDENT: &'static str = "  ";
2106
2107    /// Indent the selected lines — or the caret's line, with no selection — by
2108    /// one level (Tab).
2109    pub fn indent(&mut self) {
2110        self.reindent(true);
2111        // Nesting changes an ordered list's numbering (the nested item restarts,
2112        // its old siblings resume) — keep the source markers in step.
2113        self.renumber_here();
2114        // Nesting an empty `-` item under a text line reparses that text as a
2115        // setext heading; swap the dash for a `*` before it can (a no-op unless
2116        // the collapse actually happened).
2117        self.avoid_setext_collapse();
2118    }
2119
2120    /// Take one indent level back off the selected lines, or the caret's line
2121    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2122    ///
2123    /// A line with *less* than a full level gives back what it has rather than
2124    /// refusing: outdent's job is to walk a line left, and real documents — hand
2125    /// written, or reflowed by some other editor — are full of indentation that
2126    /// was never a clean multiple of anything. Refusing there would strand the
2127    /// line at a depth Shift+Tab couldn't undo.
2128    pub fn outdent(&mut self) {
2129        self.reindent(false);
2130        self.renumber_here();
2131    }
2132
2133    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2134    ///
2135    /// One splice across the whole line range, never one per line: a Tab is one
2136    /// thing the user did, so it has to be one undo step and one reparse. Per
2137    /// line, twig would reparse the document once per line and leave a stack of
2138    /// steps that Shift+⌘Z walks back one line at a time.
2139    fn reindent(&mut self, add: bool) {
2140        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2141        let start = source_line_range(&self.source, sel_start).start;
2142        let end = source_line_range(&self.source, sel_end).end;
2143        let region = self.source[start..end].to_string();
2144        let lines: Vec<&str> = region.split('\n').collect();
2145        // A blank line has no text to move, and padding it would leave nothing
2146        // but trailing whitespace — but Tab on a blank line *is* a request for
2147        // indentation to type into, so the skip only applies where the op has
2148        // other lines to do real work on.
2149        let skip_blank = add && lines.len() > 1;
2150
2151        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2152        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2153        let mut line_off = start;
2154        for (i, full) in lines.iter().enumerate() {
2155            if i > 0 {
2156                out.push('\n');
2157            }
2158            // A list item moves by having its whole leading prefix *replaced*,
2159            // never by having spaces pushed in front of the line. twig spells
2160            // both prefixes, so the quote markers, the parent's indent and an
2161            // ordered marker's extra column all come out right without leaf
2162            // measuring any of them — and a line that only looks like an item
2163            // (a Djot continuation) reports no marker and is left to the plain
2164            // path, where a Tab is just a Tab.
2165            let marker = self.list_marker_on_line(line_off);
2166            let own = marker
2167                .as_ref()
2168                .map(|m| m.marker_start - m.line_start)
2169                .unwrap_or(0);
2170            let delta = if add {
2171                if skip_blank && full.trim().is_empty() {
2172                    out.push_str(full);
2173                    0
2174                } else if marker.is_some() && self.first_item_of_list(line_off) {
2175                    // The first item of a list has no preceding sibling to nest
2176                    // under, so a Tab here can't spell a sub-list — twig would
2177                    // reparse the shoved-over marker as the same list, only
2178                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2179                    // item where it is, the way every list editor refuses to
2180                    // over-indent a list's first line.
2181                    out.push_str(full);
2182                    0
2183                } else if marker.is_some() {
2184                    // Nesting means standing where a *continuation* of this line
2185                    // would stand: past the parent's marker, inside its content
2186                    // column. That is `continuation_prefix`, less a checkbox.
2187                    let new = self.nesting_prefix_at(line_off);
2188                    let delta = new.len() as isize - own as isize;
2189                    out.push_str(&new);
2190                    out.push_str(&full[own..]);
2191                    delta
2192                } else {
2193                    out.push_str(Self::INDENT);
2194                    out.push_str(full);
2195                    Self::INDENT.len() as isize
2196                }
2197            } else if marker.is_some() {
2198                // Unnesting is the mirror: stand where the parent item's own
2199                // line starts, which drops exactly the level it contributed.
2200                let new = self.outdent_prefix_at(line_off);
2201                let delta = new.len() as isize - own as isize;
2202                out.push_str(&new);
2203                out.push_str(&full[own..]);
2204                delta
2205            } else {
2206                // A plain line gives back the ordinary step.
2207                let strip = outdent_width(full, Self::INDENT.len());
2208                out.push_str(&full[strip..]);
2209                -(strip as isize)
2210            };
2211            deltas.push(delta);
2212            line_off += full.len() + 1;
2213        }
2214        // Nothing to give back. Returning before the splice keeps an outdent at
2215        // column zero from spending an undo step on a document it never changed.
2216        if deltas.iter().all(|d| *d == 0) {
2217            return;
2218        }
2219
2220        // Every line's text keeps its offset *within the line*, so the caret is
2221        // remapped by its column, not by its byte offset — which the prefixes on
2222        // the lines above it have already invalidated.
2223        let remap = |off: usize| -> usize {
2224            let (mut old_ls, mut new_ls) = (start, start);
2225            for (line, delta) in lines.iter().zip(&deltas) {
2226                let old_le = old_ls + line.len();
2227                let new_len = (line.len() as isize + delta) as usize;
2228                if off <= old_le {
2229                    let col = (off - old_ls) as isize;
2230                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2231                }
2232                old_ls = old_le + 1;
2233                new_ls += new_len + 1;
2234            }
2235            start + out.len()
2236        };
2237        let placed = match self.selection() {
2238            // Keep the rewritten region selected, the way a container toggle
2239            // keeps its own: it leaves a second Tab aimed at the same lines
2240            // rather than at whatever the shifted offsets now happen to cover.
2241            Some(_) => (start + out.len(), Some(start)),
2242            None => (remap(self.caret), None),
2243        };
2244
2245        // A rolled-back splice leaves the old source in place, where every offset
2246        // computed above addresses text that was never written.
2247        if !self.splice(start, end, &out, EditKind::Other) {
2248            return;
2249        }
2250        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2251        // rewrite is the last line's end — nowhere the caret was. Place it, then
2252        // re-record the caret so this is the state redo restores, not the one
2253        // `splice` left behind from the `Change`.
2254        self.caret = placed.0.min(self.source.len());
2255        self.anchor = placed.1;
2256        self.clamp_caret();
2257        self.record_caret();
2258    }
2259
2260    /// The Enter key.
2261    ///
2262    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2263    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2264    /// caret is in decides what actually gets written.
2265    ///
2266    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2267    ///                            block at the caret and reopens its container
2268    ///   - list item            → likewise: the next item, its indent, quote
2269    ///                            prefix and `[ ]` box all reproduced by twig —
2270    ///                            except an *empty* item, which exits the list
2271    ///   - block quote          → likewise: a new paragraph inside the quote
2272    ///   - heading              → a new *paragraph*, not another heading
2273    ///   - code block           → a literal newline (stay in the block)
2274    ///   - blank line           → a literal newline (one Backspace undoes it)
2275    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2276    ///                            visible line
2277    ///
2278    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2279    /// and it is better at it: it drops the whitespace the caret was sitting in
2280    /// front of instead of stranding it at the head of the second half, and it
2281    /// knows continuations leaf's marker scan never covered — a checklist item
2282    /// continues as an *unchecked* checklist item rather than a plain bullet.
2283    ///
2284    /// The exceptions above are exceptions because `split_block` is either wrong
2285    /// there or refuses: parting a fence yields two fences with the code split
2286    /// between them, parting a heading yields a second heading where every editor
2287    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2288    /// table all report an error rather than a split.
2289    pub fn newline(&mut self) {
2290        if self.view == View::Source {
2291            self.insert_raw("\n");
2292            return;
2293        }
2294        // Enter over a selection replaces it with a paragraph break.
2295        if let Some((s, e)) = self.selection() {
2296            self.splice(s, e, "\n\n", EditKind::Other);
2297            return;
2298        }
2299        // A caret resting exactly between an inline mark's content and its own
2300        // closing delimiter (`**bold**` with nothing after it on the line —
2301        // the WYSIWYG caret's natural end-of-line position) must not splice a
2302        // block break there: every path below eventually does via
2303        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2304        // delimiter would strand it alone on the new line.
2305        self.caret = self.skip_trailing_close_delims(self.caret);
2306        // The block the caret is in. `block_offset_for_caret` nudges off a line
2307        // end (where the caret sits at the doc level); on a bare line (e.g. an
2308        // empty list item) fall back to the caret so the enclosing list/quote is
2309        // still visible in the ancestors.
2310        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2311        let kinds: Vec<Kind> = self
2312            .editor
2313            .ancestors_at(off)
2314            .map(|c| c.into_iter().map(|m| m.kind).collect())
2315            .unwrap_or_default();
2316        let has = |k: Kind| kinds.contains(&k);
2317
2318        if has(Kind::CodeBlock) {
2319            self.insert_raw("\n");
2320            return;
2321        }
2322        // An *empty* list item exits the list — the standard double-Enter — which
2323        // `split_block` reports as an error rather than a split (there is no
2324        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2325        // the AST gate — it answers from the tree, so a `- ` that reads as a
2326        // marker byte-for-byte but opens no item (a setext underline, a Djot
2327        // continuation line) never reaches here.
2328        if let Some(marker) = self.list_marker_on_line(self.caret)
2329            && self.item_is_empty(&marker)
2330        {
2331            self.exit_list(&marker);
2332            return;
2333        }
2334        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2335        // not another full paragraph break — so it moves down one line and one
2336        // Backspace undoes it, not two. (`split_block` errors here too.)
2337        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2338        let line_end = self.source[self.caret..]
2339            .find('\n')
2340            .map_or(self.source.len(), |i| self.caret + i);
2341        if self.source[line_start..line_end].trim().is_empty() {
2342            self.insert_raw("\n");
2343            return;
2344        }
2345        // In `Preserve` flow a soft break is a *visible* line the author means to
2346        // make, so Enter writes a single `\n` and typing continues the same
2347        // paragraph on the next line — the behaviour of an ordinary text editor.
2348        // A second Enter then lands on the blank line above and takes the
2349        // empty-line branch, so double-Enter still promotes to a full paragraph
2350        // break; and Backspace, which deletes a lone `\n` over a soft break,
2351        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2352        // render as an invisible space, so Enter keeps making the paragraph break
2353        // that actually shows.
2354        //
2355        // Only in running prose. A list or a quote has a continuation of its own
2356        // to write, and a `\n` there is not a soft line but a lost container.
2357        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2358        if self.line_flow == LineFlow::Preserve && !in_container {
2359            self.insert_raw("\n");
2360            return;
2361        }
2362        // A heading gets a *paragraph*, never a second heading: Enter at the end
2363        // of a title is how every editor is asked for the body under it, and
2364        // `split_block` would repeat the `#` instead. Whitespace at the split
2365        // point goes with the break rather than opening the new paragraph, which
2366        // is what `split_block` does everywhere else.
2367        if has(Kind::Heading) {
2368            let mut end = self.caret;
2369            while self.source.as_bytes().get(end) == Some(&b' ') {
2370                end += 1;
2371            }
2372            self.splice(self.caret, end, "\n\n", EditKind::Other);
2373            return;
2374        }
2375        self.split_block_here();
2376    }
2377
2378    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2379    /// the caret in the second half.
2380    ///
2381    /// twig reopens whatever the first half was inside of — the bullet with its
2382    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2383    /// reason this replaced the markup leaf used to spell from the line's bytes.
2384    /// It renumbers nothing, though: a new item mid-list is written with its
2385    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2386    /// behind it, folded into the same undo step.
2387    ///
2388    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2389    /// shape still moves the caret down rather than swallowing the keystroke.
2390    fn split_block_here(&mut self) {
2391        match self.editor.split_block(self.caret) {
2392            Ok(change) => {
2393                self.last_edit_kind = None;
2394                self.refresh();
2395                self.anchor = None;
2396                self.caret = change.new.end;
2397                self.dirty = self.source != self.clean_source;
2398                self.status = None;
2399                self.clamp_caret();
2400                self.record_caret();
2401                // Aimed at the new block's *start*: the caret twig leaves is one
2402                // past the marker it wrote, where there is no list in reach.
2403                self.renumber_at(change.new.start);
2404            }
2405            Err(_) => self.insert_raw("\n\n"),
2406        }
2407    }
2408
2409    /// Whether the item on the marker's line carries no content — the shape
2410    /// double-Enter reads as "I'm done with this list."
2411    fn item_is_empty(&self, line: &ListMarker) -> bool {
2412        let content_start = line.content_start().min(self.source.len());
2413        let line_end = self.source[self.caret..]
2414            .find('\n')
2415            .map(|i| self.caret + i)
2416            .unwrap_or(self.source.len());
2417        self.source[content_start..line_end.max(content_start)]
2418            .trim()
2419            .is_empty()
2420    }
2421
2422    /// Leave the list: replace the empty item's marker with a blank line, so the
2423    /// caret lands in a fresh paragraph below it.
2424    ///
2425    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2426    /// quote), and the caret's new line keeps the `> ` it was already behind —
2427    /// leaving the list without also leaving the quote.
2428    fn exit_list(&mut self, line: &ListMarker) {
2429        let prefix = self.quote_prefix_at(line.marker_start);
2430        let blank = prefix.trim_end();
2431        self.splice(
2432            line.line_start,
2433            self.caret,
2434            &format!("{blank}\n{prefix}"),
2435            EditKind::Other,
2436        );
2437    }
2438
2439    /// What a line continuing the containers at `off` has to open with — the
2440    /// quote markers reproduced, each enclosing item's marker as its width in
2441    /// spaces. Also the column a nested item's marker stands in, which is what
2442    /// makes it Tab's answer.
2443    fn continuation_prefix_at(&mut self, off: usize) -> String {
2444        self.editor
2445            .document()
2446            .and_then(|mut d| d.continuation_prefix(off))
2447            .map(|p| p.text)
2448            .unwrap_or_default()
2449    }
2450
2451    /// The column a *nested list* may open at inside the item at `off` — which
2452    /// is not always where the item's own text continues.
2453    ///
2454    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2455    /// it is markup a rich view hides, and the item's own wrapped text does
2456    /// stand past it. But a nested list may only open at the *list* marker's
2457    /// column, and four columns further in is an indented continuation of the
2458    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2459    /// So the box's own width goes back.
2460    ///
2461    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2462    /// reports the list marker's column apart from the box; `checked` is what
2463    /// says a box is there at all, so only its width is being measured here.
2464    fn nesting_prefix_at(&mut self, off: usize) -> String {
2465        let cont = self.continuation_prefix_at(off);
2466        let Some(item) = self.innermost_list_item(off) else {
2467            return cont;
2468        };
2469        if item.checked.is_none() {
2470            return cont;
2471        }
2472        let box_width = item
2473            .marker_span
2474            .and_then(|m| self.source.get(m))
2475            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2476            .unwrap_or(0);
2477        // The trailing columns are the ones the item's own marker contributed,
2478        // so trimming from the end leaves any quote prefix standing.
2479        cont[..cont.len().saturating_sub(box_width)].to_string()
2480    }
2481
2482    /// Where the line of the item *containing* the item at `off` begins — the
2483    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2484    /// parent contributed. The quote prefix alone for a top-level item, which
2485    /// has no level left to give.
2486    fn outdent_prefix_at(&mut self, off: usize) -> String {
2487        let items: Vec<usize> = self
2488            .editor
2489            .document()
2490            .and_then(|mut d| d.ancestors_at_caret(off))
2491            .map(|c| {
2492                c.into_iter()
2493                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2494                    .map(|m| m.span.start)
2495                    .collect()
2496            })
2497            .unwrap_or_default();
2498        // The second-innermost item is the parent; its own line's indent is the
2499        // target. `list_marker_on_line` gives that line's prefix directly.
2500        let parent = items.len().checked_sub(2).map(|i| items[i]);
2501        match parent.and_then(|p| self.list_marker_on_line(p)) {
2502            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2503            None => self.quote_prefix_at(off),
2504        }
2505    }
2506
2507    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2508    /// inside one, `"> > "` inside two.
2509    ///
2510    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2511    /// the `>` and the space after it are twig's spelling rather than leaf's.
2512    /// The whole line prefix can't answer this: it also carries the indent of
2513    /// whatever the quote holds, which a blank separator line must *not* repeat.
2514    fn quote_prefix_at(&mut self, off: usize) -> String {
2515        let Ok(chain) = self
2516            .editor
2517            .document()
2518            .and_then(|mut d| d.ancestors_at_caret(off))
2519        else {
2520            return String::new();
2521        };
2522        let quotes: Vec<usize> = chain
2523            .iter()
2524            .filter(|m| m.kind == Kind::BlockQuote)
2525            .map(|m| m.node_id as usize)
2526            .collect();
2527        let Ok(nodes) = self.editor.nodes() else {
2528            return String::new();
2529        };
2530        quotes
2531            .iter()
2532            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2533            .filter_map(|s| self.source.get(s))
2534            .collect()
2535    }
2536
2537    /// Whether the item at `off` sits inside another one — the test Backspace
2538    /// uses to choose between outdenting and dropping the marker.
2539    ///
2540    /// Counted from the AST rather than from the line's leading whitespace,
2541    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2542    fn item_is_nested(&mut self, off: usize) -> bool {
2543        self.editor
2544            .document()
2545            .and_then(|mut d| d.ancestors_at_caret(off))
2546            .map(|c| {
2547                c.into_iter()
2548                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2549                    .count()
2550                    > 1
2551            })
2552            .unwrap_or(false)
2553    }
2554
2555    /// The innermost list item containing `probe`, under twig's **caret**
2556    /// containment rule — a block's end is inside it.
2557    ///
2558    /// Half-open containment can't answer this. An empty item's span is exactly
2559    /// its marker, so the caret sitting after `- ` is one past the end and the
2560    /// item it is plainly in tests as out of reach; that is the shape
2561    /// double-Enter has to recognise to leave the list.
2562    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2563        let chain = self
2564            .editor
2565            .document()
2566            .and_then(|mut d| d.ancestors_at_caret(probe))
2567            .ok()?;
2568        let id = chain
2569            .iter()
2570            .rev()
2571            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2572            .node_id as usize;
2573        self.editor.nodes().ok()?.get(id).cloned()
2574    }
2575
2576    /// The list marker opening `off`'s line, per twig — `None` when that line
2577    /// opens no list item.
2578    ///
2579    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2580    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2581    /// and it is `None` on a *continuation* line, which opens nothing. That last
2582    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2583    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2584    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2585    /// parser knows which document it is looking at.
2586    ///
2587    /// The item's own marker is separated out via its
2588    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2589    /// the containers around it contribute and what the item does.
2590    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2591        let off = off.min(self.source.len());
2592        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2593        // The prefix belongs to a list only when an item's marker closes it —
2594        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2595        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2596        let marker = item.marker_span.clone()?;
2597        if marker.end != prefix.end {
2598            return None;
2599        }
2600        Some(ListMarker {
2601            line_start: prefix.start,
2602            marker_start: marker.start,
2603            text: self.source.get(prefix)?.to_string(),
2604        })
2605    }
2606
2607    /// Whether the list item on `line_start`'s line is the **first item** of its
2608    /// list — the one Tab must not nest, because nesting needs a preceding
2609    /// sibling to become the new parent and a first item has none. `false` for a
2610    /// line that isn't a list item, and for an item with a sibling above it (the
2611    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2612    /// the same in a setext underline that opens no list at all.
2613    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2614        let Some(marker) = self.list_marker_on_line(line_start) else {
2615            return false;
2616        };
2617        // Probe just inside the marker, where the item's own node is in reach —
2618        // the marker offset itself can resolve to the enclosing list, not the
2619        // `list_item`, whose span starts at the marker.
2620        let probe = marker.content_start().min(self.source.len());
2621        let Some(item) = self.innermost_list_item(probe) else {
2622            return false;
2623        };
2624        let Ok(nodes) = self.editor.nodes() else {
2625            return false;
2626        };
2627        match item.parent {
2628            // First when the parent list opens with this very item.
2629            Some(pid) => nodes
2630                .get(pid.0 as usize)
2631                .is_some_and(|p| p.first_child == Some(item.id)),
2632            // A parentless item is trivially the first (and only) one.
2633            None => true,
2634        }
2635    }
2636
2637    pub fn backspace(&mut self) {
2638        if let Some((s, e)) = self.selection() {
2639            self.splice(s, e, "", EditKind::Other);
2640            return;
2641        }
2642        // WYSIWYG: Backspace at the very start of a list item's content is a
2643        // structural key, not a character delete — it walks the "un-indent, then
2644        // un-list" ladder every list editor gives that keystroke (outdent a
2645        // nested item, strip a top-level one's marker to a paragraph). In source
2646        // view the `- ` is visible text the user is deleting a byte of, so it
2647        // keeps its literal meaning there, like Enter does.
2648        if self.view != View::Source && self.backspace_list_start() {
2649            return;
2650        }
2651        // WYSIWYG: and the same at the start of a heading's content — the `# `
2652        // there is markup the rich view hides, not text the user typed.
2653        if self.view != View::Source && self.backspace_heading_start() {
2654            return;
2655        }
2656        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2657        // the markup apart under a caret that cannot see it — see
2658        // `delete_around_block_media`.
2659        if self.view != View::Source && self.delete_around_block_media(false) {
2660            return;
2661        }
2662        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2663        // stop, not a single newline. On a line with no text of its own, the byte
2664        // before the caret is a `\n` that spells part of a block boundary — the gap
2665        // between two blocks, drawn but never a caret home. Removing just it strands
2666        // the caret in that gap and leaves an odd blank line the eye reads as one
2667        // separator but the caret can't land on: the "extra newline" left behind
2668        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2669        // Deleting to the previous stop instead collapses the whole break at once,
2670        // landing the caret at the end of the block above. Two blank lines in a row
2671        // are one stop apart, so this still removes exactly one — the lone-Enter /
2672        // lone-Backspace symmetry the empty-line case is built on is untouched.
2673        if self.view != View::Source
2674            && self.caret > self.caret_floor()
2675            && self.caret_on_blank_line()
2676            && let Some(stop) = self.vmap.stop_before(self.caret)
2677        {
2678            let stop = stop.max(self.caret_floor());
2679            if stop < self.caret {
2680                self.splice(stop, self.caret, "", EditKind::Delete);
2681                return;
2682            }
2683        }
2684        if self.caret > self.caret_floor() {
2685            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2686            // takes the whole tag — a single-byte step would leave a broken `<br`
2687            // showing in the cell. Rich view only (source view edits the literal).
2688            if self.view != View::Source
2689                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2690            {
2691                let start = start.max(self.caret_floor());
2692                if start < end {
2693                    self.splice(start, end, "", EditKind::Delete);
2694                    return;
2695                }
2696            }
2697            // Aim the delete at the character the writer can *see* behind the
2698            // caret, never at a delimiter the rich view drew nothing for. Two
2699            // steps, and either can apply: from the far side of a run's closing
2700            // `**` step back into the run (the caret is drawn at the end of its
2701            // word), and at the start of a run's text step out past its opening
2702            // `**` to the character in front of it, leaving the run standing.
2703            // Without them a plain Backspace unspells the phrase it is editing
2704            // and leaves a literal asterisk on screen.
2705            let end = if self.view == View::Source {
2706                self.caret
2707            } else {
2708                let inside = self.step_inside_close_delims(self.caret);
2709                self.skip_leading_open_delims(inside)
2710                    .max(self.caret_floor())
2711            };
2712            // Never delete back across the floor — that would eat hidden
2713            // frontmatter the WYSIWYG caret can't even see.
2714            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2715            // Take a hidden escape backslash with the char it escapes: the rich
2716            // view draws `\*` as a single `*`, so Backspace over it must delete
2717            // both bytes, never strand the `\` as a lone visible backslash (the
2718            // mirror of the Hidden-mode typing that wrote the escape). Source view
2719            // shows the `\`, so there it is an ordinary character.
2720            if self.view != View::Source
2721                && prev > self.caret_floor()
2722                && self.is_hidden_escape(prev - 1)
2723            {
2724                prev -= 1;
2725            }
2726            if prev < end {
2727                self.splice(prev, end, "", EditKind::Delete);
2728            }
2729        }
2730    }
2731
2732    /// Whether the caret's own source line holds nothing but whitespace — an
2733    /// empty paragraph, or the blank line a block boundary is spelled with. The
2734    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2735    /// no text of its own, so the newline before the caret belongs to the gap
2736    /// between blocks rather than to any word the caret is editing.
2737    fn caret_on_blank_line(&self) -> bool {
2738        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2739        let line_end = self.source[self.caret..]
2740            .find('\n')
2741            .map_or(self.source.len(), |i| self.caret + i);
2742        self.source[line_start..line_end].trim().is_empty()
2743    }
2744
2745    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2746    /// `edge` side — the byte range to delete whole. A table row is one source
2747    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2748    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2749    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2750    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2751    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2752    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
2753    /// apart — no ancestor walk needed. Rich view only; source view shows the
2754    /// literal tag and deletes it a byte at a time.
2755    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2756        let caret = self.caret;
2757        let nodes = self.nodes();
2758        let src = self.source.as_bytes();
2759        nodes
2760            .iter()
2761            .find(|n| {
2762                n.kind == Kind::HardBreak
2763                    && n.span.start < n.span.end
2764                    && src.get(n.span.start) == Some(&b'<')
2765                    && match edge {
2766                        BreakEdge::Backward => n.span.end == caret,
2767                        BreakEdge::Forward => n.span.start == caret,
2768                    }
2769            })
2770            .map(|n| (n.span.start, n.span.end))
2771    }
2772
2773    /// Whether the source byte at `off` is a backslash twig consumed as an escape
2774    /// (hidden in the rich view), as against a literal backslash (drawn). A
2775    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2776    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2777    /// round-trip needed.
2778    fn is_hidden_escape(&self, off: usize) -> bool {
2779        let b = self.source.as_bytes();
2780        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2781    }
2782
2783    /// Backspace's list behaviour: when the caret sits exactly at the start of a
2784    /// list item's content (right after its marker), outdent the item if it's
2785    /// nested, else strip the marker so it becomes a paragraph. Returns whether
2786    /// it acted — `false` leaves Backspace its ordinary character delete.
2787    fn backspace_list_start(&mut self) -> bool {
2788        let Some(marker) = self.list_marker_on_line(self.caret) else {
2789            return false;
2790        };
2791        // Only right after the marker. That the line opens a real item is
2792        // already settled: `list_marker_on_line` answers from the tree.
2793        if self.caret != marker.content_start() {
2794            return false;
2795        }
2796        if self.item_is_nested(marker.marker_start) {
2797            // Nested: give back one level, keeping the marker and carrying the
2798            // caret with it.
2799            self.outdent();
2800        } else {
2801            // Top level: drop the marker, leaving a paragraph, then renumber the
2802            // siblings the removed item was counted among. Only the marker goes —
2803            // a quote prefix in front of it still has a quote to hold up.
2804            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2805            self.renumber_here();
2806        }
2807        true
2808    }
2809
2810    /// Backspace's heading behaviour: with the caret exactly at the start of an
2811    /// ATX heading's content — right after the `#` marker the rich view hides —
2812    /// strip the marker so the line becomes a paragraph. The peer of
2813    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2814    /// reasoning: hidden block markup is structure, so the keystroke over it is
2815    /// structural.
2816    ///
2817    /// Without this the ordinary delete takes the space out of `# Title` and
2818    /// leaves `#Title`, which is no longer a heading at all — the hash the view
2819    /// had been hiding surfaces as literal text the user has to delete a second
2820    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2821    /// other end) goes with the marker for the same reason.
2822    ///
2823    /// Returns whether it acted; `false` leaves Backspace its character delete.
2824    fn backspace_heading_start(&mut self) -> bool {
2825        let caret = self.caret;
2826        // The heading whose content opens exactly at the caret. A bare `#` has no
2827        // content span at all — its content starts (and ends) where the line does.
2828        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2829            let (start, end) = match &n.content_span {
2830                Some(c) => (c.start, c.end),
2831                None => (n.span.end, n.span.end),
2832            };
2833            (n.kind == Kind::Heading && start == caret)
2834                .then(|| (n.span.clone(), end, n.marker_span.clone()))
2835        }) else {
2836            return false;
2837        };
2838        // twig reports the marker's own extent, so there is nothing to walk back
2839        // over and no `#` in this file. A setext heading has no marker — its
2840        // content opens the line — so it falls through to the ordinary delete,
2841        // as does anything else sitting at a content start.
2842        // `m.end == caret` is what excludes a setext heading, whose marker is the
2843        // underline *after* the content rather than a prefix before it.
2844        let Some(marker) = marker.filter(|m| m.end == caret) else {
2845            return false;
2846        };
2847        let start = marker.start;
2848        // A closing `#` sequence is hidden too, so it can't be left behind. Only
2849        // when the tail really is one: trailing spaces alone are nothing to strip.
2850        let tail = &self.source[content_end..span.end];
2851        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2852            let kept = self.source[caret..content_end].to_string();
2853            self.splice(start, span.end, &kept, EditKind::Other);
2854            // The splice leaves the caret past the text it re-wrote; the caret
2855            // belongs where the content now starts, which is where it already was.
2856            self.caret = start;
2857            self.record_caret();
2858        } else {
2859            self.splice(start, caret, "", EditKind::Other);
2860        }
2861        true
2862    }
2863
2864    pub fn delete_forward(&mut self) {
2865        if let Some((s, e)) = self.selection() {
2866            self.splice(s, e, "", EditKind::Other);
2867        } else if self.caret < self.source.len() {
2868            // The mirror of Backspace's: forward-delete in front of a picture
2869            // would eat the `!` off its markup and leave a link where a photo was.
2870            if self.view != View::Source && self.delete_around_block_media(true) {
2871                return;
2872            }
2873            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2874            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
2875            // strands a broken `<br` in the cell.
2876            if self.view != View::Source
2877                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
2878            {
2879                self.splice(start, end, "", EditKind::Delete);
2880                return;
2881            }
2882            // The mirror of Backspace's two steps: from in front of a run's
2883            // opening `**` step into it, onto the first letter of its text, and
2884            // at the end of a run's text step out past its closing `**` to the
2885            // character beyond. Either way Delete takes the character it looks
2886            // like it is pointing at, and never a delimiter drawn as nothing.
2887            // The caret then settles back inside the run it was standing in —
2888            // see `settle_inside_close_delims`.
2889            let from = if self.view == View::Source {
2890                self.caret
2891            } else {
2892                let inside = self.step_inside_open_delims(self.caret);
2893                self.skip_trailing_close_delims(inside)
2894            };
2895            let next = next_boundary(&self.source, from);
2896            if from < next {
2897                self.splice(from, next, "", EditKind::Delete);
2898            }
2899        }
2900    }
2901
2902    /// Delete from the caret back to the start of the previous word (⌥⌫ /
2903    /// Ctrl+⌫). Deletes the selection instead when one is active.
2904    pub fn delete_word_back(&mut self) {
2905        if let Some((s, e)) = self.selection() {
2906            self.splice(s, e, "", EditKind::Other);
2907        } else {
2908            // A word back from just past a picture is a word *of its markup*, and
2909            // a word back from in front of one runs through the paragraph break
2910            // into the prose above — dissolving the picture either way. See
2911            // `delete_around_block_media`.
2912            if self.view != View::Source && self.delete_around_block_media(false) {
2913                return;
2914            }
2915            let start = self.word_left_from(self.caret).max(self.caret_floor());
2916            if start < self.caret {
2917                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2918                self.splice(s, e, "", EditKind::Delete);
2919            }
2920        }
2921    }
2922
2923    /// Delete from the caret forward to the end of the next word (⌥⌦ /
2924    /// Ctrl+Del). Deletes the selection instead when one is active.
2925    pub fn delete_word_forward(&mut self) {
2926        if let Some((s, e)) = self.selection() {
2927            self.splice(s, e, "", EditKind::Other);
2928        } else {
2929            // The mirror: a word forward from in front of a picture is its markup.
2930            if self.view != View::Source && self.delete_around_block_media(true) {
2931                return;
2932            }
2933            let end = self.word_right_from(self.caret);
2934            if end > self.caret {
2935                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2936                self.splice(s, e, "", EditKind::Delete);
2937            }
2938        }
2939    }
2940
2941    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
2942    /// selection instead when one is active, as every other delete here does.
2943    ///
2944    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
2945    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
2946    /// stops at the first character and this takes the indentation with it, the
2947    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
2948    /// leave an indent behind that nothing can then ask to delete, where a caret
2949    /// left at column 0 is one press of Home away from either.
2950    pub fn delete_to_line_start(&mut self) {
2951        if let Some((s, e)) = self.selection() {
2952            self.splice(s, e, "", EditKind::Other);
2953            return;
2954        }
2955        // Never back across the floor: hidden frontmatter isn't on this line, or
2956        // on any line the WYSIWYG caret can see.
2957        let (start, _) = self.line_span();
2958        let start = start.max(self.caret_floor());
2959        if start < self.caret {
2960            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2961            self.splice(s, e, "", EditKind::Delete);
2962        }
2963    }
2964
2965    /// Kill from the caret to the end of its line (^K). Deletes the selection
2966    /// instead when one is active.
2967    ///
2968    /// At the end of the line it does nothing, rather than pulling the line
2969    /// below up into this one. Joining has no meaning to give it in both views
2970    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
2971    /// there is nothing there to delete, while the newline a *source* line ends
2972    /// with is only half of the blank line that separates two paragraphs —
2973    /// deleting one leaves a soft break, which is not the join it looks like.
2974    /// The views agreeing is worth more than emacs' second press, and Delete is
2975    /// already the key that joins.
2976    pub fn delete_to_line_end(&mut self) {
2977        if let Some((s, e)) = self.selection() {
2978            self.splice(s, e, "", EditKind::Other);
2979            return;
2980        }
2981        let (_, end) = self.line_span();
2982        if end > self.caret {
2983            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
2984            self.splice(s, e, "", EditKind::Delete);
2985        }
2986    }
2987
2988    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
2989    ///
2990    /// A glyph-space range covers what the user can see, which for `**bold**` is
2991    /// the word and never the delimiters around it — so deleting the word on its
2992    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
2993    /// word, and the styling was the word's; the two go together. Only the
2994    /// node's delimiters are taken, and those are hidden here anyway, so nothing
2995    /// visible outside the range is lost.
2996    ///
2997    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
2998    /// the strong, and only then is the strong empty too.
2999    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3000        if self.view == View::Source {
3001            return (start, end);
3002        }
3003        let nodes = self.nodes();
3004        let (mut s, mut e) = (start, end);
3005        loop {
3006            let mut grew = false;
3007            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3008                let Some(text) = inline_content_span(n, &self.source) else {
3009                    continue;
3010                };
3011                // Some of its text survives, so the node still has a job.
3012                if text.start < s || text.end > e {
3013                    continue;
3014                }
3015                if n.span.start < s || n.span.end > e {
3016                    s = s.min(n.span.start);
3017                    e = e.max(n.span.end);
3018                    grew = true;
3019                }
3020            }
3021            if !grew {
3022                return (s, e);
3023            }
3024        }
3025    }
3026
3027    /// One splice of document text, keeping the **mark-edge rule**: an inline
3028    /// mark's content never begins or ends with whitespace. In Markdown and Djot
3029    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3030    /// is four literal asterisks around a word, and a rich view drawing the
3031    /// document faithfully has no choice but to show them. That is correct
3032    /// rendering of what the file says, and nobody typing a space after a bold
3033    /// word meant to say it.
3034    ///
3035    /// So the space goes *outside* the run instead — `**bold** ` — which is the
3036    /// same document to a reader and a live one to a parser. The caret follows it
3037    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3038    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3039    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3040    ///
3041    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3042    /// through here, so the rule holds however the whitespace arrives at the
3043    /// edge. The repair is decided *after* the plain edit, by asking whether the
3044    /// mark actually died: a code span's backticks aren't whitespace-sensitive
3045    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3046    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3047        let fix = self.mark_edge_fix(start, end, text);
3048        if !self.splice_exact(start, end, text, kind) {
3049            return false;
3050        }
3051        if let Some(fix) = fix {
3052            self.repair_mark_edges(fix);
3053        }
3054        if text.is_empty() && end > start {
3055            self.settle_inside_close_delims();
3056        }
3057        true
3058    }
3059
3060    /// After a delete, take a caret left standing past a run's closing delimiters
3061    /// back inside the run.
3062    ///
3063    /// A delete leaves the caret where the deleted bytes began, and when those
3064    /// bytes were the last thing after a marked phrase — the space the mark-edge
3065    /// rule pushed out of `**bold** `, say — that spot is the far side of the
3066    /// closing `**`. The rich view has nothing to draw there: the delimiters are
3067    /// hidden, so the caret shows at the end of the word either way, and the two
3068    /// offsets are one place on screen with two different meanings. Typing at the
3069    /// outer one lands past the run, so the writer who backspaced a space out of
3070    /// their bold phrase watches the next character come out plain, and the
3071    /// toolbar button go dark, with the caret never appearing to move.
3072    ///
3073    /// The end of the run's text is the caret's home there — a delete that took
3074    /// away everything after a phrase leaves the caret at the end of that phrase,
3075    /// which is inside it — so it settles onto that
3076    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3077    /// walk, through every mark closing at the point): the word stays bold, the
3078    /// button stays lit, and the next character carries on the phrase.
3079    ///
3080    /// Rich view only, and only where a mark really closes at the caret — mid-run
3081    /// or in plain prose no span ends there and the caret stays put. The opening
3082    /// edge is left alone on purpose: a caret in front of a run inherits from the
3083    /// text on its left, which is the plain text outside.
3084    fn settle_inside_close_delims(&mut self) {
3085        if self.view != View::Wysiwyg {
3086            return;
3087        }
3088        let at = self.step_inside_close_delims(self.caret);
3089        if at != self.caret {
3090            self.caret = at;
3091            self.clear_pending();
3092            self.record_caret();
3093        }
3094    }
3095
3096    /// The splice exactly as asked, with no mark-edge repair — for the callers
3097    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3098    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3099    /// the bytes they inserted.
3100    ///
3101    /// One `edit_range` through twig, then re-anchor the caret from the returned
3102    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3103    /// Markdown/Djot) leaves the document untouched and reports.
3104    ///
3105    /// Returns whether the edit landed — for a caller that has offsets of its
3106    /// own to place afterwards, which a rolled-back splice would leave pointing
3107    /// into text that never came to exist.
3108    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3109        // The read-only gate, for every edit at once — see the field.
3110        if self.read_only {
3111            return false;
3112        }
3113        // twig records an undo step for every edit; when this one continues a
3114        // run of the same kind (typing, deleting), tell twig to fold it into the
3115        // step before it so the whole run undoes at once.
3116        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3117        // Hand twig the pre-edit caret before the splice, so the undo step it
3118        // retires carries where the caret was standing.
3119        self.record_caret();
3120        match self.editor.edit_range(start, end, text) {
3121            Ok(change) => {
3122                if coalesce {
3123                    let _ = self.editor.coalesce_last_undo();
3124                }
3125                self.last_edit_kind = Some(kind);
3126                self.refresh();
3127                self.caret = change.new.end;
3128                self.anchor = None;
3129                self.goal_col = None;
3130                self.clear_pending();
3131                self.dirty = self.source != self.clean_source;
3132                self.status = None;
3133                // And the post-edit caret, so a later redo restores it.
3134                self.record_caret();
3135                true
3136            }
3137            // The edit was rolled back, so twig's history did not move and
3138            // neither may ours: pushing here would leave a step with no edit
3139            // under it and shift every later undo onto the wrong caret.
3140            Err(e) => {
3141                self.status = Some(format!("edit: {e}"));
3142                false
3143            }
3144        }
3145    }
3146
3147    /// The re-spelling that would keep the mark-edge rule for the edit
3148    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3149    /// against a delimiter and the plain splice is already right. Computed
3150    /// *before* the edit, while the run's spans and delimiters can still be read
3151    /// off the document; applied afterwards, and only if the mark really died —
3152    /// see [`repair_mark_edges`](Self::repair_mark_edges).
3153    ///
3154    /// Rich view only. Source view is for typing raw markup, where a space put
3155    /// against a `**` is exactly the character it looks like.
3156    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3157        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3158            return None;
3159        }
3160        // Every inline mark standing over the edit, outermost first, with the
3161        // content span that says where its delimiters are.
3162        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3163            .editor
3164            .ancestors_at(start)
3165            .unwrap_or_default()
3166            .into_iter()
3167            .filter_map(|m| {
3168                let kind = inline_kind(&m.kind)?;
3169                let content = m.content_span.clone()?;
3170                Some((kind, m.span.clone(), content))
3171            })
3172            .collect();
3173        // The innermost run whose *content* holds the whole edit: the one whose
3174        // text is being changed, rather than one the edit merely sits under.
3175        let (kind, span, content) = chain
3176            .iter()
3177            .rev()
3178            .find(|(_, _, c)| c.start <= start && end <= c.end)?
3179            .clone();
3180        // What that content becomes. Whitespace at either end of it is what
3181        // would put out the mark.
3182        let body = format!(
3183            "{}{text}{}",
3184            &self.source[content.start..start],
3185            &self.source[end..content.end]
3186        );
3187        let (lead, trail) = if body.trim().is_empty() {
3188            // Nothing but whitespace left: there is no content to mark at all,
3189            // and the delimiters go with it rather than closing on a space.
3190            (body.len(), 0)
3191        } else {
3192            (
3193                body.len() - body.trim_start().len(),
3194                body.len() - body.trim_end().len(),
3195            )
3196        };
3197        // Nothing against a delimiter, and something still between them: the
3198        // plain edit stands. An emptied run is broken just as surely (`**b**`
3199        // with the `b` deleted is the literal `****`) and is re-spelt as the
3200        // nothing it now says.
3201        if lead == 0 && trail == 0 && !body.is_empty() {
3202            return None;
3203        }
3204        // Marks that open or close exactly where this one does — `***both***` is
3205        // two runs sharing an edge — spell their delimiters as one run of bytes,
3206        // so the whitespace has to clear all of them together.
3207        let (mut open_at, mut close_at) = (span.start, span.end);
3208        for _ in 0..chain.len() {
3209            match chain.iter().find(|(_, _, c)| c.start == open_at) {
3210                Some((_, s, _)) => open_at = s.start,
3211                None => break,
3212            }
3213        }
3214        for _ in 0..chain.len() {
3215            match chain.iter().find(|(_, _, c)| c.end == close_at) {
3216                Some((_, s, _)) => close_at = s.end,
3217                None => break,
3218            }
3219        }
3220        let open = &self.source[open_at..content.start];
3221        let close = &self.source[content.end..close_at];
3222        let core = &body[lead..body.len() - trail];
3223        let respelt = if core.is_empty() {
3224            body.clone()
3225        } else {
3226            format!(
3227                "{}{open}{core}{close}{}",
3228                &body[..lead],
3229                &body[body.len() - trail..]
3230            )
3231        };
3232        // The caret sits just past the inserted text within the new content —
3233        // which, when that lands in the whitespace, is now outside the delimiters.
3234        let pos = (start - content.start) + text.len();
3235        let caret = if core.is_empty() || pos <= lead {
3236            open_at + pos
3237        } else if pos >= lead + core.len() {
3238            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3239        } else {
3240            open_at + lead + open.len() + (pos - lead)
3241        };
3242        Some(MarkEdgeFix {
3243            kind,
3244            probe: content.start,
3245            start: open_at,
3246            end: close_at + text.len() - (end - start),
3247            text: respelt,
3248            caret,
3249            // The marks in force here, resolved against any armed sticky delta —
3250            // what the writer is typing in, and so what has to still be true on
3251            // the far side of the delimiter the caret just stepped over.
3252            want: chain
3253                .iter()
3254                .filter(|(_, s, _)| start < s.end)
3255                .map(|(k, _, _)| *k)
3256                .collect::<InlineMarks>()
3257                .xor(self.pending_here()),
3258        })
3259    }
3260
3261    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3262    /// did break the mark. Whether whitespace at a delimiter is fatal is the
3263    /// format's business, not leaf's: `**bold **` is no longer strong, while
3264    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3265    /// don't care either. Asking the parser afterwards settles it for every kind
3266    /// and format at once, and costs a re-spelling only where one is due.
3267    ///
3268    /// The repair rides along with the edit that caused it — one undo step puts
3269    /// back what the writer typed, not a delimiter shuffle they never saw.
3270    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3271        if fix.end > self.source.len() {
3272            return;
3273        }
3274        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3275            return; // still a mark: these delimiters don't mind the whitespace
3276        }
3277        let resumed = self.last_edit_kind;
3278        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3279            return;
3280        }
3281        let _ = self.editor.coalesce_last_undo();
3282        // The keystroke owns the undo step, so the run of typing it belongs to
3283        // keeps coalescing over the repair rather than breaking in two here.
3284        self.last_edit_kind = resumed;
3285        self.caret = fix.caret.min(self.source.len());
3286        self.anchor = None;
3287        self.goal_col = None;
3288        self.rearm(fix.want);
3289        self.clamp_caret();
3290        self.record_caret();
3291    }
3292
3293    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3294    /// writer is typing in, carried across an edit that moved the caret out of
3295    /// the run holding them. Arms nothing when the caret already stands in
3296    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3297    /// a clean delta here (see [`toggle`](Self::toggle)).
3298    fn rearm(&mut self, want: InlineMarks) {
3299        let here: InlineMarks = self
3300            .marks_at(self.caret)
3301            .into_iter()
3302            .map(|(k, _)| k)
3303            .collect();
3304        self.pending_marks = want.xor(here);
3305        self.pending_at = Some(self.caret);
3306    }
3307
3308    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3309    /// which backslash-escapes any character that would otherwise open markup in
3310    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3311    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3312    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3313    /// a collapsed point — a selection is deleted by the caller first, since
3314    /// `insert_literal` inserts rather than replaces.
3315    fn insert_literal_at(
3316        &mut self,
3317        at: usize,
3318        text: &str,
3319        kind: EditKind,
3320        force_coalesce: bool,
3321    ) -> bool {
3322        // `force_coalesce` folds this into the immediately preceding edit (the
3323        // selection-delete of an overwrite) so the pair is one undo step; else it
3324        // coalesces only when it continues a run of the same-kind typing.
3325        let coalesce =
3326            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3327        // The mark-edge rule holds for typed text however it is spelled — see
3328        // `splice`. Only an insert twig passed through unchanged can use it,
3329        // since a fix is measured in the bytes that actually land, and an escape
3330        // adds bytes this couldn't have counted.
3331        let fix = self.mark_edge_fix(at, at, text);
3332        self.record_caret();
3333        match self.editor.insert_literal(at, text) {
3334            Ok(change) => {
3335                if coalesce {
3336                    let _ = self.editor.coalesce_last_undo();
3337                }
3338                self.last_edit_kind = Some(kind);
3339                self.refresh();
3340                self.caret = change.new.end;
3341                self.anchor = None;
3342                self.goal_col = None;
3343                self.clear_pending();
3344                self.dirty = self.source != self.clean_source;
3345                self.status = None;
3346                self.record_caret();
3347                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3348                    self.repair_mark_edges(fix);
3349                }
3350                true
3351            }
3352            Err(e) => {
3353                self.status = Some(format!("edit: {e}"));
3354                false
3355            }
3356        }
3357    }
3358
3359    /// After a structural list edit (a new item, a nest/unnest), renumber the
3360    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3361    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3362    /// renumber as its own edit; fold it into the edit that triggered it so the
3363    /// two undo as one, and only when it actually changed the source (a no-op or
3364    /// a caret outside any ordered list must not coalesce the real edit into the
3365    /// step before it).
3366    fn renumber_here(&mut self) {
3367        self.renumber_at(self.caret);
3368    }
3369
3370    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3371    /// caret — for an edit that leaves the caret one past the item it just wrote,
3372    /// where twig resolves no list to renumber.
3373    fn renumber_at(&mut self, off: usize) {
3374        let before = self.source.clone();
3375        if self.editor.renumber_ordered_lists(off).is_err() {
3376            return; // not inside an ordered list — nothing to renumber
3377        }
3378        self.refresh();
3379        if self.source != before {
3380            let _ = self.editor.coalesce_last_undo();
3381            self.dirty = self.source != self.clean_source;
3382            self.clamp_caret();
3383            self.record_caret();
3384        }
3385    }
3386
3387    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3388    /// sub-item written directly beneath a text line reparses that text as a
3389    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
3390    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3391    /// bullets can't underline anything, so swap the dash for a `*`: the item
3392    /// stays an empty nested bullet, the parent stays prose, and the source
3393    /// round-trips instead of hiding a heading the user never asked for. Folded
3394    /// into the triggering edit's undo step, the way renumbering is.
3395    ///
3396    /// Gated on the collapse having actually happened (the swapped dash was
3397    /// swallowed into a `heading`), so a real setext heading the author wrote —
3398    /// or a `- x` with content, which can't underline anything — is never
3399    /// touched. This has to live in the *edit*, not the renderer: leaving the
3400    /// hazardous bytes on disk and only painting over them would ship a file
3401    /// every other CommonMark tool reads as a heading.
3402    ///
3403    /// This one keeps its own byte scan, and has to: the hazard is precisely
3404    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3405    /// which asks twig which lines open an item — reports nothing here. There is
3406    /// no node to ask about. It is also the last Markdown spelling leaf writes on
3407    /// purpose rather than for want of an answer; once twig spells continuations
3408    /// itself, avoiding the trap becomes twig's, and this goes.
3409    ///
3410    /// [`list_marker_on_line`]: Self::list_marker_on_line
3411    fn avoid_setext_collapse(&mut self) {
3412        let caret = self.caret.min(self.source.len());
3413        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3414        let bytes = self.source.as_bytes();
3415        let mut dash = line_start;
3416        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3417            dash += 1;
3418        }
3419        // A dash bullet is the only marker that doubles as a setext underline.
3420        if bytes.get(dash) != Some(&b'-') {
3421            return;
3422        }
3423        // Only an *empty* item is a bare underline; `- x` carries content and
3424        // can't fold the line above into a heading.
3425        let line_end = self.source[dash..]
3426            .find('\n')
3427            .map_or(self.source.len(), |i| dash + i);
3428        if !self.source[dash + 1..line_end].trim().is_empty() {
3429            return;
3430        }
3431        // The tell: that dash was swallowed into a `heading`. A properly nested
3432        // empty item sits under a `list_item`, with no heading in reach. Probe
3433        // the dash byte itself (well inside the heading), not the caret, whose
3434        // end-of-line offset can fall on the half-open span boundary.
3435        let collapsed = self
3436            .editor
3437            .ancestors_at(dash)
3438            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3439            .unwrap_or(false);
3440        if !collapsed {
3441            return;
3442        }
3443        let caret = self.caret;
3444        if self.splice(dash, dash + 1, "*", EditKind::Other) {
3445            // Same width, so the caret keeps its column; fold into the edit that
3446            // triggered this so Tab stays one undo step.
3447            let _ = self.editor.coalesce_last_undo();
3448            self.caret = caret.min(self.source.len());
3449            self.clamp_caret();
3450            self.record_caret();
3451        }
3452    }
3453
3454    fn snapshot(&self) -> CaretState {
3455        CaretState {
3456            caret: self.caret,
3457            anchor: self.anchor,
3458        }
3459    }
3460
3461    /// Hand twig the current caret and selection as the blob for the live
3462    /// document state. Called before an edit — so the step twig retires records
3463    /// where the caret was, and undo can restore it — and again once the op has
3464    /// placed the caret, so redo restores where the edit left it.
3465    ///
3466    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3467    /// caret through its own history, so coalescing falls out for free (folding
3468    /// two twig steps into one drops the intermediate blob, keeping the run's
3469    /// first) and the parallel stacks that had to march in lockstep — and could
3470    /// silently drift out of it — are gone.
3471    fn record_caret(&mut self) {
3472        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3473    }
3474
3475    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3476    /// the toggled region selected so a second press cleanly reverses it.
3477    pub fn toggle(&mut self, kind: InlineKind) {
3478        // Ahead of the no-selection branch below: arming a mark for text not yet
3479        // typed is a promise `insert` cannot keep in a format with no delimiters
3480        // to spell it with. Per *kind*, not per format — Markdown spells three
3481        // of the eight marks, djot all eight, HTML seven.
3482        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3483            return;
3484        }
3485        let Some((s, e)) = self.selection() else {
3486            // No selection: arm the mark for the next text typed here, the way a
3487            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3488            // in the flow of typing without ever selecting anything — the delta
3489            // is realised onto the freshly typed text by `insert`. A fresh caret
3490            // position starts the delta over from the marks actually in force.
3491            if self.pending_at != Some(self.caret) {
3492                self.pending_marks = InlineMarks::empty();
3493                self.pending_at = Some(self.caret);
3494            }
3495            self.pending_marks.flip(kind);
3496            self.status = None;
3497            return;
3498        };
3499        // Whitespace at the edge of a selection is not part of what was chosen —
3500        // a double-click takes the space after the word with it — and a mark
3501        // cannot close against one anyway: `**word **` is four literal asterisks
3502        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3503        let picked = &self.source[s..e];
3504        let (s, e) = (
3505            s + (picked.len() - picked.trim_start().len()),
3506            e - (picked.len() - picked.trim_end().len()),
3507        );
3508        if s >= e {
3509            self.status = Some(format!("{kind:?}: nothing selected to mark"));
3510            return;
3511        }
3512        // Styling a selection is a one-shot act, not a sticky mode.
3513        self.clear_pending();
3514        self.record_caret();
3515        match self.editor.toggle_inline(s, e, kind) {
3516            Ok(change) => {
3517                self.last_edit_kind = None; // structural edit is its own undo step
3518                self.refresh();
3519                self.anchor = Some(change.new.start);
3520                self.caret = change.new.end;
3521                self.dirty = self.source != self.clean_source;
3522                self.status = None;
3523                self.record_caret();
3524            }
3525            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3526        }
3527    }
3528
3529    /// Convert the block at the caret to a heading level or paragraph.
3530    pub fn set_block(&mut self, kind: BlockKind) {
3531        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3532            return;
3533        }
3534        self.record_caret();
3535        // A blank line has no node to convert, and twig opens a block there
3536        // rather than declining — so the caret's own offset is the right thing
3537        // to hand it when `block_offset_for_caret` finds nothing.
3538        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3539        match self.editor.set_block(offset, kind) {
3540            Ok(change) => {
3541                self.last_edit_kind = None;
3542                self.refresh();
3543                // Opening a block on a blank line writes a marker the caret
3544                // belongs *after*; converting an existing one moves nothing.
3545                self.caret = self.caret.max(change.new.end);
3546                self.clamp_caret();
3547                self.anchor = None;
3548                self.dirty = self.source != self.clean_source;
3549                self.status = None;
3550                self.record_caret();
3551            }
3552            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3553        }
3554    }
3555
3556    /// Whether `off` is inside a text block (paragraph, heading, code block…).
3557    fn has_block_at(&mut self, off: usize) -> bool {
3558        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3559            chain
3560                .iter()
3561                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3562        })
3563    }
3564
3565    /// The offset to hand twig's `set_block`: the caret when it is already inside
3566    /// a block, otherwise nudged onto the previous character (a caret at a line
3567    /// end sits at the doc level, outside the block). `None` when the caret is on
3568    /// a blank line — a new paragraph with no block node to convert.
3569    fn block_offset_for_caret(&mut self) -> Option<usize> {
3570        let caret = self.caret.min(self.source.len());
3571        if self.has_block_at(caret) {
3572            return Some(caret);
3573        }
3574        // Nudge to the previous character — but never across a newline: that would
3575        // target the previous block, and a blank line genuinely has no block.
3576        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3577            && ch != '\n'
3578            && self.has_block_at(i)
3579        {
3580            return Some(i);
3581        }
3582        None
3583    }
3584
3585    /// The heading level of the text block at the caret, or `None` when that
3586    /// block is not a heading.
3587    pub fn current_heading_level(&mut self) -> Option<u32> {
3588        let caret = self.caret;
3589        self.nodes()
3590            .into_iter()
3591            .filter(|n| n.kind == Kind::Heading)
3592            .find(|n| n.span.start <= caret && caret <= n.span.end)
3593            .and_then(|n| n.level)
3594    }
3595
3596    /// The inline marks in force at the caret (or over the selection) — what a
3597    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3598    /// inline counterpart. Cheap enough to call every frame: one twig
3599    /// `ancestors_at` query per caret (two with a selection), each walking root
3600    /// → deepest node at one offset. It never snapshots the tree the way
3601    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3602    /// the only allocation is twig's own small ancestor `Vec`.
3603    ///
3604    /// **A selection reports a mark only when the mark covers *all* of it.**
3605    /// That's what every real toolbar means by an active button — Bold lit over
3606    /// a half-bold selection would claim a press turns bold *off*, when
3607    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3608    /// Whole-coverage is asked as "is the same mark node standing over both the
3609    /// first and the last character?": inline nodes are contiguous, so one node
3610    /// covering both ends covers every byte between them. Two touching runs
3611    /// (`**a****b**`) are two nodes, and correctly light nothing.
3612    ///
3613    /// At a bare caret a mark is active when the caret stands inside the mark's
3614    /// span — `span.start <= caret < span.end`, delimiters included, which is
3615    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3616    /// opening `*` (2) through the last byte of the closing `**` (9) are all
3617    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3618    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3619    /// typing would actually land inside the marked run. The offset one past the
3620    /// mark (10) is the text after it and reports nothing, at the end of the
3621    /// buffer exactly as in the middle.
3622    pub fn active_inline_marks(&mut self) -> InlineMarks {
3623        let Some((start, end)) = self.selection() else {
3624            // The marks actually in force at the caret, flipped by any armed
3625            // sticky delta — so `⌘b` at a bare caret lights the Bold button
3626            // immediately, before a single character is typed.
3627            let base: InlineMarks = self
3628                .marks_at(self.caret)
3629                .into_iter()
3630                .map(|(k, _)| k)
3631                .collect();
3632            return base.xor(self.pending_here());
3633        };
3634        // The selection's *last character*, not its exclusive end: `end` is the
3635        // offset one past the selection, which for a selection ending exactly at
3636        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3637        // entirely bold, but offset 10 is the space after).
3638        let last = prev_boundary(&self.source, end);
3639        let head = self.marks_at(start);
3640        let tail = self.marks_at(last);
3641        head.into_iter()
3642            .filter(|m| tail.contains(m))
3643            .map(|(k, _)| k)
3644            .collect()
3645    }
3646
3647    /// The inline marks whose span covers `off`, each with the id of the node
3648    /// carrying it — the id is what lets a selection tell one mark node from
3649    /// another of the same kind.
3650    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3651        let off = off.min(self.source.len());
3652        self.editor
3653            .ancestors_at(off)
3654            .unwrap_or_default()
3655            .into_iter()
3656            // `span.end` is the offset one *past* the mark, so it isn't in it.
3657            // twig already resolves a boundary to whatever starts there — in
3658            // `**bold** x` offset 8 is the following text, not the strong — but
3659            // when nothing follows, the tie has nobody to break for and the
3660            // chain still ends at the mark. That would make the answer at the
3661            // last offset of the document depend on whether the file happens to
3662            // end in a newline; the rule is `span.start <= off < span.end`, and
3663            // it's the same rule at the end of a buffer as in the middle.
3664            .filter(|m| off < m.span.end)
3665            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3666            .collect()
3667    }
3668
3669    /// Toggle a heading at the caret: if the block is already this heading level,
3670    /// revert it to a paragraph; otherwise convert it to this heading level.
3671    /// This gives the heading commands the same toggle feel as bold/italic/code —
3672    /// re-applying a heading a line already has turns it back into body text.
3673    pub fn toggle_heading(&mut self, level: u32) {
3674        if self.current_heading_level() == Some(level) {
3675            self.set_block(BlockKind::Paragraph);
3676        } else {
3677            self.set_block(BlockKind::Heading(level));
3678        }
3679    }
3680
3681    /// Toggle a block quote around the selection, or around the block at the
3682    /// caret — the toolbar's Quote button.
3683    pub fn toggle_blockquote(&mut self) {
3684        self.toggle_container(BlockContainerKind::BlockQuote);
3685    }
3686
3687    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
3688    /// over the block at the caret — one op with the kind as a flag, the way
3689    /// `toggle_heading` takes its level, so a frontend needs no twig type to
3690    /// name the two buttons.
3691    ///
3692    /// Pressing the *other* list's button while in a list converts in place
3693    /// rather than nesting, so the pair reads as one three-state control
3694    /// (bulleted / numbered / neither) rather than two independent wrappers.
3695    pub fn toggle_list(&mut self, ordered: bool) {
3696        self.toggle_container(if ordered {
3697            BlockContainerKind::OrderedList
3698        } else {
3699            BlockContainerKind::BulletList
3700        });
3701    }
3702
3703    // ── Task list items ──────────────────────────────────────────────────────
3704    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
3705    // inline content of the item's first paragraph rather than part of its
3706    // marker, so adding or removing one must leave the item's continuation
3707    // indentation alone, and an item inside a quote is found past the quote
3708    // markers. leaf names the gesture and the offset; the spelling is twig's.
3709
3710    /// Whether the list item at the caret carries a checkbox, and which way it
3711    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
3712    /// item or no item at all. What a toolbar reads to light its checkbox button.
3713    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
3714        self.task_checked_at(self.caret)
3715    }
3716
3717    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
3718    /// offset — what a frontend asks before deciding a click landed on a box.
3719    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
3720        self.innermost_list_item(offset.min(self.source.len()))?
3721            .checked
3722    }
3723
3724    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
3725    /// A no-op with a reported reason when the caret is in no task item — minting
3726    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
3727    pub fn toggle_task_checked(&mut self) {
3728        self.toggle_task_at(self.caret);
3729    }
3730
3731    /// Tick or untick the task item covering `offset` — what a *click* on a
3732    /// rendered checkbox is. Separate from the caret form because a click carries
3733    /// its own offset and must not first move the caret there: ticking a box
3734    /// three paragraphs away should not take the cursor with it.
3735    pub fn toggle_task_at(&mut self, offset: usize) {
3736        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
3737            return;
3738        }
3739        let offset = offset.min(self.source.len());
3740        self.record_caret();
3741        match self.editor.toggle_task_checked(offset) {
3742            Ok(_) => self.after_task_edit(),
3743            Err(e) => self.status = Some(format!("task: {e}")),
3744        }
3745    }
3746
3747    /// Give the list item at the caret a checkbox, or take its checkbox away —
3748    /// the gesture that converts between a plain bullet and a task. A new box
3749    /// arrives unticked.
3750    pub fn toggle_task_item(&mut self) {
3751        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
3752            return;
3753        }
3754        let caret = self.caret.min(self.source.len());
3755        self.record_caret();
3756        match self.editor.toggle_task_item(caret) {
3757            Ok(_) => self.after_task_edit(),
3758            Err(e) => self.status = Some(format!("task: {e}")),
3759        }
3760    }
3761
3762    /// Settle after a task gesture. The caret rides its old byte offset and is
3763    /// clamped back in: a box is three or four bytes on the item's first line, so
3764    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
3765    /// real stop either way.
3766    fn after_task_edit(&mut self) {
3767        self.last_edit_kind = None;
3768        self.refresh();
3769        self.anchor = None;
3770        self.dirty = self.source != self.clean_source;
3771        self.status = None;
3772        self.clamp_caret();
3773        self.record_caret();
3774    }
3775
3776    // ── Tables ───────────────────────────────────────────────────────────────
3777    // A table is a grid, and twig edits it as one — add/remove/move a row or
3778    // column, set a column's alignment — re-spelling the whole table in a single
3779    // splice. Every gesture is anchored at the caret's cell. leaf just names the
3780    // gesture and re-reads the result; the whole table's numbering, borders, and
3781    // delimiter are twig's to keep straight.
3782
3783    /// Whether the caret is inside a table — what a frontend asks to enable or
3784    /// disable its table controls.
3785    ///
3786    /// An HTML `<table>` still answers `true`: the caret really is in a table,
3787    /// and the reason the grid controls stay dark there is
3788    /// [`Capabilities::table`], which is a fact about the document's format
3789    /// rather than about the caret. A frontend needs both.
3790    pub fn caret_in_table(&mut self) -> bool {
3791        let caret = self.caret.min(self.source.len());
3792        self.editor
3793            .ancestors_at(caret)
3794            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
3795            .unwrap_or(false)
3796    }
3797
3798    /// One grid op, guarded and settled — the shared body of the seven below.
3799    ///
3800    /// The guard is why this exists rather than seven copies of the same three
3801    /// lines, and it is the one guard leaf cannot delegate to twig. The table
3802    /// editor is the gesture family that consults no `Syntax` table (it spells a
3803    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
3804    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
3805    /// grid as a *pipe table* and reports success, swapping the element out for
3806    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
3807    /// downstream could tell that from a successful edit — the splice is real,
3808    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
3809    /// stopping at the door rather than detecting after the fact. See
3810    /// [`spells_pipe_tables`].
3811    fn table_op(
3812        &mut self,
3813        what: &str,
3814        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
3815    ) {
3816        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
3817            return;
3818        }
3819        self.record_caret();
3820        let at = self.caret;
3821        let r = op(&mut self.editor, at);
3822        self.apply_table(r, what);
3823    }
3824
3825    /// Insert an empty row below (`below`) or above the caret's row.
3826    pub fn table_insert_row(&mut self, below: bool) {
3827        self.table_op("table row", |e, at| e.table_insert_row(at, below));
3828    }
3829
3830    /// Delete the caret's row (not the header, not the last body row).
3831    pub fn table_delete_row(&mut self) {
3832        self.table_op("table row", |e, at| e.table_delete_row(at));
3833    }
3834
3835    /// Insert an empty column right (`right`) or left of the caret's column.
3836    pub fn table_insert_column(&mut self, right: bool) {
3837        self.table_op("table column", |e, at| e.table_insert_column(at, right));
3838    }
3839
3840    /// Delete the caret's column (unless it is the only one).
3841    pub fn table_delete_column(&mut self) {
3842        self.table_op("table column", |e, at| e.table_delete_column(at));
3843    }
3844
3845    /// Set the caret's column to `alignment`.
3846    pub fn table_set_alignment(&mut self, alignment: Alignment) {
3847        self.table_op("table alignment", |e, at| {
3848            e.table_set_alignment(at, alignment)
3849        });
3850    }
3851
3852    /// Move the caret's row one place down (`down`) or up, within the body rows.
3853    pub fn table_move_row(&mut self, down: bool) {
3854        self.table_op("table row", |e, at| e.table_move_row(at, down));
3855    }
3856
3857    /// Move the caret's column one place right (`right`) or left.
3858    pub fn table_move_column(&mut self, right: bool) {
3859        self.table_op("table column", |e, at| e.table_move_column(at, right));
3860    }
3861
3862    /// Settle the caret and document flags after a table op (or report its
3863    /// error). twig re-spells the whole table, so the caret rides its old byte
3864    /// offset and is clamped back into the rebuilt bytes — near enough to where
3865    /// it was, since the op preserves the cells' content and order around it.
3866    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
3867        match result {
3868            Ok(()) => {
3869                self.last_edit_kind = None;
3870                self.refresh();
3871                self.anchor = None;
3872                self.clamp_caret();
3873                self.dirty = self.source != self.clean_source;
3874                self.status = None;
3875                self.record_caret();
3876            }
3877            Err(e) => self.status = Some(format!("{what}: {e}")),
3878        }
3879    }
3880
3881    /// One `toggle_block_container` over the block-level target.
3882    ///
3883    /// leaf says *where*; twig decides everything else — which blocks the range
3884    /// covers, whether that means wrapping, unwrapping, nesting or converting,
3885    /// and how this document's format spells the prefix. The rule that a
3886    /// container only comes off when the range covers every block it holds is
3887    /// what the re-anchoring below is built around.
3888    fn toggle_container(&mut self, kind: BlockContainerKind) {
3889        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
3890            return;
3891        }
3892        let selected = self.selection();
3893        // A blank line holds no block, and twig opens an *empty* container on one
3894        // — since 3.2.0; it used to decline the range with `NotFound`, which is
3895        // why this used to lend it a scratch paragraph to wrap. Worth knowing
3896        // here because the line-for-line caret mapping below cannot describe it:
3897        // opening one under a paragraph writes the blank line the format needs
3898        // above the marker too, so the rewritten region has a line the old one
3899        // didn't, and "the same line, the same distance from its end" lands on
3900        // that new blank instead of in the container.
3901        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
3902        // Without a selection the target is the caret's own block, resolved the
3903        // way `set_block` resolves it — a caret at a line end sits at the doc
3904        // level and has to be nudged back onto the block it looks like it's in.
3905        // An empty range is enough: twig widens to the whole lines it touches.
3906        let (start, end) = match selected {
3907            Some(range) => range,
3908            None => {
3909                let off = self.block_offset_for_caret().unwrap_or(self.caret);
3910                (off, off)
3911            }
3912        };
3913        self.record_caret();
3914        match self.editor.toggle_block_container(start, end, kind) {
3915            Ok(change) => {
3916                // Read the caret's place out of the *pre-edit* source, before
3917                // `refresh` swaps that source out from under it.
3918                let place = (selected.is_none() && !opened_empty)
3919                    .then(|| self.caret_line_tail(&change.old));
3920                self.last_edit_kind = None; // structural edit is its own undo step
3921                self.refresh();
3922                match place {
3923                    // Both land the caret at the far end of what twig wrote, and
3924                    // differ only in what they leave selected.
3925                    //
3926                    // From a selection: select what the container now holds, the
3927                    // way `toggle` keeps its marked region selected — and for a
3928                    // stronger reason than symmetry: a container comes *off* only
3929                    // a range covering every block it holds, so a selection left
3930                    // on its old bytes (now short by a prefix per line) would nest
3931                    // on the second press instead of reversing the first.
3932                    //
3933                    // From a blank line: nothing to select, and the end of the
3934                    // region is exactly past the bare `> ` / `- ` twig wrote —
3935                    // the caret standing inside the container that was asked for.
3936                    None => {
3937                        self.anchor = (!opened_empty).then_some(change.new.start);
3938                        self.caret = change.new.end;
3939                    }
3940                    Some(place) => {
3941                        self.anchor = None;
3942                        self.caret = self.line_tail_offset(&change.new, place);
3943                    }
3944                }
3945                self.dirty = self.source != self.clean_source;
3946                self.status = None;
3947                self.clamp_caret();
3948                self.record_caret();
3949            }
3950            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3951        }
3952    }
3953
3954    /// The caret's place inside the region a container toggle is rewriting, in
3955    /// the only terms the rewrite preserves: which of the region's lines it sits
3956    /// on, and how many bytes of that line lie ahead of it.
3957    ///
3958    /// A container's markup goes in at column 0 and never touches what follows
3959    /// on the line, so that pair survives the edit exactly where a byte offset
3960    /// does not — a caret left on its old offset slides back by one prefix per
3961    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
3962    /// `> ` it just asked for.
3963    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
3964        let caret = self.caret.clamp(old.start, old.end);
3965        let line = self.source[old.start..caret].matches('\n').count();
3966        let end = self.source[caret..old.end]
3967            .find('\n')
3968            .map_or(old.end, |i| caret + i);
3969        (line, end - caret)
3970    }
3971
3972    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
3973    /// region: the offset `tail` bytes back from the end of the region's `line`.
3974    ///
3975    /// Both walks are clamped rather than trusted, because the one op that does
3976    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
3977    /// the items back apart with blank lines between them — and a caret landing
3978    /// on the nearest line of the right item beats one landing out of the region
3979    /// entirely.
3980    fn line_tail_offset(
3981        &self,
3982        new: &std::ops::Range<usize>,
3983        (line, tail): (usize, usize),
3984    ) -> usize {
3985        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3986        let mut start = 0;
3987        for _ in 0..line {
3988            match region[start..].find('\n') {
3989                Some(i) => start += i + 1,
3990                None => break,
3991            }
3992        }
3993        let end = region[start..]
3994            .find('\n')
3995            .map_or(region.len(), |i| start + i);
3996        new.start + end.saturating_sub(tail).max(start)
3997    }
3998
3999    /// Link the selection to `destination` — the toolbar's Link button. With no
4000    /// selection it acts at the caret, which re-points a link the caret is
4001    /// already standing in (twig replaces an existing link's destination and
4002    /// keeps its text) and otherwise spells a link that has no text of its own:
4003    /// an autolink (`<https://x.dev>`) where the destination is one, and
4004    /// `[destination](destination)` where it isn't.
4005    ///
4006    /// `destination` reaches twig raw. Escaping it is format knowledge and the
4007    /// two formats genuinely disagree — Markdown ends a destination at the first
4008    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
4009    /// itself — so the side holding the document is the side that gets to spell
4010    /// it. A destination twig can't carry at all (one with a newline) comes back
4011    /// as an error rather than a quietly rewritten URL.
4012    pub fn insert_link(&mut self, destination: &str) {
4013        if self.refuse_unsupported("link", Gesture::InsertLink) {
4014            return;
4015        }
4016        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4017        self.record_caret();
4018        match self.editor.insert_link(start, end, destination) {
4019            Ok(change) => {
4020                self.last_edit_kind = None;
4021                self.refresh();
4022                match self.link_text_span(change.new.start) {
4023                    // A link with text of its own: select it, so typing replaces
4024                    // a `[dest](dest)`'s stand-in label and a second press
4025                    // re-points what the first one linked.
4026                    Some(text) => {
4027                        self.anchor = (text.start != text.end).then_some(text.start);
4028                        self.caret = text.end;
4029                    }
4030                    // An autolink is finished the moment it's written — its text
4031                    // *is* the URL. Leaving it selected would aim the next press
4032                    // at the one shape twig still wraps instead of re-points.
4033                    None => {
4034                        self.anchor = None;
4035                        self.caret = change.new.end;
4036                    }
4037                }
4038                self.dirty = self.source != self.clean_source;
4039                self.status = None;
4040                self.clamp_caret();
4041                self.record_caret();
4042            }
4043            Err(e) => self.status = Some(format!("link: {e}")),
4044        }
4045    }
4046
4047    /// Insert a block-level image at the caret: `![alt](destination)`. Any
4048    /// selection becomes the alt text (so "select a caption, insert image" labels
4049    /// it); with no selection, `alt` is used — empty for none. The caret lands
4050    /// just past the inserted image.
4051    ///
4052    /// Both halves go through twig (`insert_literal` for the alt text,
4053    /// `insert_image` for the image), so neither is spelled here. That used to be a
4054    /// `format!`, and it was wrong the first time an app inserted a real filename:
4055    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
4056    /// not an image at all — and the fix is per-format, since moving into the
4057    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
4058    pub fn insert_image(&mut self, destination: &str, alt: &str) {
4059        if self.refuse_unsupported("image", Gesture::InsertImage) {
4060            return;
4061        }
4062        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4063        self.record_caret();
4064        // With no selection and an explicit `alt`, the alt text has to exist in the
4065        // document before it can be the image's — and it is raw caller input, so
4066        // it goes in through `insert_literal`, which escapes it for the format
4067        // rather than letting a `]` in someone's caption close the image early.
4068        let (start, end) = if start == end && !alt.is_empty() {
4069            match self.editor.insert_literal(start, alt) {
4070                Ok(change) => (change.new.start, change.new.end),
4071                Err(e) => {
4072                    self.status = Some(format!("image: {e}"));
4073                    return;
4074                }
4075            }
4076        } else {
4077            (start, end)
4078        };
4079        match self.editor.insert_image(start, end, destination) {
4080            Ok(change) => {
4081                self.last_edit_kind = None;
4082                self.refresh();
4083                // Just past the image, nothing selected — where a caret belongs
4084                // after inserting one.
4085                self.anchor = None;
4086                self.caret = change.new.end;
4087                self.dirty = self.source != self.clean_source;
4088                self.status = None;
4089                self.clamp_caret();
4090                self.record_caret();
4091            }
4092            Err(e) => self.status = Some(format!("image: {e}")),
4093        }
4094    }
4095
4096    /// Insert a block-level image, video, or audio at the caret. The image case
4097    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
4098    /// HTML elements, which is the only spelling Markdown and Djot have for them:
4099    ///
4100    /// ```text
4101    /// <video src="clip.mp4" controls>alt</video>
4102    /// <audio src="take.mp3" controls>alt</audio>
4103    /// ```
4104    ///
4105    /// HTML rather than a `::video{…}` directive deliberately. A directive means
4106    /// something only to an app that knows the vocabulary, so the document would
4107    /// read as literal punctuation everywhere else; `<video>` is what every other
4108    /// renderer already understands, and what leaf's own reader picks back up
4109    /// through `html_elements` promotion (see [`parse_extensions`]).
4110    ///
4111    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
4112    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
4113    /// `html_elements`. Before that only the multi-line form parsed as a block at
4114    /// all, and this wrote three lines to work around it.
4115    ///
4116    /// `controls` is always written: a player with no transport is a still frame
4117    /// the reader can't do anything with. Any selection becomes the element's
4118    /// fallback text, exactly as it becomes an image's alt.
4119    ///
4120    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
4121    /// applies, and bites harder here: a `"` in `destination` closes the
4122    /// attribute. A frontend taking these from a file picker is fine; one taking
4123    /// them from free text should keep them tame.
4124    ///
4125    /// [`MediaInfo`]: crate::MediaInfo
4126    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
4127        if kind == MediaKind::Image {
4128            return self.insert_image(destination, alt);
4129        }
4130        // Gated on the *image* gesture, not on one of its own — there isn't one,
4131        // since the bytes below are spelled here rather than by twig, and an HTML
4132        // document would in fact parse them. The button is one control with three
4133        // kinds behind it, and two of them working in a format where the third
4134        // cannot is a worse surface than three that agree — especially as
4135        // `insert_image` is the kind anyone reaches for first.
4136        if self.refuse_unsupported("media", Gesture::InsertImage) {
4137            return;
4138        }
4139        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4140        let alt_text = self
4141            .selected_text()
4142            .map(str::to_string)
4143            .unwrap_or_else(|| alt.to_string());
4144        let tag = match kind {
4145            MediaKind::Audio => "audio",
4146            _ => "video",
4147        };
4148        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
4149        self.edit(start, end, &markup);
4150    }
4151
4152    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
4153    /// button. Spelling and placement are both twig's; leaf used to write `---`
4154    /// itself, which was the Markdown spelling in a djot document too.
4155    ///
4156    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
4157    /// mid-paragraph and lands it after the caret's whole block. To get a rule
4158    /// *at* the caret — the paragraph parted in two around it, which is what a
4159    /// rule button is understood to do — the paragraph is first divided with
4160    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
4161    /// the offset `split_block` returns puts the rule after the *second* half
4162    /// instead, which is a rule in the right document and the wrong place.
4163    ///
4164    /// Only a plain paragraph is split. Everywhere else the rule simply lands
4165    /// after the block, which is both twig's own answer and the better one:
4166    /// splitting a fenced code block would leave two fences with a rule between
4167    /// them, and splitting a list item would mint an item nobody asked for on the
4168    /// way to a rule that lands after the list regardless. A table and a setext
4169    /// heading refuse the split outright, so they take the same path by
4170    /// themselves.
4171    pub fn insert_thematic_break(&mut self) {
4172        if self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak) {
4173            return;
4174        }
4175        self.caret = self.skip_trailing_close_delims(self.caret);
4176        // A selection is replaced by the rule, so collapse it first and let the
4177        // split-and-rule below run from the caret it leaves behind.
4178        if let Some((s, e)) = self.selection() {
4179            self.splice(s, e, "", EditKind::Other);
4180        }
4181        self.anchor = None;
4182        self.record_caret();
4183        let at = self.caret;
4184        if self.caret_in_bare_paragraph() {
4185            // A failure here is not fatal: the rule still lands after the block,
4186            // which is exactly what this call was trying to improve on.
4187            let _ = self.editor.split_block(at);
4188        }
4189        match self.editor.insert_thematic_break(at) {
4190            Ok(change) => {
4191                self.last_edit_kind = None;
4192                self.refresh();
4193                self.anchor = None;
4194                self.caret = change.new.end;
4195                self.dirty = self.source != self.clean_source;
4196                self.status = None;
4197                self.clamp_caret();
4198                self.record_caret();
4199            }
4200            Err(e) => self.status = Some(format!("thematic break: {e}")),
4201        }
4202    }
4203
4204    /// Whether the caret sits in a paragraph and nothing else — no list item, no
4205    /// quote, no fence, no table. The one shape where parting the block around
4206    /// the caret is unambiguously what a rule button means; see
4207    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
4208    /// container is left to take the rule after itself.
4209    fn caret_in_bare_paragraph(&mut self) -> bool {
4210        let caret = self.caret.min(self.source.len());
4211        let Ok(chain) = self.editor.ancestors_at(caret) else {
4212            return false;
4213        };
4214        let mut in_para = false;
4215        for m in chain {
4216            match m.kind {
4217                Kind::Para => in_para = true,
4218                Kind::ListItem
4219                | Kind::TaskListItem
4220                | Kind::BlockQuote
4221                | Kind::CodeBlock
4222                | Kind::Table => return false,
4223                _ => {}
4224            }
4225        }
4226        in_para
4227    }
4228
4229    /// The destination of the link under the caret — what a Link prompt shows so
4230    /// ⌘K on an existing link edits its URL instead of asking for it again.
4231    /// `None` when the caret stands in no link.
4232    ///
4233    /// An autolink carries no separate destination: its text *is* the URL, so
4234    /// that's what comes back for one.
4235    pub fn link_destination_at_caret(&mut self) -> Option<String> {
4236        self.link_destination_at(self.caret)
4237    }
4238
4239    /// The destination of the link at `off`.
4240    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
4241    /// the caret isn't.
4242    ///
4243    /// The offset form exists for the same reason
4244    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
4245    /// the document somewhere else — a footnote's text in a popover, say — has
4246    /// rows and runs but no caret in them, and still needs to know which of those
4247    /// runs a reader can follow.
4248    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
4249        self.nodes()
4250            .into_iter()
4251            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
4252            .filter(|n| n.span.start <= off && off < n.span.end)
4253            .max_by_key(|n| n.span.start)
4254            .and_then(|n| n.destination.or(n.text))
4255    }
4256
4257    /// Where the locator `id` lands in this document — the `#v2` half of a
4258    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
4259    /// answers to it.
4260    ///
4261    /// The other end of a link, and the reason this exists: without it a
4262    /// destination has only file granularity, so following a citation into a
4263    /// chapter drops the reader at the top of it to hunt for the verse. Which is
4264    /// also why it is a *document* query rather than a caret one — the document
4265    /// being asked is usually not the one the reader is in.
4266    ///
4267    /// Three readings, tried in order, because the same `#some-heading` is
4268    /// written three ways across the formats leaf opens:
4269    ///
4270    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
4271    ///    the auto-ids djot mints for its headings. The only exact answer, so it
4272    ///    goes first — a document that says `{#v1}` has settled the question.
4273    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
4274    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
4275    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
4276    ///    authored anywhere land on a djot heading.
4277    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
4278    ///    none and `{#custom}` is literal text in a Markdown heading — so for
4279    ///    the format most vaults are written in, the heading's own words are the
4280    ///    only thing a fragment can name. This is the rule every Markdown
4281    ///    renderer already follows, which is what makes `#a-heading` mean in
4282    ///    diaryx what it means on the web.
4283    ///
4284    /// Ties go to the earliest match, then to the widest: a duplicated id is the
4285    /// document's mistake and the first one is the answer every anchor
4286    /// implementation gives, while preferring the wider span picks the section
4287    /// over the heading that opens it — more for a peek to show, same place to
4288    /// land.
4289    pub fn locate(&mut self, id: &str) -> Option<Landing> {
4290        let id = id.trim();
4291        if id.is_empty() {
4292            return None;
4293        }
4294        let nodes = self.nodes();
4295
4296        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
4297        // is picking, among nodes that start together, the one that ends last.
4298        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
4299            matches
4300                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
4301                .map(|n| Landing {
4302                    start: n.span.start,
4303                    end: n.span.end,
4304                })
4305        };
4306
4307        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
4308            return Some(landing);
4309        }
4310        let want = slug(id);
4311        if want.is_empty() {
4312            return None;
4313        }
4314        if let Some(landing) = pick(
4315            &mut nodes
4316                .iter()
4317                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
4318        ) {
4319            return Some(landing);
4320        }
4321
4322        // A heading by its words. Its span is one line, so the end comes from
4323        // where the *section* it opens gives out — the next heading that is not
4324        // under it, or the end of the document. A Markdown heading has no
4325        // section node to ask (twig only builds those for djot), and a peek that
4326        // showed the heading alone would answer "what does that say" with the
4327        // title of the thing it says.
4328        let heading = nodes
4329            .iter()
4330            .filter(|n| n.kind == Kind::Heading)
4331            .filter(|n| {
4332                n.content_span
4333                    .clone()
4334                    .and_then(|s| self.source.get(s))
4335                    .is_some_and(|text| slug(text) == want)
4336            })
4337            .min_by_key(|n| n.span.start)?;
4338        let level = heading.level.unwrap_or(u32::MAX);
4339        let end = nodes
4340            .iter()
4341            .filter(|n| n.kind == Kind::Heading)
4342            .filter(|n| n.span.start > heading.span.start)
4343            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4344            .map(|n| n.span.start)
4345            .min()
4346            .unwrap_or(self.source.len());
4347        Some(Landing {
4348            start: heading.span.start,
4349            end,
4350        })
4351    }
4352
4353    /// Write a footnote at the caret — the toolbar's Footnote button, and the
4354    /// one gesture in the footnote story that *authors* rather than follows.
4355    ///
4356    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4357    /// the `[^1]:` definition at the end of the document. Half a footnote is not
4358    /// a footnote — a bare reference with nothing defining it renders as literal
4359    /// brackets — so a single button that wrote only the reference would leave
4360    /// the author to hand-spell the other half in a document that had just
4361    /// stopped showing them what the first half meant. One edit also means one
4362    /// undo takes both back.
4363    ///
4364    /// The definition's body is left empty and **the caret lands in it**, which
4365    /// is the whole point of pressing the button: nobody wants a reference to a
4366    /// note they have not written yet. Getting back to where they were writing
4367    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4368    /// the same return leg a reader following a reference already uses, so the
4369    /// author is left standing on the near end of a round trip that works.
4370    ///
4371    /// A selection collapses to its *end* rather than being replaced: a
4372    /// reference annotates the words before it, so "select the claim, add a
4373    /// footnote" should mark that claim, not consume it.
4374    pub fn insert_footnote(&mut self) {
4375        if self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4376            return;
4377        }
4378        let at = self.selection().map_or(self.caret, |(_, end)| end);
4379        self.anchor = None;
4380        self.caret = at;
4381        self.record_caret();
4382        let label = self.next_footnote_label();
4383        match self.editor.insert_footnote(at, &label) {
4384            Ok(change) => {
4385                self.last_edit_kind = None;
4386                self.refresh();
4387                self.anchor = None;
4388                // `change.new` runs from the reference to the end of the
4389                // document, so its start is the `[^1]` just written and
4390                // `footnote_at` resolves it to the note the same way a reader's
4391                // tap does — and to the note's *body*, which is already a caret
4392                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4393                // and has none), so this needs no snap on top. The fallback is
4394                // the reference's own offset: a format that spelled the pair some
4395                // way leaf can't read back should still leave the caret on the
4396                // edit rather than at the far end of a document it just grew.
4397                self.caret = self
4398                    .footnote_at(change.new.start)
4399                    .and_then(|note| note.offset)
4400                    .unwrap_or(change.new.start);
4401                self.dirty = self.source != self.clean_source;
4402                self.status = None;
4403                self.clamp_caret();
4404                self.record_caret();
4405            }
4406            Err(e) => self.status = Some(format!("footnote: {e}")),
4407        }
4408    }
4409
4410    /// The label to give a footnote the author has not named: the lowest counting
4411    /// number no footnote in the document is already wearing.
4412    ///
4413    /// twig takes the label rather than minting one, because it holds no opinion
4414    /// about what a document's footnotes should be called — and it is right not
4415    /// to. Numbering them is what every author of a numbered note expects, and
4416    /// re-using a taken number would silently point the new reference at somebody
4417    /// else's note (twig reuses an existing definition rather than appending a
4418    /// second one, which is the right rule for citing a note twice on purpose and
4419    /// exactly the wrong accident to have by default).
4420    ///
4421    /// *References* are counted alongside definitions, not just definitions: a
4422    /// document carrying a dangling `[^2]` has a 2 that means something to
4423    /// whoever wrote it, and minting a definition for it here would answer a
4424    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4425    /// count entirely — they take no number, so they block none.
4426    fn next_footnote_label(&mut self) -> String {
4427        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4428            .into_iter()
4429            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4430            .filter_map(|label| label.parse().ok())
4431            .collect();
4432        taken.extend(
4433            self.nodes()
4434                .into_iter()
4435                .filter(|n| n.kind == Kind::FootnoteReference)
4436                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4437                .filter_map(|label| label.parse::<u32>().ok()),
4438        );
4439        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4440    }
4441
4442    /// The footnote reference under the caret, resolved to the note it names.
4443    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4444    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4445        self.footnote_at(self.caret)
4446    }
4447
4448    /// The footnote reference at `off`, resolved to the note it names — what a
4449    /// frontend shows when a reader activates a `[^1]`.
4450    ///
4451    /// A reference is not a link node, so
4452    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4453    /// (and should not) answer for one: a link names a destination to leave for,
4454    /// a reference names a note that is already in this document. Following one
4455    /// is a move within the page, which is why this hands back an `offset`
4456    /// rather than something to open.
4457    ///
4458    /// Offset-based rather than caret-only because the gesture that wants this
4459    /// most is the one that must not move the caret: a pointer hovering a `[1]`
4460    /// asks what note it names without disturbing where the reader was typing.
4461    /// The caret is just the offset a click already placed —
4462    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4463    ///
4464    /// `None` when `off` stands in no reference. A reference whose note the
4465    /// document never defines is *not* `None` — it answers with the label it
4466    /// looked for and no text, which is what lets a frontend say so instead of
4467    /// silently doing nothing.
4468    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4469        // Innermost-wins by latest start, the rule its link sibling uses.
4470        let span = self
4471            .nodes()
4472            .into_iter()
4473            .filter(|n| n.kind == Kind::FootnoteReference)
4474            .filter(|n| n.span.start <= off && off < n.span.end)
4475            .max_by_key(|n| n.span.start)?
4476            .span;
4477        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4478
4479        // The note itself. Definitions are roots beside `doc` rather than
4480        // children of it, so they're asked for directly — see
4481        // `wysiwyg::footnote_definitions`.
4482        let note = wysiwyg::footnote_definitions(&mut self.editor)
4483            .into_iter()
4484            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4485        let Some(note) = note else {
4486            return Some(FootnoteRef {
4487                label,
4488                text: None,
4489                offset: None,
4490                end: None,
4491            });
4492        };
4493        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4494        Some(FootnoteRef {
4495            label,
4496            text: body
4497                .clone()
4498                .and_then(|b| self.source.get(b))
4499                .map(str::to_string),
4500            // The body's start, not the definition's — see `FootnoteRef::offset`.
4501            offset: body.clone().map(|b| b.start),
4502            end: body.map(|b| b.end),
4503        })
4504    }
4505
4506    /// The footnote *definition* the caret stands in, and where the reference
4507    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4508    /// at the caret's offset.
4509    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4510        self.footnote_definition_at(self.caret)
4511    }
4512
4513    /// The footnote definition spanning `off`, and where the reference that
4514    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4515    ///
4516    /// The mirror image, deliberately: the same gesture that takes a reader from
4517    /// `[1]` down to the note takes them from the note back up to `[1]`, so
4518    /// following a footnote is a round trip rather than a fall. It needs no
4519    /// memory of how the reader arrived — the document says where the reference
4520    /// is — which is what makes it work for a reader who scrolled to the notes
4521    /// themselves, and what keeps it right after an edit moves either end.
4522    ///
4523    /// `None` when `off` stands in no definition. A definition nothing cites is
4524    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4525    /// its label and no offset, so a frontend can say "nothing refers to this"
4526    /// rather than offer a jump that goes nowhere.
4527    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4528        // Definitions are roots beside `doc`, so `nodes()` — which walks the
4529        // document body — never reports one. They're asked for directly, the way
4530        // `footnote_at` asks for the note it resolves to.
4531        //
4532        // Closed at the end, unlike the half-open test its neighbours use. A
4533        // definition's span stops at its last content byte — the newline ending
4534        // the line is outside it — so `span.end` is the caret stop at the end of
4535        // the note's own row, not the first byte of anything after. Excluding it
4536        // meant the one caret an author is guaranteed to have, the one left
4537        // sitting at the end of the note they just typed, was in no definition at
4538        // all: writing a note and then asking to go back to its reference
4539        // answered nothing. Two definitions in a row still can't both match —
4540        // there is a blank line between them — and `max_by_key` decides anyway.
4541        let note = wysiwyg::footnote_definitions(&mut self.editor)
4542            .into_iter()
4543            .filter(|m| m.span.start <= off && off <= m.span.end)
4544            .max_by_key(|m| m.span.start)?;
4545        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4546
4547        // The earliest reference carrying this label. `min` rather than a `find`,
4548        // because `nodes()` reports a flattened walk whose order is twig's
4549        // business, not document order. Bound first: the walk needs `&mut self`
4550        // and reading the labels back out needs `&self.source`.
4551        let nodes = self.nodes();
4552        let offset = nodes
4553            .into_iter()
4554            .filter(|n| n.kind == Kind::FootnoteReference)
4555            .filter(|n| {
4556                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4557            })
4558            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4559            .map(|n| n.span.start + 2)
4560            .min();
4561        Some(FootnoteDef { label, offset })
4562    }
4563
4564    /// The destination of the image under the caret — what an image prompt shows
4565    /// so editing an existing image starts from its current URL instead of blank,
4566    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4567    /// `None` when the caret stands in no image. A caret resting just after a
4568    /// block image (its trailing stop) is still "in" it — the half-open span test
4569    /// excludes that offset, which is the intended precision: past the image is
4570    /// past it.
4571    pub fn image_destination_at_caret(&mut self) -> Option<String> {
4572        let off = self.caret;
4573        self.nodes()
4574            .into_iter()
4575            .filter(|n| n.kind == Kind::Image)
4576            .filter(|n| n.span.start <= off && off < n.span.end)
4577            .max_by_key(|n| n.span.start)
4578            .and_then(|n| n.destination)
4579    }
4580
4581    /// The language of the fenced code block the caret stands in — what a
4582    /// language prompt shows so editing it starts from the current value rather
4583    /// than blank. `None` when the caret is in no code block, or in one whose
4584    /// fence carries no language (or an indented block, which has no fence).
4585    pub fn code_language_at_caret(&mut self) -> Option<String> {
4586        let start = self.code_block_start_at_caret()?;
4587        wysiwyg::code_language(&self.source, start)
4588    }
4589
4590    /// Whether the caret stands in a fenced code block — the one a language
4591    /// prompt could edit. A frontend gates its "set language" affordance on this
4592    /// (an indented block, which can't carry a language, reports `false`).
4593    pub fn caret_in_fenced_code(&mut self) -> bool {
4594        self.code_block_start_at_caret()
4595            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4596    }
4597
4598    /// Set (or clear, with `""`) the language of the fenced code block the caret
4599    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4600    /// block, and a reported error for a language the format's fence cannot
4601    /// carry.
4602    ///
4603    /// twig rewrites the info string, so the fence's own width — measured
4604    /// against a body neither side touches — is kept, and a language holding a
4605    /// space, a line end or the fence character is refused rather than written
4606    /// out to reparse as something else. Leaf used to splice over the info span
4607    /// itself and `trim()` the input, which handled the one bad case it had
4608    /// thought of.
4609    pub fn set_code_language(&mut self, lang: &str) {
4610        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4611            return;
4612        }
4613        if self.code_block_start_at_caret().is_none() {
4614            return;
4615        }
4616        let lang = lang.trim();
4617        // `None` clears the info string; `Some("")` asks for an empty one. Both
4618        // write a bare fence, and the prompt's empty value means "clear".
4619        let want = (!lang.is_empty()).then_some(lang);
4620        self.record_caret();
4621        match self.editor.set_code_language(self.caret, want) {
4622            Ok(_) => {
4623                self.last_edit_kind = None;
4624                self.refresh();
4625                self.anchor = None;
4626                self.dirty = self.source != self.clean_source;
4627                self.status = None;
4628                self.clamp_caret();
4629                self.record_caret();
4630            }
4631            Err(e) => self.status = Some(format!("code language: {e}")),
4632        }
4633    }
4634
4635    /// The `span.start` of the code block covering the caret — the anchor
4636    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4637    /// in none.
4638    fn code_block_start_at_caret(&mut self) -> Option<usize> {
4639        let off = self.caret;
4640        self.nodes()
4641            .into_iter()
4642            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4643            .max_by_key(|n| n.span.start)
4644            .map(|n| n.span.start)
4645    }
4646
4647    /// The source range of the text inside the link covering `off` — what sits
4648    /// between its `[` and `]`. `None` when twig reports no link there.
4649    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4650        self.nodes()
4651            .into_iter()
4652            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4653            // the other's `span.start`; the link that starts latest at or before
4654            // `off` is the one `off` is actually in.
4655            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4656            .max_by_key(|n| n.span.start)
4657            .and_then(|n| n.content_span)
4658    }
4659
4660    // ── undo / redo ───────────────────────────────────────────────────────────
4661    // twig owns the history of *bytes* (it owns the buffer) and now carries the
4662    // caret through it too: `record_caret` stashes each state's caret in twig's
4663    // opaque per-step blob, and undo/redo hand it back with the source they
4664    // restore. So leaf keeps no history of its own — no parallel stacks to march
4665    // in lockstep and silently drift out of it.
4666
4667    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
4668    /// where they were when that step began.
4669    pub fn undo(&mut self) {
4670        if self.read_only {
4671            return;
4672        }
4673        match self.editor.undo() {
4674            Ok(Some(change)) => self.after_history(change),
4675            Ok(None) => self.status = Some("nothing to undo".into()),
4676            Err(e) => self.status = Some(format!("undo: {e}")),
4677        }
4678    }
4679
4680    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
4681    /// selection back where that step originally left them.
4682    pub fn redo(&mut self) {
4683        if self.read_only {
4684            return;
4685        }
4686        match self.editor.redo() {
4687            Ok(Some(change)) => self.after_history(change),
4688            Ok(None) => self.status = Some("nothing to redo".into()),
4689            Err(e) => self.status = Some(format!("redo: {e}")),
4690        }
4691    }
4692
4693    /// Refresh the cached source and put the caret back where the step being
4694    /// undone/redone had it, clearing any active run.
4695    ///
4696    /// The caret comes from twig's blob for the restored state (what
4697    /// `record_caret` stored). `change` is only the fallback for a state with no
4698    /// blob — a caret at the end of the restored text, which is where this always
4699    /// landed before the blobs were kept. It is the edit site, not where the user
4700    /// was standing, so it's a floor and not the behaviour: undoing should hand
4701    /// back the document *and* the place you were working, which for an edit made
4702    /// anywhere but under the caret are two different places.
4703    fn after_history(&mut self, change: Change) {
4704        self.refresh();
4705        match self
4706            .editor
4707            .caret_blob()
4708            .ok()
4709            .and_then(|b| CaretState::from_blob(&b))
4710        {
4711            Some(state) => {
4712                self.caret = state.caret.min(self.source.len());
4713                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
4714            }
4715            None => {
4716                self.caret = change.new.end.min(self.source.len());
4717                self.anchor = None;
4718            }
4719        }
4720        self.goal_col = None;
4721        self.last_edit_kind = None;
4722        self.dirty = self.source != self.clean_source;
4723        self.status = None;
4724        self.clamp_caret();
4725    }
4726
4727    // ── the file ──────────────────────────────────────────────────────────────
4728
4729    #[cfg(feature = "fs")]
4730    pub fn save(&mut self) {
4731        if self.is_untitled() {
4732            // No path to write and no name to invent: ⌘S on an untitled document
4733            // is a Save As, and only a frontend has a picker to ask with. Say so
4734            // rather than failing at the filesystem with an empty path.
4735            self.status = Some("untitled — save as…".into());
4736            return;
4737        }
4738        let path = self.path.clone();
4739        if self.write(&path) {
4740            self.mark_saved();
4741        }
4742    }
4743
4744    /// Save As: write the document to `path` and *move* it there — `self.path`
4745    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
4746    /// what Save As means; a copy would leave the user editing a document whose
4747    /// name is no longer where their keystrokes go.
4748    ///
4749    /// The move only happens if the bytes actually landed. A failed write leaves
4750    /// the path, `dirty`, and the disk watermark exactly as they were, with the
4751    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
4752    /// must never come away believing it was saved.
4753    ///
4754    /// An existing `path` is overwritten, and the caller is the one that knows
4755    /// whether to ask first: a Save As picker has already run that prompt, and a
4756    /// second confirmation from down here would be the same question twice.
4757    ///
4758    /// `format` does **not** follow the new extension. The buffer is parsed as
4759    /// the format it was opened with, and re-reading it as another one is a
4760    /// conversion — a different, lossy operation that would throw away the undo
4761    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
4762    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
4763    /// until it's reopened.
4764    #[cfg(feature = "fs")]
4765    pub fn save_as(&mut self, path: PathBuf) {
4766        if !self.write(&path) {
4767            return;
4768        }
4769        self.path = path;
4770        self.mark_saved();
4771    }
4772
4773    /// Put `source` on disk at `path`, reporting whether it got there. The one
4774    /// place leaf writes a document, so a save and a Save As can't disagree
4775    /// about what a failure looks like.
4776    #[cfg(feature = "fs")]
4777    fn write(&mut self, path: &Path) -> bool {
4778        match std::fs::write(path, self.source.as_bytes()) {
4779            Ok(()) => true,
4780            Err(e) => {
4781                self.status = Some(format!("save failed: {e}"));
4782                false
4783            }
4784        }
4785    }
4786
4787    /// Re-base the document's saved watermark to the current bytes: clears
4788    /// `dirty`, records `source` as the new clean state (so undoing back to here
4789    /// clears the flag again), and re-stamps the on-disk hash.
4790    ///
4791    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
4792    /// the hook a **filesystem-free host** calls itself once it has persisted
4793    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
4794    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
4795    /// are already where that host wants them, and this just tells the model they
4796    /// are safe.
4797    pub fn mark_saved(&mut self) {
4798        self.clean_source = self.source.clone();
4799        self.dirty = false;
4800        // The bytes on disk are now ours, so this is the new watermark: without
4801        // re-stamping it, every save would report its own work as an external
4802        // change forever after.
4803        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
4804        self.status = Some(format!("saved {}", self.file_name()));
4805    }
4806
4807    /// What the file looks like now against the bytes leaf last read or wrote.
4808    ///
4809    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
4810    /// so this is a filesystem round-trip, not a per-frame question — ask it
4811    /// when a window regains focus, on a timer, or before a save.
4812    ///
4813    /// This *only* reports the file. Whether the document also has unsaved edits
4814    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
4815    /// [`DiskState::Changed`] means a save overwrites someone's work and a
4816    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
4817    /// it has no way to ask — so it hands a frontend both halves and lets it put
4818    /// the question to the person who can answer it.
4819    #[cfg(feature = "fs")]
4820    pub fn disk_state(&self) -> DiskState {
4821        let Some(want) = self.disk_hash else {
4822            return DiskState::Untitled;
4823        };
4824        match std::fs::read(&self.path) {
4825            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
4826            Ok(_) => DiskState::Changed,
4827            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
4828            Err(_) => DiskState::Unreadable,
4829        }
4830    }
4831
4832    /// Re-read the file and replace the document with what's there — the other
4833    /// answer to a [`DiskState::Changed`].
4834    ///
4835    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
4836    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
4837    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
4838    /// document shouldn't have to argue with a guard.
4839    ///
4840    /// **The undo history survives, and the reload is one step in it.** The
4841    /// whole buffer is spliced with the file's bytes through the same door every
4842    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
4843    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
4844    /// swapped the document out from under a reader gives them back what they
4845    /// were looking at, marked dirty, and ^Z again carries on into whatever they
4846    /// had done before it. This used to build a fresh parse and drop the stack,
4847    /// on the reasoning that twig's history belongs to the buffer and these are
4848    /// different bytes; that is true of *rebasing* a step onto them and not of
4849    /// recording the swap itself as one, which is all this is. A splice twig
4850    /// won't take falls back to the fresh parse, and only that path still costs
4851    /// the history.
4852    ///
4853    /// The caret keeps its byte offset, clamped to the new length; the selection
4854    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
4855    /// file changed, so it can't know where the caret "still" is. Clamping keeps
4856    /// it where the user left it in the common case (a change further down the
4857    /// file, or none in the text they're sitting in), and never puts it
4858    /// somewhere invalid. A selection has two such offsets and no such excuse —
4859    /// silently reinterpreting one over changed bytes would arm the *next*
4860    /// keystroke to delete something the user never selected.
4861    ///
4862    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
4863    /// document alone with a status.
4864    #[cfg(feature = "fs")]
4865    pub fn reload(&mut self) {
4866        if self.is_untitled() {
4867            self.status = Some("no file to reload".into());
4868            return;
4869        }
4870        let bytes = match std::fs::read(&self.path) {
4871            Ok(b) => b,
4872            Err(e) => {
4873                self.status = Some(format!("reload failed: {e}"));
4874                return;
4875            }
4876        };
4877        let Ok(source) = String::from_utf8(bytes) else {
4878            self.status = Some("reload failed: file is not UTF-8".into());
4879            return;
4880        };
4881        // Already these bytes — someone saved a file back unchanged, or leaf's
4882        // own write is being read back. Re-baseline against it and stop: a
4883        // splice of the text onto itself would put an undo step on the stack for
4884        // something nobody did.
4885        if source == self.source {
4886            self.disk_hash = Some(hash_bytes(source.as_bytes()));
4887            self.clean_source = source;
4888            self.dirty = false;
4889            self.status = Some(format!("reloaded {}", self.file_name()));
4890            return;
4891        }
4892        let caret = self.caret;
4893        // The pre-reload caret, so undoing the swap puts it back where the
4894        // reader was standing — the same bracketing `splice_exact` does.
4895        self.record_caret();
4896        if self
4897            .editor
4898            .edit_range(0, self.source.len(), &source)
4899            .is_ok()
4900        {
4901            self.refresh();
4902        } else {
4903            // twig wouldn't take the splice. Start over from the bytes, which is
4904            // what this always did, and is the one path that still costs the
4905            // history — `format` is the format this document *is*, not what the
4906            // (unchanged) name now says, see `save_as`.
4907            match new_editor(source.as_bytes(), self.format) {
4908                Ok(editor) => {
4909                    self.editor = editor;
4910                    self.source = source.clone();
4911                    // Not going through `refresh`, so the revision has to move
4912                    // here or every frontend keeps painting the old file from
4913                    // cache.
4914                    self.revision += 1;
4915                }
4916                Err(e) => {
4917                    self.status = Some(format!("reload failed: {e}"));
4918                    return;
4919                }
4920            }
4921        }
4922        self.disk_hash = Some(hash_bytes(source.as_bytes()));
4923        self.clean_source = self.source.clone();
4924        self.caret = caret.min(self.source.len());
4925        self.anchor = None;
4926        self.goal_col = None;
4927        self.last_edit_kind = None;
4928        self.dirty = false;
4929        self.status = Some(format!("reloaded {}", self.file_name()));
4930        self.clamp_caret();
4931        // And the post-reload caret, so a redo restores it.
4932        self.record_caret();
4933    }
4934
4935    /// Re-read the source from twig after it has changed the document. The one
4936    /// funnel every edit, undo, and redo comes through — so it's where the
4937    /// revision moves, and anything cached against the text dies here.
4938    fn refresh(&mut self) {
4939        if let Ok(s) = self.editor.source_str() {
4940            self.source = s;
4941        }
4942        self.revision += 1;
4943        self.clamp_caret();
4944    }
4945
4946    // ── caret movement ─────────────────────────────────────────────────────────
4947    // `extend` grows the selection (Shift+motion): it pins the anchor on the
4948    // first extended step and moves only the caret; an un-extended motion drops
4949    // the selection.
4950
4951    /// Place the caret at byte `offset` (clamped to a char boundary), extending
4952    /// the selection when `extend` is set. The public form of `move_to`, for a
4953    /// frontend that hit-tests pixels straight to a source offset.
4954    pub fn place_caret(&mut self, offset: usize, extend: bool) {
4955        self.goal_col = None;
4956        let before = self.caret;
4957        // A pixel hit-test can land between the visible caret stops — in the
4958        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
4959        // Snap to the nearest real stop so the caret can't come to rest where it
4960        // would draw in one place and type in another. The `(row, col)` click
4961        // path (`click`) already snaps this way through `offset_of_pos`; the
4962        // source view reaches every byte, so it snaps to nothing.
4963        let target = match self.view {
4964            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
4965            // The source view reaches every byte, so there is no stop to snap
4966            // to — but "every byte" still means every *character* boundary. A
4967            // caret resting inside a multi-byte character draws nowhere real
4968            // and panics the next time anything slices there.
4969            View::Source => self.char_boundary_at_or_before(offset),
4970        };
4971        self.move_to(target, extend);
4972        self.clamp_caret();
4973        self.debug_assert_on_a_stop(before);
4974    }
4975
4976    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
4977    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
4978    /// grab the metadata) while the source view still selects the literal whole.
4979    pub fn select_all(&mut self) {
4980        self.anchor = Some(self.caret_floor());
4981        self.caret = self.source.len();
4982        self.goal_col = None;
4983        self.last_edit_kind = None;
4984        self.status = None;
4985    }
4986
4987    /// Select the word (or whitespace / punctuation run) at `offset` — the
4988    /// double-click gesture. Anchors on the run's start with the caret at its
4989    /// end so a following Shift-motion extends from the far edge.
4990    pub fn select_word_at(&mut self, offset: usize) {
4991        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
4992        self.anchor = Some(s);
4993        self.caret = e;
4994        self.goal_col = None;
4995        self.last_edit_kind = None;
4996        self.status = None;
4997        self.clamp_caret();
4998    }
4999
5000    /// Select the whole enclosing text block (paragraph, heading, list item's
5001    /// text…) at `offset` — the triple-click gesture. Reads the range straight
5002    /// from the AST (twig's `content_span`), so it selects the entire *logical*
5003    /// paragraph even when that paragraph soft-wraps across several visual rows —
5004    /// where a visual-row-based select breaks down, because one source offset at
5005    /// a wrap boundary belongs to two rows at once.
5006    pub fn select_block_at(&mut self, offset: usize) {
5007        let off = offset.min(self.source.len());
5008        let range = self
5009            .editor
5010            .ancestors_at(off)
5011            .ok()
5012            .and_then(|chain| {
5013                // Ancestors run root → deepest; the deepest node that is neither
5014                // an inline span nor a multi-block container is the text block
5015                // the caret sits in (a paragraph, a heading, a code block…).
5016                chain
5017                    .into_iter()
5018                    .rev()
5019                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5020                    .map(|m| m.content_span.unwrap_or(m.span))
5021            })
5022            .unwrap_or_else(|| source_line_range(&self.source, off));
5023        self.anchor = Some(range.start.min(self.source.len()));
5024        self.caret = range.end.min(self.source.len());
5025        self.goal_col = None;
5026        self.last_edit_kind = None;
5027        self.status = None;
5028        self.clamp_caret();
5029    }
5030
5031    /// Select the exact source range `[start, end)` — anchor at `start`, caret
5032    /// at `end` — without snapping either end to a visible caret stop.
5033    ///
5034    /// The one caret verb that takes a range it was *handed* rather than one it
5035    /// worked out, for a host that already knows the bytes it means: a search
5036    /// hit, an annotation's footprint, a quote re-anchored through
5037    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
5038    /// wrong tool for that, and not by a little — it snaps to the nearest
5039    /// *visible* stop, and where a range butts up against a hidden delimiter
5040    /// the nearest stop is the one before it, so selecting the "needle" of
5041    /// `**needle**` comes back with "needl" and an edit against it strands the
5042    /// "e".
5043    ///
5044    /// What `place_caret` does that is bookkeeping rather than snapping still
5045    /// happens here, because a host handing in a range is not asking to opt out
5046    /// of the invariants:
5047    ///
5048    /// - both ends are clamped into the document and up to
5049    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
5050    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
5051    ///   types into the metadata;
5052    /// - both land on character boundaries, so nothing slices a `é` in half;
5053    /// - the sticky vertical goal column is dropped, and any armed inline mark
5054    ///   disarmed, since a range from outside inherits neither.
5055    ///
5056    /// An empty range is a caret rather than a selection —
5057    /// [`selection`](Self::selection) reports `None` for it, as it does for any
5058    /// anchor that has met the caret.
5059    pub fn select_range(&mut self, start: usize, end: usize) {
5060        let floor = self.caret_floor();
5061        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
5062        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
5063        self.anchor = Some(anchor);
5064        self.caret = caret;
5065        self.goal_col = None;
5066        self.status = None;
5067        self.last_edit_kind = None;
5068        self.clear_pending();
5069    }
5070
5071    /// `offset` itself if it is a character boundary, else the boundary before
5072    /// it. An offset that isn't one draws nowhere real and panics the next time
5073    /// anything slices there.
5074    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
5075        let mut o = offset.min(self.source.len());
5076        while o > 0 && !self.source.is_char_boundary(o) {
5077            o -= 1;
5078        }
5079        o
5080    }
5081
5082    /// The lowest source offset the caret may occupy in the active view. In
5083    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
5084    /// the first rendered offset; the source view reaches everything, so it's 0.
5085    fn caret_floor(&self) -> usize {
5086        match self.view {
5087            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
5088            View::Source => 0,
5089        }
5090    }
5091
5092    /// Land in a table cell with its whole content selected — the anchor at the
5093    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
5094    /// like tabbing into a form field: the text comes up selected, so typing
5095    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
5096    /// end`) collapses to a plain caret home (an empty selection is no selection).
5097    fn select_cell(&mut self, start: usize, end: usize) {
5098        self.select_range(start, end);
5099    }
5100
5101    fn move_to(&mut self, offset: usize, extend: bool) {
5102        if extend {
5103            if self.anchor.is_none() {
5104                self.anchor = Some(self.caret);
5105            }
5106        } else {
5107            self.anchor = None;
5108        }
5109        self.caret = offset.min(self.source.len()).max(self.caret_floor());
5110        self.status = None;
5111        // A caret move ends the current typing/deletion run, so the next edit
5112        // starts a fresh undo group rather than coalescing across the gap.
5113        self.last_edit_kind = None;
5114        // Moving away disarms any sticky mark — "start bold" applies only where
5115        // it was asked for, not wherever the caret next lands.
5116        self.clear_pending();
5117    }
5118
5119    // In the source view, motion walks source bytes / source lines. In the
5120    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
5121    // what steps the caret cleanly over hidden delimiters.
5122
5123    pub fn move_left(&mut self, extend: bool) {
5124        self.goal_col = None;
5125        if !extend && let Some((s, _e)) = self.selection() {
5126            self.move_to(s, false);
5127            return;
5128        }
5129        let target = match self.view {
5130            View::Source => {
5131                if self.caret > 0 {
5132                    prev_boundary(&self.source, self.caret)
5133                } else {
5134                    0
5135                }
5136            }
5137            // Walks caret *stops*, not columns: decoration (a table border, a
5138            // cell's padding) is stepped over in one press, and a hidden
5139            // delimiter never holds the caret up.
5140            View::Wysiwyg => self.vmap.stop_before(self.caret).unwrap_or(self.caret),
5141        };
5142        let before = self.caret;
5143        self.move_to(target, extend);
5144        self.debug_assert_on_a_stop(before);
5145    }
5146
5147    pub fn move_right(&mut self, extend: bool) {
5148        self.goal_col = None;
5149        if !extend && let Some((_s, e)) = self.selection() {
5150            self.move_to(e, false);
5151            return;
5152        }
5153        let target = match self.view {
5154            View::Source => {
5155                if self.caret < self.source.len() {
5156                    next_boundary(&self.source, self.caret)
5157                } else {
5158                    self.caret
5159                }
5160            }
5161            View::Wysiwyg => self.vmap.stop_after(self.caret).unwrap_or(self.caret),
5162        };
5163        let before = self.caret;
5164        self.move_to(target, extend);
5165        self.debug_assert_on_a_stop(before);
5166    }
5167
5168    /// Move to the start of the previous word (⌥← / Ctrl+←).
5169    pub fn move_word_left(&mut self, extend: bool) {
5170        self.goal_col = None;
5171        let before = self.caret;
5172        let target = self.word_left_from(self.caret);
5173        self.move_to(target, extend);
5174        self.debug_assert_on_a_stop(before);
5175    }
5176
5177    /// Move to the end of the next word (⌥→ / Ctrl+→).
5178    pub fn move_word_right(&mut self, extend: bool) {
5179        self.goal_col = None;
5180        let before = self.caret;
5181        let target = self.word_right_from(self.caret);
5182        self.move_to(target, extend);
5183        self.debug_assert_on_a_stop(before);
5184    }
5185
5186    // Word boundaries are found in the space the *view* is in. The source view
5187    // walks the source, because there the source is what's rendered. WYSIWYG
5188    // walks the rendered text instead: `**` is invisible to the user, so it has
5189    // to be invisible to word motion too — a caret parked inside one draws in
5190    // the column after `bold` and types two bytes earlier, and a word-delete
5191    // that stops there shreds the markup into `a ** c`.
5192
5193    /// The word boundary to the left of `off` in the active view's space.
5194    fn word_left_from(&self, off: usize) -> usize {
5195        match self.view {
5196            View::Source => prev_word(&self.source, off),
5197            View::Wysiwyg => self.glyph_word_left(off),
5198        }
5199    }
5200
5201    /// The word boundary to the right of `off` in the active view's space.
5202    fn word_right_from(&self, off: usize) -> usize {
5203        match self.view {
5204            View::Source => next_word(&self.source, off),
5205            View::Wysiwyg => self.glyph_word_right(off),
5206        }
5207    }
5208
5209    /// The character class of the glyph drawn at stop `off`.
5210    ///
5211    /// Read from the source, because a stop points at the source byte its glyph
5212    /// came from — the source *is* where the rendered character is written. What
5213    /// makes the walk glyph space rather than source space is that it only ever
5214    /// visits stops, and the hidden bytes between them have none.
5215    fn class_at(&self, off: usize) -> Class {
5216        self.source
5217            .get(off..)
5218            .and_then(|s| s.chars().next())
5219            .map_or(Class::Space, classify)
5220    }
5221
5222    /// [`next_word`] in glyph space: skip any leading separators, then consume
5223    /// the following word run, with the stop table standing in for the source's
5224    /// characters.
5225    fn glyph_word_right(&self, from: usize) -> usize {
5226        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
5227            return from;
5228        };
5229        let mut in_word = false;
5230        loop {
5231            match self.class_at(off) {
5232                Class::Word => in_word = true,
5233                _ if in_word => return off,
5234                _ => {}
5235            }
5236            match self.vmap.stop_after(off) {
5237                Some(next) => off = next,
5238                None => return off,
5239            }
5240        }
5241    }
5242
5243    /// [`prev_word`] in glyph space: skip separators walking left, then consume
5244    /// the preceding word run.
5245    fn glyph_word_left(&self, from: usize) -> usize {
5246        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
5247            return from;
5248        };
5249        let mut in_word = false;
5250        while let Some(prev) = self.vmap.stop_before(off) {
5251            match self.class_at(prev) {
5252                Class::Word => in_word = true,
5253                _ if in_word => return off,
5254                _ => {}
5255            }
5256            off = prev;
5257        }
5258        off
5259    }
5260
5261    /// After a motion that walks the visual map, the caret must be *on* the map.
5262    /// A stop is the only offset where the caret draws and edits in the same
5263    /// place, and it's the invariant both a caret parked inside an emoji and one
5264    /// parked inside a `**` were quietly breaking.
5265    ///
5266    /// Only when the caret actually moved: a walk with nowhere to go leaves it
5267    /// where it was, which is wherever the floor or a frontend put it rather
5268    /// than somewhere this motion chose.
5269    fn debug_assert_on_a_stop(&self, before: usize) {
5270        debug_assert!(
5271            self.view != View::Wysiwyg
5272                || self.vmap.num_rows() == 0
5273                || self.caret == before
5274                || self.vmap.is_stop(self.caret),
5275            "motion left the caret at {}, which is not a caret stop: it would draw in \
5276             one place and type in another",
5277            self.caret
5278        );
5279    }
5280
5281    // Up and Down run off the ends of the document rather than stopping dead at
5282    // them: Up from the first row lands at the document's start, Down from the
5283    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
5284    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
5285    // reaching the end of the text is what a reader means by it.
5286    //
5287    // The views used to disagree here by accident rather than by decision: the
5288    // source view fell into the edge behaviour through `row_col_to_offset`
5289    // clamping an out-of-range row to the end of the string, while WYSIWYG had
5290    // no row below to walk to and did nothing at all. They share the rule now,
5291    // each in its own space — the source view reaches every byte, WYSIWYG only
5292    // the offsets it draws.
5293
5294    pub fn move_up(&mut self, extend: bool) {
5295        let (row, col) = self.caret_pos();
5296        let goal = self.goal_col.unwrap_or(col);
5297        let target = match self.view {
5298            View::Source => match row.checked_sub(1) {
5299                Some(r) => row_col_to_offset(&self.source, r, goal),
5300                None => self.reachable_start(),
5301            },
5302            // A table's border rules are drawn but hold no caret, so Up steps
5303            // over them to the row that does.
5304            View::Wysiwyg => match self.vmap.navigable_above(row) {
5305                Some(r) => self.row_target(r, goal),
5306                None => self.reachable_start(),
5307            },
5308        };
5309        self.step_vertical(target, goal, extend);
5310    }
5311
5312    pub fn move_down(&mut self, extend: bool) {
5313        let (row, col) = self.caret_pos();
5314        let goal = self.goal_col.unwrap_or(col);
5315        let target = match self.view {
5316            View::Source => match self.source_row_below(row) {
5317                Some(r) => row_col_to_offset(&self.source, r, goal),
5318                None => self.reachable_end(),
5319            },
5320            View::Wysiwyg => match self.vmap.navigable_below(row) {
5321                Some(r) => self.row_target(r, goal),
5322                None => self.reachable_end(),
5323            },
5324        };
5325        self.step_vertical(target, goal, extend);
5326    }
5327
5328    /// Land a vertical motion at `target`, latching the `goal` column it aimed
5329    /// with so the rest of the run keeps aiming there.
5330    ///
5331    /// A motion with nowhere to go changes *nothing*, the goal column included:
5332    /// the latch used to run before the early return at the top of the document,
5333    /// so an Up that did nothing still armed a column, and the next Down aimed
5334    /// at one the caret had never been in.
5335    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
5336        let before = self.caret;
5337        if target == before {
5338            return;
5339        }
5340        self.goal_col = Some(goal);
5341        self.move_to(target, extend);
5342        self.debug_assert_on_a_stop(before);
5343    }
5344
5345    /// The source line below `row`, or `None` when `row` is the last one. Lines
5346    /// are counted by newline, so a trailing one leaves a real, empty last line
5347    /// for the caret to sit on — the document ends below it, not on it.
5348    fn source_row_below(&self, row: usize) -> Option<usize> {
5349        let last = self.source.bytes().filter(|&b| b == b'\n').count();
5350        (row < last).then_some(row + 1)
5351    }
5352
5353    /// Where a vertical motion aiming at the `goal` column lands on visual row
5354    /// `r`: the column clamped to the row, mapped to its offset, then held
5355    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
5356    /// column belongs to the row below, and a gutter's column 0 points at the
5357    /// block rather than at this row.
5358    fn row_target(&self, r: usize, goal: usize) -> usize {
5359        let (start, end) = self.row_bounds(r);
5360        self.vmap
5361            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
5362            .clamp(start, end)
5363    }
5364
5365    /// The first and last offsets the caret can reach in the active view.
5366    ///
5367    /// Not the same span in both: the source view shows every byte, so it can
5368    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
5369    /// sits below the first stop, and a document's trailing newline is drawn
5370    /// nowhere and so sits past the last.
5371    fn reachable_start(&self) -> usize {
5372        match self.view {
5373            View::Source => 0,
5374            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
5375        }
5376    }
5377
5378    fn reachable_end(&self) -> usize {
5379        match self.view {
5380            View::Source => self.source.len(),
5381            View::Wysiwyg => self
5382                .vmap
5383                .stop_at_or_before(self.source.len())
5384                .unwrap_or(self.caret),
5385        }
5386    }
5387
5388    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
5389    /// including the space a soft wrap ate off its end, which is drawn on this
5390    /// row however much the offset past it belongs to the next one.
5391    fn row_span(&self, r: usize) -> (usize, usize) {
5392        let start = self
5393            .vmap
5394            .row_start(r)
5395            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
5396        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
5397        (start.min(end), end)
5398    }
5399
5400    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
5401    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
5402    /// this row's last position is the one before it — the offset before the
5403    /// space the wrap ate, where the caret draws just past the row's last word
5404    /// and types there too.
5405    ///
5406    /// Aiming at the shared offset instead is what stalled End: it is the row's
5407    /// last *column*, so End pressed on the row reached it and then read back as
5408    /// the row below's start, where a second press ran on to that row's end and
5409    /// the next to the one after — End walking down the paragraph a row a press.
5410    fn row_bounds(&self, r: usize) -> (usize, usize) {
5411        let (start, end) = self.row_span(r);
5412        let wraps = self
5413            .vmap
5414            .navigable_below(r)
5415            .and_then(|b| self.vmap.row_start(b))
5416            .is_some_and(|off| off == end);
5417        match wraps {
5418            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5419            false => (start, end),
5420        }
5421    }
5422
5423    /// The `[start, end]` of the line Home and End aim at: the visual row in
5424    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5425    ///
5426    /// A soft-wrapped row is a line here, because it is one to the eye and the
5427    /// eye is what these keys are aimed by — a reader pressing End means the end
5428    /// of the line they can see. (`select_block_at` wants the opposite and reads
5429    /// the AST for it: a triple-click grabs the whole paragraph, however many
5430    /// rows it folds into.)
5431    fn line_bounds(&self) -> (usize, usize) {
5432        let (row, _) = self.caret_pos();
5433        match self.view {
5434            View::Source => {
5435                let start = line_start(&self.source, row);
5436                (start, line_end_from(&self.source, start))
5437            }
5438            View::Wysiwyg => self.row_bounds(row),
5439        }
5440    }
5441
5442    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5443    /// *drawn* — what a kill takes.
5444    ///
5445    /// The two part only at a soft wrap, over the space the wrap ate: the caret
5446    /// can't stand after it (that offset opens the row below, and End stopping
5447    /// there would walk), but it is on this row, and a kill that spared it would
5448    /// leave a double space behind where the row's text had been. Deleting it
5449    /// joins nothing — a wrap is drawn, not written.
5450    fn line_span(&self) -> (usize, usize) {
5451        let (row, _) = self.caret_pos();
5452        match self.view {
5453            View::Source => self.line_bounds(),
5454            View::Wysiwyg => self.row_span(row),
5455        }
5456    }
5457
5458    /// The first offset in `[start, end]` holding something other than
5459    /// whitespace, or `end` when the line holds nothing else — where Home aims.
5460    ///
5461    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5462    /// so a hidden delimiter is never taken for the line's first character (nor
5463    /// landed on), and the source view steps the source it is showing.
5464    fn first_non_space(&self, start: usize, end: usize) -> usize {
5465        let mut off = start;
5466        while off < end {
5467            if self.class_at(off) != Class::Space {
5468                return off;
5469            }
5470            off = match self.view {
5471                View::Source => next_boundary(&self.source, off),
5472                View::Wysiwyg => match self.vmap.stop_after(off) {
5473                    Some(next) => next,
5474                    None => return end,
5475                },
5476            };
5477        }
5478        end
5479    }
5480
5481    /// Home: to the first character on the line, or to column 0 when the caret
5482    /// is already on it — the two-press toggle every editor spells this way.
5483    /// The indentation is somewhere the caret has to be able to reach and almost
5484    /// never where a reader is headed, so it costs the second press.
5485    pub fn move_home(&mut self, extend: bool) {
5486        self.goal_col = None;
5487        let (start, end) = self.line_bounds();
5488        let text = self.first_non_space(start, end);
5489        let target = if self.caret == text { start } else { text };
5490        let before = self.caret;
5491        self.move_to(target, extend);
5492        self.debug_assert_on_a_stop(before);
5493    }
5494
5495    /// End: to the end of the line.
5496    pub fn move_end(&mut self, extend: bool) {
5497        self.goal_col = None;
5498        let (_, end) = self.line_bounds();
5499        let before = self.caret;
5500        self.move_to(end, extend);
5501        self.debug_assert_on_a_stop(before);
5502    }
5503
5504    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5505    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5506    /// when the caret isn't in a table, or is already in the last/first cell — the
5507    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5508    /// meaning everywhere else.
5509    pub fn cell_hop(&mut self, forward: bool) -> bool {
5510        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5511            return false;
5512        };
5513        // Flatten to document (row-major) order and step one cell either way.
5514        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5515        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5516        let next = if forward {
5517            i.checked_add(1)
5518        } else {
5519            i.checked_sub(1)
5520        };
5521        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5522            return false; // at the table's edge; leave Tab to the frontend
5523        };
5524        self.select_cell(start, end);
5525        true
5526    }
5527
5528    /// Move the caret to the cell directly above (`down == false`) or below in
5529    /// the same column, landing with the cell's whole content selected (see
5530    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5531    /// when the caret isn't in a table), so the frontend can fall through — the
5532    /// vertical counterpart of [`Self::cell_hop`].
5533    ///
5534    /// A ragged row that is short a column clamps to its last cell, so Down never
5535    /// falls out of the table over a gap the row above happened to have.
5536    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5537        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5538            return false;
5539        };
5540        let target = match down {
5541            true => r + 1,
5542            false if r == 0 => return false,
5543            false => r - 1,
5544        };
5545        let Some(row) = grid.get(target) else {
5546            return false;
5547        };
5548        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5549            return false;
5550        };
5551        self.select_cell(start, end);
5552        true
5553    }
5554
5555    /// The table containing `off` as a row-major grid of `(start, end)` cell
5556    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5557    /// isn't in a table. Read straight off the visual map's laid-out grid, so
5558    /// every cell (an empty one included, whose derived home twig gives no
5559    /// `content_span` for) is present and in the order Tab walks them.
5560    // Grid, row, column — three returns that only ever travel together, and a
5561    // named type for the pair of them would be read at one call site.
5562    #[allow(clippy::type_complexity)]
5563    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5564        for t in &self.vmap.tables {
5565            let mut pos = None;
5566            let grid: Vec<Vec<(usize, usize)>> = t
5567                .grid
5568                .iter()
5569                .enumerate()
5570                .map(|(r, row)| {
5571                    row.cells
5572                        .iter()
5573                        .enumerate()
5574                        .map(|(c, cell)| {
5575                            if pos.is_none() && off >= cell.start && off <= cell.end {
5576                                pos = Some((r, c));
5577                            }
5578                            (cell.start, cell.end)
5579                        })
5580                        .collect()
5581                })
5582                .collect();
5583            if let Some((r, c)) = pos {
5584                return Some((grid, r, c));
5585            }
5586        }
5587        None
5588    }
5589
5590    // ── table key policy ──────────────────────────────────────────────────────
5591    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5592    // as one policy every frontend shares, rather than each re-deriving it. Each
5593    // reports whether it acted *as a table key*; a `false` hands the key back to
5594    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5595    // everywhere else.
5596
5597    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5598    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5599    /// back and simply stays put at the very first cell. `false` when the caret
5600    /// isn't in a table.
5601    pub fn cell_tab(&mut self, forward: bool) -> bool {
5602        if !self.caret_in_table() {
5603            return false;
5604        }
5605        if self.cell_hop(forward) {
5606            return true;
5607        }
5608        // Off the last cell: grow the table by a row and step into its first
5609        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5610        if forward {
5611            self.append_row_and_enter(0);
5612        }
5613        true
5614    }
5615
5616    /// Return inside a table: drop to the cell below in the same column,
5617    /// appending a new row when the caret is already in the last one. `false`
5618    /// when the caret isn't in a table, so the frontend inserts a newline.
5619    pub fn cell_return(&mut self) -> bool {
5620        if !self.caret_in_table() {
5621            return false;
5622        }
5623        if self.cell_move_vertical(true) {
5624            return true;
5625        }
5626        // Already on the last row: grow one below and drop into the same column.
5627        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
5628        self.append_row_and_enter(col);
5629        true
5630    }
5631
5632    /// Append a row below the caret's (last) row and land in `col` of it. The
5633    /// caret is in the last row, so twig's "insert below" makes the fresh row the
5634    /// table's new last — but twig re-spells the whole table, moving every byte,
5635    /// so the destination is read back from the rebuilt grid by the table's
5636    /// position (stable across a row insert), not from the pre-edit caret.
5637    fn append_row_and_enter(&mut self, col: usize) {
5638        let table = self.caret_table_index();
5639        self.table_insert_row(true);
5640        self.rebuild_map();
5641        let Some((start, end)) = table
5642            .and_then(|ti| self.vmap.tables.get(ti))
5643            .and_then(|t| t.grid.last())
5644            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
5645            .map(|cell| (cell.start, cell.end))
5646        else {
5647            return;
5648        };
5649        self.select_cell(start, end);
5650    }
5651
5652    /// The index, among the document's tables, of the one the caret sits in —
5653    /// `None` when it's in none. Used to re-find a table after an edit re-spells
5654    /// it (a row insert leaves the table order unchanged).
5655    fn caret_table_index(&self) -> Option<usize> {
5656        let off = self.caret;
5657        self.vmap.tables.iter().position(|t| {
5658            t.grid
5659                .iter()
5660                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
5661        })
5662    }
5663
5664    /// Shift+Return inside a table: insert a hard line break *within* the current
5665    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
5666    /// table, so the frontend inserts an ordinary line break.
5667    ///
5668    /// A table row is a single source line, so the newline-spelled hard break
5669    /// can't live in a cell. twig spells the in-cell break the format's way
5670    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
5671    /// break round-trips as structure the renderer reads back as a line — not the
5672    /// opaque raw HTML the old raw-splice left behind.
5673    ///
5674    /// Djot has no idiomatic in-cell break, so twig refuses it
5675    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
5676    /// would render as the literal text `<br>`. The gesture is still *consumed*
5677    /// there — returning `false` would let the frontend insert a real newline,
5678    /// which splits the one-line row — it just leaves the cell unchanged and says
5679    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
5680    ///
5681    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
5682    /// have to be read together: djot is not the only `false`, and naming it in
5683    /// the message was already a guess that HTML — which spells the break as its
5684    /// own `<br>` — would have made wrong.
5685    pub fn cell_line_break(&mut self) -> bool {
5686        if !self.caret_in_table() {
5687            return false;
5688        }
5689        self.record_caret();
5690        match self.editor.insert_line_break(self.caret) {
5691            Ok(change) => {
5692                self.last_edit_kind = None;
5693                self.refresh();
5694                self.caret = change.new.end;
5695                self.anchor = None;
5696                self.goal_col = None;
5697                self.clamp_caret();
5698                self.dirty = self.source != self.clean_source;
5699                self.status = None;
5700                self.record_caret();
5701            }
5702            Err(twig::Error::UnsupportedFormat) => {
5703                self.status = Some(format!(
5704                    "in-cell line breaks aren't supported in {}",
5705                    self.format_name()
5706                ));
5707            }
5708            Err(_) => {}
5709        }
5710        true
5711    }
5712
5713    /// Rebuild the visual map at the width the last build used. A structural edit
5714    /// bumps the revision and swaps the source in, but leaves the *map* stale;
5715    /// when a single gesture edits and then moves over the result (Tab appending
5716    /// a row, then stepping into it), the move needs the map to already show the
5717    /// edit rather than waiting for the frontend's next frame.
5718    fn rebuild_map(&mut self) {
5719        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
5720        self.build_map(wrap);
5721    }
5722
5723    /// Move the caret to the very start of the document (⌘↑ on macOS,
5724    /// Ctrl+Home on Windows/Linux).
5725    pub fn move_doc_start(&mut self, extend: bool) {
5726        self.goal_col = None;
5727        self.move_to(0, extend);
5728    }
5729
5730    /// Move the caret to the very end of the document (⌘↓ on macOS,
5731    /// Ctrl+End on Windows/Linux).
5732    pub fn move_doc_end(&mut self, extend: bool) {
5733        self.goal_col = None;
5734        let end = self.source.len();
5735        self.move_to(end, extend);
5736    }
5737
5738    /// Point the caret at the body cell `(row, col)` the mouse landed on —
5739    /// `col` being a cell of the terminal grid, which is what a display column
5740    /// is. A click on the far cell of a wide character lands at that
5741    /// character's start; the mapping's own doc-comments carry the rule.
5742    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
5743        self.goal_col = None;
5744        let target = match self.view {
5745            View::Source => row_col_to_offset(&self.source, row, col),
5746            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
5747        };
5748        let before = self.caret;
5749        self.move_to(target, extend);
5750        self.debug_assert_on_a_stop(before);
5751    }
5752
5753    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
5754    /// screen if it has moved since the last frame, and never scroll past the
5755    /// last of `rows`.
5756    ///
5757    /// Only if it has *moved* — that's the whole point. Revealing the caret on
5758    /// every frame ties the viewport to it, and a scroll wheel that fights the
5759    /// caret for the viewport loses: the view snaps back the instant it tries to
5760    /// pass the caret's row, so the document can't be scrolled beyond what's
5761    /// already on screen. A caret move is the frontend's cue to follow; a scroll
5762    /// with the caret sitting still is the reader's cue to leave it alone.
5763    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
5764        if self.drawn_caret != Some(self.caret) {
5765            if caret_row < self.scroll {
5766                self.scroll = caret_row;
5767            } else if height > 0 && caret_row >= self.scroll + height {
5768                self.scroll = caret_row + 1 - height;
5769            }
5770            self.drawn_caret = Some(self.caret);
5771        }
5772        self.scroll = self.scroll.min(rows.saturating_sub(1));
5773    }
5774
5775    /// The caret's screen position `(row, col)` in the active view's grid, with
5776    /// `col` a display column: the cell to draw the caret in, which on a line of
5777    /// `你好` or emoji is not the count of characters before it.
5778    pub fn caret_pos(&self) -> (usize, usize) {
5779        match self.view {
5780            View::Source => offset_to_row_col(&self.source, self.caret),
5781            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
5782        }
5783    }
5784
5785    fn clamp_caret(&mut self) {
5786        if self.caret > self.source.len() {
5787            self.caret = self.source.len();
5788        }
5789        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
5790        // any selection anchor) to the first rendered offset.
5791        let floor = self.caret_floor();
5792        if self.caret < floor {
5793            self.caret = floor;
5794        }
5795        if let Some(a) = self.anchor
5796            && a < floor
5797        {
5798            self.anchor = Some(floor);
5799        }
5800        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
5801            self.caret -= 1;
5802        }
5803    }
5804}
5805
5806// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
5807
5808// Left/right motion and backspace/delete step by *grapheme cluster*, not
5809// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
5810// marks moves and deletes as the single character a user sees. Grapheme
5811// boundaries are a superset of char boundaries, so the caret stays valid for twig.
5812
5813/// How an insert of `text` groups for undo: a single typed character folds into
5814/// the run of typing around it, while a newline or a multi-character insert is a
5815/// step of its own.
5816fn typed_edit_kind(text: &str) -> EditKind {
5817    if text.chars().take(2).count() == 1 && text != "\n" {
5818        EditKind::Insert
5819    } else {
5820        EditKind::Other
5821    }
5822}
5823
5824fn prev_boundary(s: &str, i: usize) -> usize {
5825    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5826    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
5827}
5828
5829fn next_boundary(s: &str, i: usize) -> usize {
5830    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5831    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
5832}
5833
5834// ── word boundaries ──────────────────────────────────────────────────────────
5835// The shared primitive behind word-wise motion, word deletion, and
5836// double-click-to-select-a-word. A "word" is a maximal run of one character
5837// class; whitespace and punctuation are their own classes, so motion skips
5838// cleanly between them the way native text fields do.
5839
5840#[derive(PartialEq, Eq, Clone, Copy)]
5841enum Class {
5842    Word,
5843    Space,
5844    Other,
5845}
5846
5847/// The source range of an inline node's own visible text — the part of it a
5848/// WYSIWYG caret can reach, as against the delimiters that only spell it.
5849/// `None` for a node with no interior to empty (a `str`, a break).
5850///
5851/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
5852/// one delimiter in from the span — the same place the renderer maps it to. A
5853/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
5854/// against the source rather than trusted: a range guessed wrong here is text
5855/// deleted wrong.
5856fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
5857    if let Some(span) = n.content_span.clone() {
5858        return Some(span);
5859    }
5860    match n.kind.as_str() {
5861        "verbatim" | "inline_math" => {
5862            let text = n.text.as_ref()?;
5863            let start = n.span.start + 1;
5864            let range = start..start + text.len();
5865            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
5866        }
5867        _ => None,
5868    }
5869}
5870
5871/// The `id` a node declares, or `None` for one that declares none — the
5872/// attribute djot writes for a `{#v1}` and mints for a heading.
5873///
5874/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
5875/// names nothing, so it reads as absent rather than as the empty string.
5876fn declared_id(n: &FlatNode) -> Option<&str> {
5877    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
5878}
5879
5880/// A heading's words reduced to the form a link fragment spells them in:
5881/// lowercase, runs of anything else collapsed to a single `-`, with none left
5882/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
5883///
5884/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
5885/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
5886/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
5887/// any other language is still a heading someone will link to. Underscores
5888/// survive for the same reason they do on the web: they are word characters
5889/// wherever identifiers are written.
5890fn slug(text: &str) -> String {
5891    let mut out = String::new();
5892    let mut pending = false;
5893    for c in text.chars() {
5894        if c.is_alphanumeric() || c == '_' {
5895            if pending && !out.is_empty() {
5896                out.push('-');
5897            }
5898            pending = false;
5899            out.extend(c.to_lowercase());
5900        } else {
5901            pending = true;
5902        }
5903    }
5904    out
5905}
5906
5907fn is_block_container(kind: &Kind) -> bool {
5908    matches!(
5909        kind,
5910        Kind::Doc
5911            | Kind::Section
5912            | Kind::BlockQuote
5913            | Kind::BulletList
5914            | Kind::OrderedList
5915            | Kind::TaskList
5916            | Kind::ListItem
5917            | Kind::TaskListItem
5918            // Every `container` — a directive in any of its three forms, or a
5919            // promoted HTML element. A *text* directive is really inline, so
5920            // claiming it here is a small overreach, and the deliberate one this
5921            // function's kind-only peer `is_inline_kind` documents: the pair is
5922            // consulted together, and answering "block container" for something
5923            // inline is what keeps an ancestor walk from stopping short of the
5924            // paragraph that actually holds it.
5925            | Kind::Container
5926    )
5927}
5928
5929/// The `[start, end)` byte range of the source line containing `off` (newline
5930/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
5931/// line between paragraphs).
5932fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
5933    let off = off.min(s.len());
5934    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
5935    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
5936    start..end
5937}
5938
5939/// How many leading bytes an outdent takes off `line`: a whole indent level
5940/// where the line has one, and whatever it has where it has less.
5941///
5942/// A leading tab counts as a level on its own. It's indentation some other
5943/// editor wrote, and one tab is one level everywhere it came from — measuring it
5944/// in spaces it doesn't contain would leave it untouchable.
5945fn outdent_width(line: &str, unit: usize) -> usize {
5946    if line.starts_with('\t') {
5947        return 1;
5948    }
5949    line.bytes().take(unit).take_while(|b| *b == b' ').count()
5950}
5951
5952/// A list marker found at the head of a line, together with everything before it
5953/// that a sibling line has to repeat.
5954///
5955/// The three offsets differ only inside a block quote, where `>   - b` opens with
5956/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
5957/// `line_start == marker_start`, and `text` is the plain `"  - "`.
5958#[derive(Clone, Debug)]
5959struct ListMarker {
5960    /// The line's first byte.
5961    line_start: usize,
5962    /// Where the marker proper begins, past any quote prefix. The offset to hand
5963    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
5964    marker_start: usize,
5965    /// `line_start` through the marker's trailing space — quote prefix, indent
5966    /// and bullet together, which is what the next item's line opens with.
5967    text: String,
5968}
5969
5970impl ListMarker {
5971    /// Where the item's content starts — one past the marker's trailing space.
5972    fn content_start(&self) -> usize {
5973        self.line_start + self.text.len()
5974    }
5975}
5976
5977fn classify(c: char) -> Class {
5978    if c == '_' || c.is_alphanumeric() {
5979        Class::Word
5980    } else if c.is_whitespace() {
5981        Class::Space
5982    } else {
5983        Class::Other
5984    }
5985}
5986
5987/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
5988/// skip any leading separators, then consume the following word run.
5989fn next_word(s: &str, i: usize) -> usize {
5990    let mut off = i;
5991    let mut in_word = false;
5992    for c in s[i..].chars() {
5993        if classify(c) == Class::Word {
5994            in_word = true;
5995        } else if in_word {
5996            break;
5997        }
5998        off += c.len_utf8();
5999    }
6000    off
6001}
6002
6003/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
6004/// skip separators walking left, then consume the preceding word run.
6005fn prev_word(s: &str, i: usize) -> usize {
6006    let mut off = i;
6007    let mut in_word = false;
6008    for c in s[..i].chars().rev() {
6009        if classify(c) == Class::Word {
6010            in_word = true;
6011        } else if in_word {
6012            break;
6013        }
6014        off -= c.len_utf8();
6015    }
6016    off
6017}
6018
6019/// The `[start, end)` run of same-class characters surrounding `off` — the
6020/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
6021/// the run ending there is used.
6022fn word_range_at(s: &str, off: usize) -> (usize, usize) {
6023    if s.is_empty() {
6024        return (0, 0);
6025    }
6026    let off = off.min(s.len());
6027    let reference = if off < s.len() {
6028        s[off..].chars().next()
6029    } else {
6030        s[..off].chars().next_back()
6031    };
6032    let Some(rc) = reference else {
6033        return (off, off);
6034    };
6035    let class = classify(rc);
6036
6037    let mut start = off;
6038    for c in s[..start].chars().rev() {
6039        if classify(c) == class {
6040            start -= c.len_utf8();
6041        } else {
6042            break;
6043        }
6044    }
6045    let mut end = off;
6046    for c in s[end..].chars() {
6047        if classify(c) == class {
6048            end += c.len_utf8();
6049        } else {
6050            break;
6051        }
6052    }
6053    (start, end)
6054}
6055
6056/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
6057/// the line's start — terminal cells, not characters, so the column names the
6058/// cell the caret is drawn in even on a line of `你好` or emoji.
6059fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
6060    let off = off.min(s.len());
6061    let mut row = 0;
6062    let mut line_start = 0;
6063    for (i, &b) in s.as_bytes().iter().enumerate() {
6064        if i >= off {
6065            break;
6066        }
6067        if b == b'\n' {
6068            row += 1;
6069            line_start = i + 1;
6070        }
6071    }
6072    (row, wysiwyg::text_width(&s[line_start..off]))
6073}
6074
6075/// The byte offset at display column `col` of `row` (clamped to that line's
6076/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
6077///
6078/// A column landing *inside* a character — the second cell of `你`, or any cell
6079/// but the first of an emoji — resolves to that character's start, which is the
6080/// column the caret would have been drawn at to begin with. So both cells of a
6081/// wide character mean the character, and every offset survives the round trip
6082/// out to a column and back. The walk steps by grapheme cluster for the same
6083/// reason the caret does: a cluster is the character, and the cells belong to it
6084/// rather than to the codepoints spelling it.
6085fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
6086    let start = line_start(s, row);
6087    let end = line_end_from(s, start);
6088    let mut off = start;
6089    let mut at = 0; // the display column `off` sits at
6090    while off < end {
6091        let next = next_boundary(s, off).min(end);
6092        let cells = wysiwyg::text_width(&s[off..next]);
6093        if at + cells > col {
6094            break; // `col` is one of this cluster's own cells
6095        }
6096        at += cells;
6097        off = next;
6098    }
6099    off
6100}
6101
6102fn line_start(s: &str, row: usize) -> usize {
6103    if row == 0 {
6104        return 0;
6105    }
6106    let mut r = 0;
6107    for (i, &b) in s.as_bytes().iter().enumerate() {
6108        if b == b'\n' {
6109            r += 1;
6110            if r == row {
6111                return i + 1;
6112            }
6113        }
6114    }
6115    s.len()
6116}
6117
6118fn line_end_from(s: &str, start: usize) -> usize {
6119    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
6120}
6121
6122/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
6123/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
6124/// twig applies writing the mark out, so the toolbar can light the same button
6125/// that made the node.
6126///
6127/// `None` for every other kind, including the inline nodes that aren't marks at
6128/// all (`str`, `link`, `image`, the math and break kinds): they're things a
6129/// caret stands in, not formatting a button toggles.
6130fn inline_kind(kind: &Kind) -> Option<InlineKind> {
6131    Some(match kind {
6132        Kind::Strong => InlineKind::Strong,
6133        Kind::Emph => InlineKind::Emph,
6134        Kind::Verbatim => InlineKind::Verbatim,
6135        Kind::Mark => InlineKind::Mark,
6136        Kind::Superscript => InlineKind::Superscript,
6137        Kind::Subscript => InlineKind::Subscript,
6138        Kind::Insert => InlineKind::Insert,
6139        Kind::Delete => InlineKind::Delete,
6140        _ => return None,
6141    })
6142}
6143
6144/// A watermark for a file's contents (see `Doc::disk_hash`).
6145///
6146/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
6147/// watermark is compared only against one taken by the same process moments
6148/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
6149/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
6150fn hash_bytes(bytes: &[u8]) -> u64 {
6151    use std::hash::{Hash, Hasher};
6152    let mut h = std::collections::hash_map::DefaultHasher::new();
6153    bytes.hash(&mut h);
6154    h.finish()
6155}
6156
6157#[cfg(feature = "fs")]
6158fn detect_format(path: &Path) -> Result<Format> {
6159    let ext = path
6160        .extension()
6161        .and_then(|e| e.to_str())
6162        .unwrap_or("")
6163        .to_ascii_lowercase();
6164    Ok(match ext.as_str() {
6165        "dj" | "djot" => Format::Djot,
6166        "md" | "markdown" => Format::Markdown,
6167        "xml" => Format::Xml,
6168        "html" | "htm" => Format::Html,
6169        other => return Err(anyhow!("unknown document extension: .{other}")),
6170    })
6171}
6172
6173#[cfg(test)]
6174mod tests {
6175    use super::*;
6176
6177    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
6178    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
6179    /// without one is a view no user is ever in.
6180    fn doc_in(view: View, name: &str, body: &str) -> Doc {
6181        // The fixture name doubles as the temp file's, so two tests picking the
6182        // same one raced under the parallel runner and read each other's body —
6183        // a green suite proving the wrong thing. The counter makes that
6184        // unreachable rather than asking every future caller to notice.
6185        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6186        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6187        let mut p = std::env::temp_dir();
6188        p.push(format!("leaf_test_{name}_{seq}.md"));
6189        std::fs::write(&p, body).unwrap();
6190        let mut d = Doc::open(p).unwrap();
6191        d.view = view;
6192        if view == View::Wysiwyg {
6193            d.build_visual(80);
6194        }
6195        d
6196    }
6197
6198    // Source-view document for the source-behaviour tests. `Doc::open` now
6199    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
6200    // `wysiwyg_doc` builds the rich-text variant on top of this.
6201    fn doc_with(name: &str, body: &str) -> Doc {
6202        doc_in(View::Source, name, body)
6203    }
6204
6205    /// Every visual row's drawn text — what the reader actually sees, which is
6206    /// the only thing the reveal preference is supposed to change.
6207    fn drawn_rows(d: &Doc) -> Vec<String> {
6208        d.vmap
6209            .rows
6210            .iter()
6211            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6212            .collect()
6213    }
6214
6215    /// Put the caret at the first byte of `needle` and rebuild, so the row under
6216    /// it becomes the revealed line.
6217    fn caret_at(d: &mut Doc, needle: &str) {
6218        d.caret = d.source.find(needle).expect("needle in source");
6219        d.build_visual(80);
6220    }
6221
6222    #[test]
6223    fn blockquote_after_a_list_is_not_bulleted() {
6224        // twig nests a following top-level block quote under the `bullet_list`
6225        // (a direct child, not a `list_item`). The map must render it de-nested —
6226        // `│ quote`, never `• │ quote` — with a blank separator, like any block
6227        // that follows a list. Regression for the "combined list + blockquote" bug.
6228        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
6229        d.build_visual(80);
6230        let rows: Vec<String> = d
6231            .vmap
6232            .rows
6233            .iter()
6234            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6235            .collect();
6236        assert!(
6237            rows.iter().any(|r| r == "│ quote"),
6238            "block quote should render on its own gutter, got rows: {rows:?}"
6239        );
6240        assert!(
6241            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
6242            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
6243        );
6244    }
6245
6246    // ── the map is built at most once per (revision, wrap) ───────────────────
6247    //
6248    // A frontend repaints for reasons that have nothing to do with the text — a
6249    // blinking caret, a scroll — and rebuilding the map is O(document). These
6250    // pin *that the cache fires*, which a passing suite can't tell you: a cache
6251    // that never hits is invisible to every other test in this file.
6252    //
6253    // The probe is to wreck the built map and ask for it again. A rebuild
6254    // repairs it; a cache hit hands the wreckage straight back. Nothing else
6255    // can distinguish the two from outside.
6256
6257    #[test]
6258    fn a_rebuild_with_nothing_changed_reuses_the_map() {
6259        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
6260        d.build_visual(80);
6261        assert!(!d.vmap.rows.is_empty());
6262        d.vmap.rows.clear(); // wreck it
6263        d.build_visual(80);
6264        assert!(
6265            d.vmap.rows.is_empty(),
6266            "the map was rebuilt though nothing changed — the cache never fired"
6267        );
6268    }
6269
6270    #[test]
6271    fn an_edit_rebuilds_the_map() {
6272        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
6273        d.build_visual(80);
6274        let before = d.revision();
6275        d.vmap.rows.clear();
6276        d.insert("x");
6277        d.build_visual(80);
6278        assert!(d.revision() > before, "an edit must move the revision");
6279        assert!(
6280            !d.vmap.rows.is_empty(),
6281            "an edited document must not paint from a stale map"
6282        );
6283    }
6284
6285    #[test]
6286    fn a_width_change_rebuilds_the_map() {
6287        // The map is a function of the wrap width too, so a resize is a miss
6288        // even though the text is untouched.
6289        let mut d = doc_in(
6290            View::Wysiwyg,
6291            "cache_width",
6292            "one two three four five six\n",
6293        );
6294        d.build_visual(80);
6295        d.vmap.rows.clear();
6296        d.build_visual(12);
6297        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
6298        // And the unwrapped map is its own key, not the same as any width.
6299        d.vmap.rows.clear();
6300        d.build_visual_unwrapped();
6301        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
6302    }
6303
6304    #[test]
6305    fn a_motion_does_not_rebuild_the_map() {
6306        // The whole point: moving the caret changes nothing the map is built
6307        // from. If a motion bumped the revision, every arrow key would cost a
6308        // full rebuild and the cache would be worthless.
6309        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
6310        d.build_visual(80);
6311        let rev = d.revision();
6312        d.move_right(false);
6313        d.move_right(true);
6314        d.move_down(false);
6315        assert_eq!(d.revision(), rev, "a motion must not move the revision");
6316        d.vmap.rows.clear();
6317        d.build_visual(80);
6318        assert!(
6319            d.vmap.rows.is_empty(),
6320            "a motion should not rebuild the map"
6321        );
6322    }
6323
6324    #[test]
6325    fn saving_does_not_rebuild_the_map() {
6326        // Saving changes `dirty`, not the text.
6327        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
6328        d.insert("x");
6329        d.build_visual(80);
6330        let rev = d.revision();
6331        d.save();
6332        assert_eq!(d.revision(), rev, "a save must not move the revision");
6333        assert!(!d.dirty, "the save should have cleaned the document");
6334    }
6335
6336    #[test]
6337    fn a_reload_rebuilds_the_map() {
6338        // Reload replaces the text without going through `refresh`, so it has to
6339        // move the revision itself — else the editor paints the old file.
6340        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
6341        d.build_visual(80);
6342        let rev = d.revision();
6343        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
6344        d.reload();
6345        assert!(d.revision() > rev, "a reload must move the revision");
6346        d.build_visual(80);
6347        let text: String = d
6348            .vmap
6349            .rows
6350            .iter()
6351            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6352            .collect();
6353        assert!(
6354            text.contains("wholly new"),
6355            "the reloaded text should be on screen, got {text:?}"
6356        );
6357    }
6358
6359    // ── golden-case harness ──────────────────────────────────────────────────
6360    // The pattern the whole parity suite can reuse: write a fixture with the
6361    // caret marked by `|`, run one action, and compare the rendered result —
6362    // also caret-marked — against the expected string. One readable line per
6363    // behavior, and it exercises the exact `Doc` ops both frontends call.
6364
6365    /// Split a `|`-marked fixture into `(source, caret_offset)`.
6366    fn parse_caret(marked: &str) -> (String, usize) {
6367        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
6368        (marked.replacen('|', "", 1), caret)
6369    }
6370
6371    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
6372    /// selection) so a result reads like the fixtures.
6373    fn render_caret(d: &Doc) -> String {
6374        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
6375        // so the caret always renders inside its own selection.
6376        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
6377        if let Some((s, e)) = d.selection() {
6378            marks.push((s, 0, '['));
6379            marks.push((e, 2, ']'));
6380        }
6381        // Insert right-to-left: descending offset, then descending rank.
6382        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
6383        let mut out = d.source.clone();
6384        for (at, _, ch) in marks {
6385            out.insert(at, ch);
6386        }
6387        out
6388    }
6389
6390    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
6391    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6392        golden_in(View::Source, name, marked, action)
6393    }
6394
6395    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
6396    /// the same fixture has to read the same way in both.
6397    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6398        let (src, caret) = parse_caret(marked);
6399        let mut d = doc_in(view, name, &src);
6400        d.caret = caret;
6401        action(&mut d);
6402        render_caret(&d)
6403    }
6404
6405    #[test]
6406    fn word_motion_walks_word_by_word() {
6407        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
6408        assert_eq!(
6409            g("hello wor|ld", |d| d.move_word_left(false)),
6410            "hello |world"
6411        );
6412        assert_eq!(
6413            g("hello| world", |d| d.move_word_left(false)),
6414            "|hello world"
6415        );
6416        assert_eq!(
6417            g("hel|lo world", |d| d.move_word_right(false)),
6418            "hello| world"
6419        );
6420        assert_eq!(
6421            g("hello| world", |d| d.move_word_right(false)),
6422            "hello world|"
6423        );
6424        // Punctuation is its own class, so motion stops at the boundary.
6425        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6426    }
6427
6428    #[test]
6429    fn word_motion_extends_the_selection_when_asked() {
6430        assert_eq!(
6431            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6432            "hello [world|]"
6433        );
6434    }
6435
6436    #[test]
6437    fn delete_word_removes_a_whole_word() {
6438        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6439        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6440        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6441        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6442    }
6443
6444    // ── Home / End ───────────────────────────────────────────────────────────
6445
6446    #[test]
6447    fn home_toggles_between_the_line_s_text_and_its_margin() {
6448        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6449        // indent to the markup it spells everywhere it means one, so the fixture
6450        // with whitespace left to walk is a code block, which is verbatim.
6451        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6452        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
6453        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
6454        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
6455        // A line with no indentation has one place to go, so the toggle is a
6456        // no-op rather than a trip to nowhere.
6457        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6458        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6459
6460        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
6461        let indent = d.source.find("    indented").unwrap();
6462        d.caret = indent + 6; // inside "indented"
6463        d.move_home(false);
6464        assert_eq!(
6465            d.caret,
6466            indent + 4,
6467            "wysiwyg: Home aims at the code line's text"
6468        );
6469        d.move_home(false);
6470        assert_eq!(
6471            d.caret, indent,
6472            "wysiwyg: the second press takes the indent"
6473        );
6474        d.move_home(false);
6475        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6476    }
6477
6478    #[test]
6479    fn end_takes_the_line_the_view_is_showing() {
6480        // The line differs by view for the same document, and that is the point:
6481        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6482        // as a space on one row and the source view as two lines.
6483        let mut d = doc_with("end_src", "one two\nthree\n");
6484        d.caret = 1;
6485        d.move_end(false);
6486        assert_eq!(d.caret, 7, "source: the end of the source line");
6487
6488        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6489        d.caret = 1;
6490        d.move_end(false);
6491        assert_eq!(
6492            d.caret, 13,
6493            "wysiwyg: the end of the row, soft break and all"
6494        );
6495    }
6496
6497    #[test]
6498    fn home_and_end_extend_the_selection_when_asked() {
6499        for (view, tag) in VIEWS {
6500            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
6501            d.caret = 6;
6502            d.move_end(true);
6503            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
6504            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
6505            d.caret = 6;
6506            d.move_home(true);
6507            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
6508        }
6509    }
6510
6511    // ── kill to the line's start / end ───────────────────────────────────────
6512
6513    #[test]
6514    fn kill_to_the_line_start_and_end_in_both_views() {
6515        for (view, tag) in VIEWS {
6516            // The gap that reads as a paragraph break in each view: the source
6517            // view's lines are the renderer's rows only where the source says so.
6518            let gap = if view == View::Source { "\n" } else { "\n\n" };
6519            let mut d = doc_in(
6520                view,
6521                &format!("kill_end_{tag}"),
6522                &format!("one two{gap}three\n"),
6523            );
6524            d.caret = 3;
6525            d.delete_to_line_end();
6526            assert_eq!(
6527                d.source,
6528                format!("one{gap}three\n"),
6529                "{tag}: ^K to the line's end"
6530            );
6531            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
6532
6533            let mut d = doc_in(
6534                view,
6535                &format!("kill_start_{tag}"),
6536                &format!("one two{gap}three\n"),
6537            );
6538            d.caret = 7; // the end of the first line
6539            d.delete_to_line_start();
6540            assert_eq!(
6541                d.source,
6542                format!("{gap}three\n"),
6543                "{tag}: ⌘⌫ to the line's start"
6544            );
6545            assert_eq!(d.caret, 0, "{tag}");
6546        }
6547    }
6548
6549    #[test]
6550    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
6551        // The decision: at the boundary both kills do nothing, rather than
6552        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
6553        // at a soft wrap as often as at a newline, where there is nothing
6554        // written to delete — and a source newline is only half of the blank
6555        // line between two paragraphs, so taking it leaves a soft break rather
6556        // than the join it looks like. Backspace and Delete are the keys for it.
6557        for (view, tag) in VIEWS {
6558            let gap = if view == View::Source { "\n" } else { "\n\n" };
6559            let src = format!("one{gap}three\n");
6560            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
6561            d.caret = 3; // the end of "one"
6562            d.delete_to_line_end();
6563            assert_eq!(
6564                d.source, src,
6565                "{tag}: ^K at the line's end joined it to the next"
6566            );
6567
6568            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
6569            d.caret = 3 + gap.len(); // the start of "three"
6570            d.delete_to_line_start();
6571            assert_eq!(
6572                d.source, src,
6573                "{tag}: ⌘⌫ at the line's start joined it to the last"
6574            );
6575        }
6576    }
6577
6578    #[test]
6579    fn a_kill_takes_the_selection_when_there_is_one() {
6580        // What every other delete here does with one, so these two as well.
6581        for (view, tag) in VIEWS {
6582            for (name, kill) in [
6583                (
6584                    "end",
6585                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
6586                ),
6587                ("start", |d: &mut Doc| d.delete_to_line_start()),
6588            ] {
6589                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
6590                d.anchor = Some(4);
6591                d.caret = 7; // "two"
6592                kill(&mut d);
6593                assert_eq!(
6594                    d.source, "one  three\n",
6595                    "{tag}: {name} ignored the selection"
6596                );
6597                assert_eq!(d.selection(), None, "{tag}: {name}");
6598            }
6599        }
6600    }
6601
6602    #[test]
6603    fn a_kill_takes_the_markup_it_empties_with_it() {
6604        // The same hazard a word-delete has: a WYSIWYG range covers what the
6605        // user can see, which for `**bold**` is the word and never the
6606        // delimiters, so a kill that stopped at the text would leave `a ****` —
6607        // markup wrapped around nothing.
6608        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
6609        d.caret = d.source.find("bold").unwrap();
6610        d.delete_to_line_end();
6611        assert_eq!(d.source, "a \n");
6612    }
6613
6614    #[test]
6615    fn a_kill_is_undone_in_one_step() {
6616        for (view, tag) in VIEWS {
6617            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
6618            d.caret = 3;
6619            d.delete_to_line_end();
6620            assert_eq!(d.source, "one\n", "{tag}");
6621            d.undo();
6622            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
6623        }
6624    }
6625
6626    #[test]
6627    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
6628        // Regression: triple-click used move_home/move_end over visual rows, so
6629        // it only worked on a paragraph's first row (a wrap-boundary offset maps
6630        // to the earlier row). select_block_at reads the AST, so every offset in
6631        // the paragraph selects the whole thing.
6632        let body = "one two three four five six seven eight\n";
6633        let mut d = doc_with("sel_block", body);
6634        d.view = View::Wysiwyg;
6635        d.build_visual(12); // force the paragraph to wrap into several rows
6636        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
6637        let para = (0, "one two three four five six seven eight".len());
6638        for off in [0usize, 8, 19, 28, 38] {
6639            d.caret = 0;
6640            d.anchor = None;
6641            d.select_block_at(off);
6642            assert_eq!(
6643                d.selection(),
6644                Some(para),
6645                "offset {off} should select the paragraph"
6646            );
6647        }
6648    }
6649
6650    #[test]
6651    fn select_block_uses_content_span_for_a_heading() {
6652        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
6653        d.select_block_at(4); // inside "Title"
6654        // content_span excludes the "# " marker.
6655        assert_eq!(d.selected_text(), Some("Title"));
6656        d.select_block_at(10); // inside "body"
6657        assert_eq!(d.selected_text(), Some("body"));
6658    }
6659
6660    #[test]
6661    fn select_all_spans_the_document() {
6662        let mut d = doc_with("sel_all", "abc\n\ndef\n");
6663        d.select_all();
6664        assert_eq!(d.selection(), Some((0, d.source.len())));
6665    }
6666
6667    #[test]
6668    fn select_word_at_picks_the_surrounding_word() {
6669        let mut d = doc_with("sel_word", "hello world\n");
6670        d.select_word_at(8); // inside "world"
6671        assert_eq!(d.selection(), Some((6, 11)));
6672        // Double-clicking at end-of-word still grabs the word to its left.
6673        d.select_word_at(5); // the space between the words
6674        assert_eq!(d.selection(), Some((5, 6)));
6675    }
6676
6677    #[test]
6678    fn word_helpers_respect_utf8_boundaries() {
6679        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
6680        assert_eq!(
6681            golden("utf8", "|café ok", |d| d.move_word_right(false)),
6682            "café| ok"
6683        );
6684        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
6685    }
6686
6687    #[test]
6688    fn typing_inserts_at_the_caret_and_advances_it() {
6689        let mut d = doc_with("type", "hello\n");
6690        d.insert("Hi ");
6691        assert_eq!(d.source, "Hi hello\n");
6692        assert_eq!(d.caret, 3);
6693        assert!(d.dirty);
6694    }
6695
6696    #[test]
6697    fn backspace_deletes_the_char_before_the_caret() {
6698        let mut d = doc_with("bs", "hello\n");
6699        d.caret = 3; // after "hel"
6700        d.backspace();
6701        assert_eq!(d.source, "helo\n");
6702        assert_eq!(d.caret, 2);
6703    }
6704
6705    #[test]
6706    fn typing_replaces_the_selection() {
6707        let mut d = doc_with("replace", "a word b\n");
6708        d.anchor = Some(2);
6709        d.caret = 6; // "word" selected
6710        d.insert("X");
6711        assert_eq!(d.source, "a X b\n");
6712        assert_eq!(d.caret, 3);
6713        assert_eq!(d.anchor, None);
6714    }
6715
6716    #[test]
6717    fn toggle_bold_wraps_then_unwraps_the_selection() {
6718        let mut d = doc_with("bold", "a word b\n");
6719        d.anchor = Some(2);
6720        d.caret = 6;
6721        d.toggle(InlineKind::Strong);
6722        assert_eq!(d.source, "a **word** b\n");
6723        // The toggled region stays selected, so a second toggle reverses it.
6724        d.toggle(InlineKind::Strong);
6725        assert_eq!(d.source, "a word b\n");
6726        d.toggle(InlineKind::Strong);
6727        assert_eq!(d.source, "a **word** b\n");
6728    }
6729
6730    #[test]
6731    fn toggle_code_wraps_then_unwraps_the_selection() {
6732        let mut d = doc_with("code_rt", "a word b\n");
6733        d.anchor = Some(2);
6734        d.caret = 6;
6735        d.toggle(InlineKind::Verbatim);
6736        assert_eq!(d.source, "a `word` b\n");
6737        d.toggle(InlineKind::Verbatim);
6738        assert_eq!(d.source, "a word b\n");
6739    }
6740
6741    #[test]
6742    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
6743        // ⌘b at a bare caret, then type: the text comes out bold with no
6744        // selection ever made — the word-processor "start bold here" gesture.
6745        let mut d = doc_with("sticky_wrap", "xy\n");
6746        d.caret = 1; // between x and y
6747        d.toggle(InlineKind::Strong);
6748        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
6749        d.insert("A");
6750        assert_eq!(d.source, "x**A**y\n");
6751    }
6752
6753    #[test]
6754    fn sticky_bold_lights_the_toolbar_before_any_typing() {
6755        // The button must light the instant ⌘b is pressed, or the mode is
6756        // invisible until the first character lands.
6757        let mut d = doc_with("sticky_light", "xy\n");
6758        d.caret = 1;
6759        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6760        d.toggle(InlineKind::Strong);
6761        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6762    }
6763
6764    #[test]
6765    fn sticky_bold_toggled_off_types_normally_again() {
6766        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
6767        // in the flow of typing, the exact sequence the user described.
6768        let mut d = doc_with("sticky_off", "\n");
6769        d.caret = 0;
6770        d.toggle(InlineKind::Strong);
6771        d.insert("a");
6772        d.insert("b"); // continues inside the run, no re-arming
6773        assert_eq!(d.source, "**ab**\n");
6774        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
6775        d.insert("c");
6776        assert_eq!(d.source, "**ab**c\n");
6777    }
6778
6779    #[test]
6780    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
6781        // Once a mark is realised the caret sits inside the run, so plain typing
6782        // extends it rather than starting a second, adjacent bold span.
6783        let mut d = doc_with("sticky_cont", "\n");
6784        d.caret = 0;
6785        d.toggle(InlineKind::Emph);
6786        d.insert("h");
6787        d.insert("i");
6788        assert_eq!(d.source, "*hi*\n");
6789    }
6790
6791    #[test]
6792    fn moving_the_caret_disarms_a_sticky_mark() {
6793        // Arming a mark and then moving away must not style text elsewhere.
6794        let mut d = doc_with("sticky_disarm", "xy\n");
6795        d.caret = 0;
6796        d.toggle(InlineKind::Strong);
6797        d.move_right(false); // caret 0 → 1, disarms
6798        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6799        d.insert("A");
6800        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
6801    }
6802
6803    #[test]
6804    fn stacked_sticky_marks_apply_together() {
6805        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
6806        let mut d = doc_with("sticky_stack", "\n");
6807        d.caret = 0;
6808        d.toggle(InlineKind::Strong);
6809        d.toggle(InlineKind::Emph);
6810        d.insert("x");
6811        // Land the caret on the styled character and confirm both marks are live.
6812        d.anchor = Some(d.source.find('x').unwrap());
6813        d.caret = d.anchor.unwrap() + 1;
6814        let marks = d.active_inline_marks();
6815        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
6816        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
6817    }
6818
6819    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
6820
6821    #[test]
6822    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
6823        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
6824        // The space inside the run made `**bold **`, which is *not* bold — four
6825        // literal asterisks — so the rich view drew them, correctly and
6826        // uselessly, until the next character happened to close the run again.
6827        let mut d = wysiwyg_doc("edge_typing", "a \n");
6828        d.caret = 2;
6829        d.toggle(InlineKind::Strong);
6830        for c in "bold".chars() {
6831            d.insert(&c.to_string());
6832        }
6833        assert_eq!(d.source, "a **bold**\n");
6834        d.insert(" ");
6835        assert_eq!(
6836            d.source, "a **bold** \n",
6837            "the space belongs outside the run"
6838        );
6839        assert!(
6840            d.active_inline_marks().contains(InlineKind::Strong),
6841            "bold is still what's being typed, so the button stays lit"
6842        );
6843        // What the writer is looking at while all this happens: their words.
6844        d.build_visual(80);
6845        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6846        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
6847        for c in "hey".chars() {
6848            d.insert(&c.to_string());
6849        }
6850        assert_eq!(
6851            d.source, "a **bold hey**\n",
6852            "one bold phrase, not two runs"
6853        );
6854    }
6855
6856    #[test]
6857    fn typing_past_a_space_can_still_leave_the_bold_behind() {
6858        // The other half: the marks stay armed across the space, so ⌘b turns
6859        // them off again there and the next word is plain — the run isn't
6860        // rejoined by a caret that was told not to.
6861        let mut d = wysiwyg_doc("edge_shed", "\n");
6862        d.caret = 0;
6863        d.toggle(InlineKind::Strong);
6864        for c in "bold ".chars() {
6865            d.insert(&c.to_string());
6866        }
6867        assert_eq!(d.source, "**bold** \n");
6868        d.toggle(InlineKind::Strong);
6869        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6870        d.insert("x");
6871        assert_eq!(d.source, "**bold** x\n");
6872    }
6873
6874    #[test]
6875    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
6876        // ⌘b and then a space before any word: the space is not marked (nothing
6877        // is), and the word after it is.
6878        let mut d = wysiwyg_doc("edge_space_first", "a\n");
6879        d.caret = 1;
6880        d.toggle(InlineKind::Strong);
6881        d.insert(" ");
6882        assert_eq!(d.source, "a \n");
6883        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6884        d.insert("b");
6885        assert_eq!(d.source, "a **b**\n");
6886    }
6887
6888    #[test]
6889    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
6890        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
6891        d.caret = 8; // the caret's home at the end of the run's text
6892        d.insert(" ");
6893        assert_eq!(
6894            d.source, "x **bold** \n",
6895            "the space lands past the delimiters"
6896        );
6897        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
6898
6899        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
6900        d.caret = 4; // in front of the "b"
6901        d.insert(" ");
6902        assert_eq!(d.source, "x  **bold** y\n");
6903        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
6904    }
6905
6906    #[test]
6907    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
6908        // Backspace over the last letter of a bold phrase.
6909        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
6910        d.caret = 10; // past the "h"
6911        d.backspace();
6912        assert_eq!(d.source, "a **bold** \n");
6913        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
6914        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6915        d.insert("x");
6916        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
6917    }
6918
6919    #[test]
6920    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
6921        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
6922        // mark, which is only text. The marks live on in the caret instead.
6923        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
6924        d.caret = 5;
6925        d.backspace();
6926        assert_eq!(d.source, "a  c\n");
6927        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6928        d.insert("x");
6929        assert_eq!(d.source, "a **x** c\n");
6930    }
6931
6932    #[test]
6933    fn typing_over_a_whole_bold_word_keeps_it_bold() {
6934        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
6935        d.anchor = Some(4);
6936        d.caret = 8; // the word, not its delimiters
6937        d.insert("x");
6938        assert_eq!(d.source, "a **x** c\n");
6939    }
6940
6941    #[test]
6942    fn a_code_span_keeps_the_space_it_is_given() {
6943        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
6944        // is still verbatim, so nothing is re-spelt. The repair asks the parser
6945        // rather than a table of kinds, and this is the answer it gets.
6946        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
6947        d.caret = 7;
6948        d.insert(" ");
6949        assert_eq!(d.source, "a `code ` c\n");
6950    }
6951
6952    #[test]
6953    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
6954        // A run's closing delimiter has a caret home on each side of it, one
6955        // column apart on screen — and a plain ← off the space after a bold word
6956        // lands on the outer one. The character drawn behind the caret there is
6957        // still the last letter of the phrase, so that is what Backspace takes;
6958        // the byte behind it is a `*` nobody can see.
6959        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
6960        d.caret = 9;
6961        d.move_left(false);
6962        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
6963        d.backspace();
6964        assert_eq!(
6965            d.source, "**bol** x\n",
6966            "a letter of the phrase, not its `*`"
6967        );
6968        assert_eq!(d.caret, 5);
6969
6970        // And the mirror in front of the opening delimiter, where Delete's
6971        // character is the first letter of the run.
6972        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
6973        d.caret = 1;
6974        d.delete_forward();
6975        assert_eq!(d.source, "x**old**\n");
6976        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
6977    }
6978
6979    #[test]
6980    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
6981        // The byte beside the caret at either edge of a bold word is a `*` the
6982        // rich view draws nothing for. Taking it is not the character delete the
6983        // key was pressed for — it unspells the run and puts a literal asterisk
6984        // on screen (`a *bold** c`). The visible character is the one that goes.
6985        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
6986        d.caret = 4; // in front of the "b"
6987        d.backspace();
6988        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
6989
6990        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
6991        d.caret = 8; // past the "d"
6992        d.delete_forward();
6993        assert_eq!(d.source, "a **bold**c\n");
6994        assert_eq!(d.caret, 8, "and the caret stays inside the run");
6995        d.insert("x");
6996        assert_eq!(d.source, "a **boldx**c\n");
6997
6998        // A code span's backticks are hidden the same way, so they are covered
6999        // by the same rule and not by a list of kinds.
7000        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
7001        d.caret = 3;
7002        d.backspace();
7003        assert_eq!(d.source, "a`code` c\n");
7004    }
7005
7006    #[test]
7007    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
7008        // The asterisks are on the screen there and the caret can stand between
7009        // them, so a delete takes exactly the byte it is aimed at.
7010        let mut d = doc_with("edge_open_src", "a **bold** c\n");
7011        d.caret = 4;
7012        d.backspace();
7013        assert_eq!(d.source, "a *bold** c\n");
7014
7015        let mut d = doc_with("edge_close_src", "a **bold** c\n");
7016        d.caret = 8;
7017        d.delete_forward();
7018        assert_eq!(d.source, "a **bold* c\n");
7019    }
7020
7021    #[test]
7022    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
7023        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
7024        // The space had stepped outside the run (the mark-edge rule), taking the
7025        // caret with it, so the delete put it back down on the far side of the
7026        // closing `**` — one place on screen, and the wrong side of it. Typing
7027        // came out plain and the toolbar went dark, with nothing to see.
7028        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
7029        d.caret = 0;
7030        d.toggle(InlineKind::Strong);
7031        for c in "bold".chars() {
7032            d.insert(&c.to_string());
7033        }
7034        d.insert(" ");
7035        assert_eq!(d.source, "**bold** \n");
7036        d.backspace();
7037        assert_eq!(
7038            d.source, "**bold**\n",
7039            "the space goes, the delimiters stay"
7040        );
7041        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
7042        assert!(
7043            d.active_inline_marks().contains(InlineKind::Strong),
7044            "so the button is still lit"
7045        );
7046        d.insert("x");
7047        assert_eq!(
7048            d.source, "**boldx**\n",
7049            "and the next character is still bold"
7050        );
7051    }
7052
7053    #[test]
7054    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
7055        // What the stranded caret did next: the byte behind it was the closing
7056        // `*`, so a second press took that instead of a letter — `**bold*`, the
7057        // styling gone and an asterisk on the screen where the word had been.
7058        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
7059        d.caret = 0;
7060        d.toggle(InlineKind::Strong);
7061        for c in "bold ".chars() {
7062            d.insert(&c.to_string());
7063        }
7064        assert_eq!(d.source, "**bold** \n");
7065        d.backspace();
7066        d.backspace();
7067        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
7068        assert_eq!(d.caret, 5);
7069    }
7070
7071    #[test]
7072    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
7073        // `***both***` closes two runs with one stack of asterisks: the caret has
7074        // to walk in through all of them, or it lands between the emph and the
7075        // strong and types half-marked.
7076        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
7077        d.caret = 11;
7078        d.backspace();
7079        assert_eq!(d.source, "***both***\n");
7080        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
7081        d.insert("x");
7082        assert_eq!(d.source, "***bothx***\n");
7083    }
7084
7085    #[test]
7086    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
7087        // The settle only moves a caret a run actually closed over. Ordinary
7088        // deletes — inside a run, or in plain prose — are untouched.
7089        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
7090        d.caret = 8;
7091        d.backspace();
7092        assert_eq!(d.source, "a **bol** c\n");
7093        assert_eq!(d.caret, 7);
7094
7095        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
7096        d.caret = 5;
7097        d.backspace();
7098        assert_eq!(d.source, "plai\n");
7099        assert_eq!(d.caret, 4);
7100    }
7101
7102    #[test]
7103    fn the_source_view_leaves_a_delete_where_it_landed() {
7104        // The delimiters are on the screen there, so the offset past them is a
7105        // place the caret can be seen to be — nothing to settle.
7106        let mut d = doc_with("edge_bksp_src", "**bold** \n");
7107        d.caret = 9;
7108        d.backspace();
7109        assert_eq!(d.source, "**bold**\n");
7110        assert_eq!(d.caret, 8);
7111    }
7112
7113    #[test]
7114    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
7115        // `***both***` closes two runs with one stack of asterisks; a space that
7116        // clears only the inner one lands against the outer's and breaks that
7117        // instead.
7118        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
7119        d.caret = 9;
7120        d.insert(" ");
7121        assert_eq!(d.source, "a ***both*** \n");
7122        assert_eq!(d.caret, 13);
7123        d.insert("x");
7124        assert_eq!(d.source, "a ***both x***\n");
7125    }
7126
7127    #[test]
7128    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
7129        // The delimiter shuffle is not an edit the writer made, so it is not a
7130        // step they have to undo past.
7131        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
7132        d.caret = 8;
7133        d.insert(" ");
7134        assert_eq!(d.source, "a **bold** \n");
7135        d.undo();
7136        assert_eq!(d.source, "a **bold**\n");
7137    }
7138
7139    #[test]
7140    fn the_source_view_types_the_space_where_it_was_asked_to() {
7141        // The rule is a rich-view courtesy. In the source view the delimiters are
7142        // on the screen and the user is editing the bytes they can see.
7143        let mut d = doc_with("edge_src", "a **bold** c\n");
7144        d.caret = 8;
7145        d.insert(" ");
7146        assert_eq!(d.source, "a **bold ** c\n");
7147    }
7148
7149    #[test]
7150    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
7151        // Double-clicking a word takes the space after it; bolding that must not
7152        // spell `**word **`, which is not bold at all.
7153        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
7154        d.anchor = Some(2);
7155        d.caret = 7; // "word "
7156        d.toggle(InlineKind::Strong);
7157        assert_eq!(d.source, "a **word** b\n");
7158        d.toggle(InlineKind::Strong);
7159        assert_eq!(d.source, "a word b\n");
7160        d.toggle(InlineKind::Strong);
7161        assert_eq!(
7162            d.source, "a **word** b\n",
7163            "reapplying the mark must not wrap stale delimiter offsets"
7164        );
7165        // And a selection of nothing but whitespace has no word to mark.
7166        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
7167        d.anchor = Some(6);
7168        d.caret = 7;
7169        d.toggle(InlineKind::Strong);
7170        assert_eq!(d.source, "a word b\n");
7171        assert!(d.status.is_some());
7172    }
7173
7174    #[test]
7175    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
7176        let mut d = doc_with("head_set", "hello\n");
7177        d.caret = 2; // caret inside the paragraph, no selection
7178        d.set_block(BlockKind::Heading(1));
7179        assert_eq!(d.source, "# hello\n");
7180    }
7181
7182    #[test]
7183    fn set_block_heading_works_in_wysiwyg_view() {
7184        // The app defaults to WYSIWYG; the caret is a source offset either way.
7185        let mut d = wysiwyg_doc("head_wys", "hello\n");
7186        d.caret = 2;
7187        d.set_block(BlockKind::Heading(1));
7188        assert_eq!(d.source, "# hello\n");
7189    }
7190
7191    #[test]
7192    fn toggle_heading_applies_switches_and_reverts() {
7193        let mut d = doc_with("head_toggle", "hello\n");
7194        d.caret = 2;
7195        d.toggle_heading(1);
7196        assert_eq!(d.source, "# hello\n"); // paragraph → H1
7197        d.toggle_heading(2);
7198        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
7199        d.toggle_heading(2);
7200        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
7201    }
7202
7203    #[test]
7204    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
7205        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
7206        // the blank line but the caret rendered on the *next* line, because the
7207        // separator was a non-navigable decoration row. In Preserve flow that
7208        // blank line is a real caret home — the caret must resolve onto it, and
7209        // typing there makes the soft break that continues the paragraph.
7210        let src = "line one:\nsecond line\n";
7211        let mut d = wysiwyg_doc("pre_enter_lineend", src);
7212        d.set_line_flow(LineFlow::Preserve);
7213        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
7214        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
7215        d.newline();
7216        d.build_visual_unwrapped();
7217        assert_eq!(d.source, "line one:\n\nsecond line\n");
7218        assert_eq!(
7219            d.caret, 10,
7220            "caret sits on the new blank line, not the next line"
7221        );
7222        // The blank line is row 1, and the caret resolves onto it — not row 2.
7223        assert_eq!(
7224            d.vmap.pos_of_offset(10),
7225            (1, 0),
7226            "caret renders on the blank row"
7227        );
7228        assert!(
7229            !d.vmap.rows[1].decoration,
7230            "the blank line is navigable in Preserve"
7231        );
7232        // Typing there makes a soft break: one paragraph, three lines.
7233        d.insert("new clause,");
7234        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
7235    }
7236
7237    #[test]
7238    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
7239        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
7240        // that keeps it one paragraph — where Fold would open a second paragraph.
7241        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
7242        d.set_line_flow(LineFlow::Preserve);
7243        d.caret = 3;
7244        d.newline();
7245        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
7246
7247        // End-of-paragraph: Enter then typing continues the same paragraph on a
7248        // new line (a soft break), not a fresh paragraph.
7249        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
7250        d.set_line_flow(LineFlow::Preserve);
7251        d.caret = 3;
7252        d.newline();
7253        d.insert("def");
7254        assert_eq!(
7255            d.source, "abc\ndef\n",
7256            "end-of-line Enter + typing is a soft break"
7257        );
7258    }
7259
7260    #[test]
7261    fn preserve_double_enter_still_makes_a_paragraph() {
7262        // Two Enters in a row promote to a real paragraph break: the second lands
7263        // on the blank line the first opened and takes the empty-line branch.
7264        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
7265        d.set_line_flow(LineFlow::Preserve);
7266        d.caret = 3;
7267        d.newline();
7268        d.newline();
7269        d.insert("def");
7270        assert_eq!(
7271            d.source, "abc\n\ndef\n",
7272            "double Enter is a paragraph break"
7273        );
7274    }
7275
7276    #[test]
7277    fn preserve_backspace_joins_across_a_soft_break() {
7278        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
7279        // soft break it deletes the single newline and joins the two lines.
7280        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
7281        d.set_line_flow(LineFlow::Preserve);
7282        d.build_visual(80);
7283        d.caret = 4; // start of "def", just past the soft break
7284        d.backspace();
7285        assert_eq!(
7286            d.source, "abcdef\n",
7287            "Backspace joins across the soft break"
7288        );
7289        assert_eq!(d.caret, 3, "caret lands where the lines meet");
7290    }
7291
7292    #[test]
7293    fn fold_enter_still_starts_a_new_paragraph() {
7294        // The default flow is unchanged: a lone `\n` would render as an invisible
7295        // space, so Enter keeps opening the paragraph break that actually shows.
7296        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
7297        d.caret = 3;
7298        d.newline();
7299        assert_eq!(
7300            d.source, "abc\n\ndef\n",
7301            "Fold mid-line Enter is a paragraph break"
7302        );
7303    }
7304
7305    #[test]
7306    fn wysiwyg_one_enter_starts_a_new_paragraph() {
7307        // Regression: one Enter left the caret between the two newlines, so typing
7308        // made a soft break (one paragraph) and you needed a second Enter.
7309        let mut d = wysiwyg_doc("wys_enter", "abc\n");
7310        d.caret = 3;
7311        d.newline();
7312        d.insert("def");
7313        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
7314    }
7315
7316    #[test]
7317    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
7318        // Regression: Enter at the caret's natural End-of-line resting place
7319        // after a bold run with nothing following it (on screen: right after
7320        // "bold", before the hidden closing "**") spliced the paragraph break
7321        // at that very byte offset — which sits *before* the closing "**" in
7322        // the source, since the delimiter is hidden and emits no glyph of its
7323        // own for `push_row`'s "end of row" fallback to count. That severed the
7324        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
7325        // "**" alone on the new line instead of leaving "**bold**" intact with
7326        // a fresh empty paragraph after it.
7327        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
7328        d.move_end(false); // the WYSIWYG End key, from caret 0
7329        assert_eq!(
7330            d.caret, 6,
7331            "caret rests right after \"bold\", before the hidden \"**\""
7332        );
7333        d.newline();
7334        assert!(
7335            d.source.starts_with("**bold**"),
7336            "the closing ** must stay attached to \"bold\": got {:?}",
7337            d.source
7338        );
7339        assert_eq!(
7340            d.source, "**bold**\n\n\n",
7341            "a fresh empty paragraph follows the still-intact bold run"
7342        );
7343    }
7344
7345    #[test]
7346    fn source_view_enter_is_a_single_newline() {
7347        let mut d = doc_with("src_enter", "abc\n");
7348        d.caret = 3;
7349        d.newline();
7350        assert_eq!(d.source, "abc\n\n");
7351    }
7352
7353    #[test]
7354    fn heading_applies_at_the_end_of_a_paragraph() {
7355        // The caret at a line end sits at the doc level; set_block must still find
7356        // the block on that line.
7357        let mut d = doc_with("head_end", "abc\n");
7358        d.caret = 3; // end of "abc"
7359        d.toggle_heading(1);
7360        assert_eq!(d.source, "# abc\n");
7361    }
7362
7363    #[test]
7364    fn heading_on_an_empty_new_paragraph_creates_one() {
7365        let mut d = wysiwyg_doc("head_empty", "abc\n");
7366        d.caret = 3;
7367        d.newline(); // caret now on a fresh, empty paragraph
7368        d.toggle_heading(1);
7369        d.insert("Title");
7370        assert!(d.source.contains("# Title"), "got {:?}", d.source);
7371    }
7372
7373    #[test]
7374    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
7375        // The reported bug, end to end: click a blank line with another one under
7376        // it, press H1, type. The text landed in the heading and the caret's
7377        // offset was right (the source view drew it there), but the rich view
7378        // drew it two rows lower, on the trailing blank line — the empty `# `
7379        // heading had left every row below it short by the marker's two bytes,
7380        // and the blank line ended up claiming the heading's own end offset.
7381        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
7382        d.build_visual_unwrapped();
7383        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
7384        d.toggle_heading(1);
7385        for c in "title".chars() {
7386            d.insert(&c.to_string());
7387            d.build_visual_unwrapped(); // as a frontend does, one frame per key
7388        }
7389        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
7390        assert_eq!(
7391            d.caret_pos(),
7392            (4, 5),
7393            "the caret draws at the end of the heading"
7394        );
7395    }
7396
7397    #[test]
7398    fn clicking_an_empty_heading_types_after_its_marker() {
7399        // The same anchor from the other side: the empty heading's row is its own
7400        // caret home, so a click on it must land past the hidden `# `. Landing in
7401        // front of the hashes made the first keystroke un-heading the line.
7402        let mut d = wysiwyg_doc("head_click", "# \n");
7403        d.build_visual_unwrapped();
7404        d.caret = d.vmap.offset_of_pos(0, 0);
7405        d.insert("x");
7406        assert_eq!(d.source, "# x\n");
7407    }
7408
7409    #[test]
7410    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
7411        let mut d = wysiwyg_doc("head_enter", "# Title\n");
7412        d.caret = 7; // end of the heading
7413        d.newline();
7414        d.insert("body");
7415        assert_eq!(d.source, "# Title\n\nbody\n");
7416    }
7417
7418    #[test]
7419    fn wysiwyg_enter_continues_a_bullet_list() {
7420        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7421        d.caret = 6; // end of "item"
7422        d.newline();
7423        d.insert("two");
7424        assert_eq!(d.source, "- item\n- two\n");
7425    }
7426
7427    #[test]
7428    fn wysiwyg_enter_increments_an_ordered_list() {
7429        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7430        d.caret = 6; // end of "one"
7431        d.newline();
7432        d.insert("two");
7433        assert_eq!(d.source, "1. one\n2. two\n");
7434    }
7435
7436    #[test]
7437    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7438        // Regression for the "extra newline" left between a list and the paragraph
7439        // below it. Enter, Enter leaves the list on a fresh empty paragraph
7440        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7441        // Backspace should then take the caret cleanly back to the end of the list
7442        // item, `- item\n\nnext`, not delete a single newline and strand it on the
7443        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7444        // no caret can land on. The map is rebuilt between keystrokes exactly as a
7445        // frontend does, since Backspace reads the stop table to place the delete.
7446        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7447        d.caret = 6; // end of "item"
7448        d.newline();
7449        d.build_visual(80);
7450        d.newline(); // leave the list onto a fresh empty paragraph
7451        d.build_visual(80);
7452        assert_eq!(
7453            d.source, "- item\n\n\n\nnext\n",
7454            "double-Enter opens the empty paragraph"
7455        );
7456        d.backspace();
7457        assert_eq!(
7458            d.source, "- item\n\nnext\n",
7459            "one Backspace collapses the whole gap"
7460        );
7461        assert_eq!(
7462            d.caret, 6,
7463            "and lands the caret back at the end of the list item"
7464        );
7465    }
7466
7467    #[test]
7468    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7469        // The stop-wise delete must not over-reach when there is no block boundary
7470        // to cross: two blank lines in a row are one caret stop apart, so pressing
7471        // Enter on an empty line and then Backspace removes exactly the one newline
7472        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7473        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7474        d.caret = 5; // the empty paragraph the first Enter already opened
7475        d.build_visual(80);
7476        d.newline();
7477        d.build_visual(80);
7478        assert_eq!(
7479            d.source, "abc\n\n\n\n",
7480            "Enter on the blank line adds one newline"
7481        );
7482        d.backspace();
7483        assert_eq!(
7484            d.source, "abc\n\n\n",
7485            "Backspace takes back exactly that one newline"
7486        );
7487    }
7488
7489    #[test]
7490    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7491        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7492        d.caret = 6; // end of the empty "- " item
7493        d.newline();
7494        d.insert("p");
7495        assert_eq!(d.source, "- a\n\np\n");
7496    }
7497
7498    #[test]
7499    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
7500        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
7501        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
7502        // not take the list-exit path (which would splice the `- ` away as if
7503        // leaving an empty item); the AST guard sends it to a normal break and
7504        // leaves the underline intact.
7505        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
7506        assert!(
7507            d.nodes().iter().any(|n| n.kind == Kind::Heading),
7508            "precondition: twig parses this as a heading, not a list",
7509        );
7510        d.caret = 7; // on the `- ` underline line
7511        d.newline();
7512        assert!(
7513            d.source.contains("- "),
7514            "the setext underline survives, not spliced away as a list item: {:?}",
7515            d.source,
7516        );
7517    }
7518
7519    #[test]
7520    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
7521        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
7522        d.caret = 7; // end of "abc" inside the fence
7523        d.newline();
7524        d.insert("def");
7525        assert_eq!(d.source, "```\nabc\ndef\n```\n");
7526    }
7527
7528    #[test]
7529    fn wysiwyg_enter_continues_a_block_quote() {
7530        // Enter opens a new *paragraph* inside the quote, not a second line of
7531        // the same one. `> quote\n> more` is a soft break, which under
7532        // `LineFlow::Fold` renders as a space — the keystroke would look like it
7533        // did nothing. The quoted blank line is what makes the break visible, and
7534        // it's the same thing Enter does in running prose.
7535        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
7536        d.caret = 7; // end of "quote"
7537        d.newline();
7538        d.insert("more");
7539        assert_eq!(d.source, "> quote\n>\n> more\n");
7540        // Still one quote, now holding two paragraphs — not a quote and a stray
7541        // line that fell out of it.
7542        let quotes = d
7543            .nodes()
7544            .iter()
7545            .filter(|n| n.kind == Kind::BlockQuote)
7546            .count();
7547        assert_eq!(quotes, 1);
7548    }
7549
7550    #[test]
7551    fn set_block_makes_a_heading_at_the_caret() {
7552        let mut d = doc_with("head", "Title\n\nbody\n");
7553        d.caret = 0;
7554        d.set_block(BlockKind::Heading(2));
7555        assert_eq!(d.source, "## Title\n\nbody\n");
7556        d.set_block(BlockKind::Paragraph);
7557        assert_eq!(d.source, "Title\n\nbody\n");
7558    }
7559
7560    // ── block containers (quote / list) ──────────────────────────────────────
7561
7562    #[test]
7563    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
7564        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
7565        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
7566        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
7567        // A caret at a line end sits at the doc level; the block is still found.
7568        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
7569    }
7570
7571    #[test]
7572    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
7573        // Every source line of the paragraph gets its own `> `, so a caret left
7574        // on its old byte offset falls one prefix per line above it too far
7575        // back — inside the markup it just asked for rather than in its word.
7576        assert_eq!(
7577            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
7578            "> aaa\n> b|bb\n> ccc\n"
7579        );
7580    }
7581
7582    #[test]
7583    fn toggle_blockquote_works_in_wysiwyg_view() {
7584        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7585        assert_eq!(
7586            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
7587            "> hel|lo\n"
7588        );
7589        assert_eq!(
7590            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
7591            "hel|lo\n"
7592        );
7593    }
7594
7595    #[test]
7596    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
7597        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
7598        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7599        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7600        // The *other* kind converts in place instead of nesting, which is what
7601        // makes the two buttons one three-state control.
7602        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7603        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7604        // Its own kind, over the only item the list holds, takes it off.
7605        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
7606    }
7607
7608    #[test]
7609    fn toggle_list_works_in_wysiwyg_view() {
7610        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7611        assert_eq!(
7612            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
7613            "1. hel|lo\n"
7614        );
7615        assert_eq!(
7616            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
7617            "- hel|lo\n"
7618        );
7619        assert_eq!(
7620            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
7621            "hel|lo\n"
7622        );
7623    }
7624
7625    #[test]
7626    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
7627        // The selection has to grow with the markup: twig takes a container off
7628        // only a range covering every block it holds, so the second press can
7629        // reverse the first only if the result is what's selected.
7630        let mut d = doc_with("list_sel", "abc\n\ndef\n");
7631        d.select_all();
7632        d.toggle_list(true);
7633        assert_eq!(d.source, "1. abc\n\n2. def\n");
7634        assert_eq!(d.selection(), Some((0, d.source.len())));
7635        d.toggle_list(true);
7636        assert_eq!(d.source, "abc\n\ndef\n");
7637    }
7638
7639    #[test]
7640    fn toggle_blockquote_nests_a_partly_covered_quote() {
7641        // twig's rule: covering only some of a container's blocks nests, because
7642        // taking the quote off would drag its uncovered siblings out with it.
7643        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
7644        d.caret = 2; // in the first quoted paragraph only
7645        d.toggle_blockquote();
7646        assert_eq!(d.source, "> > a\n>\n> b\n");
7647    }
7648
7649    #[test]
7650    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
7651        // A blank line used to be no block for twig to wrap —
7652        // `toggle_block_container` answered `NotFound` — so Quote and the list
7653        // buttons did nothing on the very line the H1 button works on, and leaf
7654        // lent twig a scratch paragraph to wrap and took it back out again.
7655        // twig 3.2.0 opens an empty container there itself, so what is left here
7656        // is where the caret lands: inside the marker that was just written.
7657        let mut d = doc_with("quote_blank", "\nabc\n");
7658        d.caret = 0;
7659        d.toggle_blockquote();
7660        assert_eq!(d.source, "> \nabc\n");
7661        assert_eq!(
7662            d.caret, 2,
7663            "the caret belongs inside the quote it just opened"
7664        );
7665        assert!(d.status.is_none(), "{:?}", d.status);
7666        assert!(d.dirty);
7667
7668        // And the paragraph below is still its own block: an empty container one
7669        // soft break from `abc` would take that paragraph into the quote with it.
7670        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
7671        d.caret = 0;
7672        d.toggle_blockquote();
7673        d.build_visual(80);
7674        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
7675
7676        // The same from the other side: a blank line directly under a paragraph
7677        // earns the blank line an empty block needs, rather than being read as a
7678        // soft break inside that paragraph.
7679        let mut d = doc_with("list_blank_below", "abc\n");
7680        d.caret = 4;
7681        d.toggle_list(false);
7682        assert_eq!(d.source, "abc\n\n- ");
7683        assert_eq!(d.caret, 7);
7684    }
7685
7686    #[test]
7687    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
7688        // The gesture the rendering fix is for. `newline` inside a quote already
7689        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
7690        // spelling — but the two marker lines it adds belonged to no node until
7691        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
7692        // just made drew as plain prose under the quote.
7693        let mut d = wysiwyg_doc("quote_enter", "> a\n");
7694        d.caret = 3; // past `a`, at the end of the quoted line
7695        d.newline();
7696        assert_eq!(d.source, "> a\n>\n> \n");
7697        d.build_visual(80);
7698        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
7699        // And the caret is on the new line, not stranded on the old one.
7700        assert_eq!(d.caret, 8);
7701    }
7702
7703    #[test]
7704    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
7705        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
7706        // now it is twig's single edit. Either way one ⌘z has to put the blank
7707        // line back rather than undoing into a half-built document.
7708        for open in [
7709            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
7710            &|d: &mut Doc| d.toggle_list(false),
7711            &|d: &mut Doc| d.toggle_list(true),
7712        ] {
7713            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
7714            d.caret = 3;
7715            open(&mut d);
7716            assert_ne!(d.source, "a\n\n\n\nb\n");
7717            d.undo();
7718            assert_eq!(d.source, "a\n\n\n\nb\n");
7719        }
7720    }
7721
7722    #[test]
7723    fn a_container_toggle_is_one_undo_step() {
7724        let mut d = doc_with("quote_undo", "hello\n");
7725        d.caret = 3;
7726        d.insert("X"); // a typing run the structural edit must not fold into
7727        d.toggle_blockquote();
7728        assert_eq!(d.source, "> helXlo\n");
7729        d.undo();
7730        assert_eq!(d.source, "helXlo\n");
7731    }
7732
7733    // ── links ────────────────────────────────────────────────────────────────
7734
7735    #[test]
7736    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
7737        let mut d = doc_with("link_sel", "word here\n");
7738        d.anchor = Some(0);
7739        d.caret = 4;
7740        d.insert_link("http://x.dev");
7741        assert_eq!(d.source, "[word](http://x.dev) here\n");
7742        // The text, not the destination — so a second press re-points the link
7743        // the first one made rather than nesting one inside it.
7744        assert_eq!(d.selected_text(), Some("word"));
7745        d.insert_link("http://y.dev");
7746        assert_eq!(d.source, "[word](http://y.dev) here\n");
7747        assert_eq!(d.selected_text(), Some("word"));
7748    }
7749
7750    #[test]
7751    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
7752        let mut d = doc_with("img_caret", "before after\n");
7753        d.caret = 7; // between "before " and "after"
7754        d.insert_image("cat.png", "a cat");
7755        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
7756        // The caret sits just past the inserted image, nothing selected.
7757        assert_eq!(d.selection(), None);
7758        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
7759    }
7760
7761    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
7762    /// destination at the first space, so the `format!` this used to be wrote
7763    /// something that was not an image at all — and the reader saw the markup as
7764    /// text. twig owns the spelling now, and moves it into the angle form.
7765    #[test]
7766    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
7767        let mut d = doc_with("img_space", "x\n");
7768        d.caret = 0;
7769        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
7770        assert_eq!(
7771            d.source,
7772            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
7773        );
7774        // And it reads back as an image pointing at the unescaped path — the angle
7775        // brackets are spelling, not part of the destination.
7776        d.caret = 2;
7777        assert_eq!(
7778            d.image_destination_at_caret(),
7779            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
7780        );
7781    }
7782
7783    /// A `)` in a caption or a filename must not close the image early.
7784    #[test]
7785    fn insert_image_escapes_a_paren_in_either_half() {
7786        let mut d = doc_with("img_paren", "x\n");
7787        d.caret = 0;
7788        d.insert_image("a)b.png", "");
7789        assert_eq!(d.source, "![](a\\)b.png)x\n");
7790        d.caret = 2;
7791        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
7792    }
7793
7794    #[test]
7795    fn insert_image_uses_the_selection_as_alt_text() {
7796        let mut d = doc_with("img_sel", "caption here\n");
7797        d.anchor = Some(0);
7798        d.caret = 7; // "caption"
7799        d.insert_image("p.png", "ignored fallback");
7800        assert_eq!(d.source, "![caption](p.png) here\n");
7801    }
7802
7803    #[test]
7804    fn insert_image_with_no_alt_leaves_empty_brackets() {
7805        let mut d = doc_with("img_noalt", "\n");
7806        d.caret = 0;
7807        d.insert_image("logo.svg", "");
7808        assert_eq!(d.source, "![](logo.svg)\n");
7809    }
7810
7811    #[test]
7812    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
7813        // The round trip is the point: it's no use writing markup the reader
7814        // can't pick up again. This is the pair that only holds from twig 2.5.1
7815        // on — before it, the one-line form went in fine and came back as a
7816        // paragraph of raw tags, publishing no media at all.
7817        let mut d = doc_with("vid_rt", "\n");
7818        d.caret = 0;
7819        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
7820        assert_eq!(
7821            d.source,
7822            "<video src=\"clip.mp4\" controls>a clip</video>\n"
7823        );
7824
7825        d.build_visual(80);
7826        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
7827        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
7828        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
7829        assert_eq!(d.vmap.media[0].alt, "a clip");
7830    }
7831
7832    #[test]
7833    fn insert_media_spells_audio_with_its_own_tag() {
7834        let mut d = doc_with("aud_rt", "\n");
7835        d.caret = 0;
7836        d.insert_media(MediaKind::Audio, "take.mp3", "");
7837        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
7838        d.build_visual(80);
7839        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
7840    }
7841
7842    #[test]
7843    fn insert_media_uses_the_selection_as_fallback_text() {
7844        // The same courtesy `insert_image` does with alt: select a caption,
7845        // insert, and the caption labels the thing rather than being replaced.
7846        let mut d = doc_with("vid_sel", "the talk here\n");
7847        d.anchor = Some(0);
7848        d.caret = 8; // "the talk"
7849        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
7850        assert_eq!(
7851            d.source,
7852            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
7853        );
7854    }
7855
7856    #[test]
7857    fn insert_media_with_an_image_kind_is_just_insert_image() {
7858        let mut d = doc_with("img_via_media", "\n");
7859        d.caret = 0;
7860        d.insert_media(MediaKind::Image, "logo.svg", "x");
7861        assert_eq!(d.source, "![x](logo.svg)\n");
7862    }
7863
7864    // ── thematic breaks ─────────────────────────────────────────────────────
7865
7866    /// The node the source parses as at `caret` — what confirms an inserted
7867    /// `---` actually reads back as a rule, not stray text or a setext heading.
7868    ///
7869    /// The *narrowest* node covering the offset. Every ancestor covers it too,
7870    /// and since twig 2.8 that includes the `doc` root, which now carries a real
7871    /// span (it reported none before, so taking the first match used to land on
7872    /// the block by luck and now always answers `"doc"`).
7873    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
7874        d.nodes()
7875            .into_iter()
7876            .filter(|n| n.span.start <= caret && caret < n.span.end)
7877            .min_by_key(|n| n.span.end - n.span.start)
7878            .map(|n| n.kind)
7879    }
7880
7881    #[test]
7882    fn a_task_box_toggles_at_the_caret_and_reads_back() {
7883        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
7884        d.caret = 8; // inside "todo"
7885        assert_eq!(d.task_checked_at_caret(), Some(false));
7886        d.toggle_task_checked();
7887        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
7888        assert_eq!(d.task_checked_at_caret(), Some(true));
7889        d.toggle_task_checked();
7890        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
7891    }
7892
7893    #[test]
7894    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
7895        // The whole reason `toggle_task_at` exists apart from the caret form:
7896        // ticking a box elsewhere must not move the cursor out of what's being
7897        // typed.
7898        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
7899        d.caret = 8; // inside "first"
7900        let second = d.source.find("second").unwrap();
7901        d.toggle_task_at(second);
7902        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
7903        assert_eq!(d.caret, 8, "the caret stayed in the first item");
7904    }
7905
7906    #[test]
7907    fn a_plain_item_gains_and_loses_a_box() {
7908        let mut d = doc_with("task_mint", "- plain\n");
7909        d.caret = 4;
7910        assert_eq!(d.task_checked_at_caret(), None);
7911        d.toggle_task_item();
7912        assert_eq!(d.source, "- [ ] plain\n");
7913        assert_eq!(
7914            d.task_checked_at_caret(),
7915            Some(false),
7916            "a new box arrives unticked"
7917        );
7918        d.toggle_task_item();
7919        assert_eq!(d.source, "- plain\n");
7920    }
7921
7922    #[test]
7923    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
7924        // `set checked` must not silently convert a bullet into a task — that is
7925        // `toggle_task_item`'s job, and twig refuses it here.
7926        let mut d = doc_with("task_none", "- plain\n");
7927        d.caret = 4;
7928        d.toggle_task_checked();
7929        assert_eq!(d.source, "- plain\n", "nothing written");
7930        assert!(
7931            d.status.is_some(),
7932            "the refusal should reach the status line"
7933        );
7934    }
7935
7936    #[test]
7937    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
7938        let mut d = doc_with("task_quote", "> - [ ] nested\n");
7939        d.caret = d.source.find("nested").unwrap();
7940        assert_eq!(d.task_checked_at_caret(), Some(false));
7941        d.toggle_task_checked();
7942        assert_eq!(d.source, "> - [x] nested\n");
7943    }
7944
7945    #[test]
7946    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
7947        // A rule is a block, so twig's `insert_thematic_break` alone lands it
7948        // after the whole paragraph. `split_block` parts the paragraph first and
7949        // the rule is aimed at the *first* half, which is what a rule button is
7950        // understood to do — and what leaf spelled by hand until twig grew both
7951        // halves of the gesture.
7952        let mut d = doc_with("hr_mid", "before after\n");
7953        d.caret = 7; // between "before " and "after"
7954        d.insert_thematic_break();
7955        assert_eq!(d.source, "before \n\n---\n\nafter\n");
7956        assert_eq!(d.selection(), None);
7957        assert_eq!(
7958            kind_at(&mut d, "before \n\n".len()),
7959            Some(Kind::ThematicBreak)
7960        );
7961    }
7962
7963    #[test]
7964    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
7965        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
7966        // and leaf wrote the first into both until twig started spelling it.
7967        let mut md = doc_with("hr_md", "para\n");
7968        md.caret = 2;
7969        md.insert_thematic_break();
7970        assert_eq!(md.source, "pa\n\n---\n\nra\n");
7971
7972        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
7973        dj.caret = 2;
7974        dj.insert_thematic_break();
7975        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
7976    }
7977
7978    #[test]
7979    fn clicking_below_a_final_thematic_break_can_type_after_it() {
7980        let mut d = wysiwyg_doc("hr_final_click", "---\n");
7981        d.build_visual(80);
7982        d.click(d.vmap.num_rows() + 2, 0, false);
7983        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
7984        d.insert("after");
7985        assert_eq!(d.source, "---\nafter");
7986    }
7987
7988    #[test]
7989    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
7990        // The same bytes are two documents. In Markdown `  - b` is a nested item
7991        // and the next one belongs beside it, at its indent. In Djot a list
7992        // marker can't interrupt a paragraph, so those bytes are literal text in
7993        // item `a` and there is only one item — writing `  - ` under it would add
7994        // no item at all, just more text, and the new sibling has to go to
7995        // column zero. Both spellings come out of the *enclosing item's* line.
7996        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
7997        md.caret = "- a\n  - b".len();
7998        md.newline();
7999        assert_eq!(md.source, "- a\n  - b\n  - \n");
8000        assert_eq!(list_items(&mut md), 3);
8001
8002        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
8003        dj.view = View::Wysiwyg;
8004        dj.build_visual(80);
8005        dj.caret = "- a\n  - b".len();
8006        dj.newline();
8007        assert_eq!(dj.source, "- a\n  - b\n- \n");
8008        assert_eq!(list_items(&mut dj), 2);
8009
8010        // Where Djot's nesting is real — opened by a blank line — the indent is
8011        // reproduced there too, and the two formats agree again.
8012        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
8013        dj.view = View::Wysiwyg;
8014        dj.build_visual(80);
8015        dj.caret = "- a\n\n  - b".len();
8016        dj.newline();
8017        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
8018        assert_eq!(list_items(&mut dj), 3);
8019    }
8020
8021    #[test]
8022    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
8023        // Tab replaces the line's whole prefix with the one twig spells, so the
8024        // quote markers, the parent's indent and an ordered marker's extra
8025        // column are all its answer rather than leaf's arithmetic.
8026        for (name, body, caret, want) in [
8027            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
8028            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
8029            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
8030            // A checkbox is markup the item's own text wraps past, but a nested
8031            // list may only open at the *list* marker's column — four in from
8032            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
8033            // parses as one item, not two.
8034            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
8035            (
8036                "quoted task",
8037                "> - [ ] a\n> - [ ] b\n",
8038                18,
8039                "> - [ ] a\n>   - [ ] b\n",
8040            ),
8041        ] {
8042            let mut doc = wysiwyg_doc(name, body);
8043            doc.caret = caret;
8044            doc.indent();
8045            assert_eq!(doc.source, want, "{name}");
8046            // The nesting is real, not just indented text.
8047            assert_eq!(list_items(&mut doc), 2, "{name}");
8048        }
8049    }
8050
8051    #[test]
8052    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
8053        // The same bytes, the two formats disagreeing, and a gesture that used
8054        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
8055        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
8056        // so those bytes are literal text inside item `a` — there is nothing to
8057        // outdent, and treating them as a marker turned one item into two, a
8058        // structural edit from a keystroke that should delete one character.
8059        //
8060        // twig's `line_prefix` is what tells them apart: it reports the marker
8061        // on the Markdown line and nothing on the Djot one, which is a
8062        // continuation. No byte scan can reach that answer.
8063        let src = "- a\n  - b\n";
8064        let at = "- a\n  - ".len();
8065
8066        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
8067        md.view = View::Wysiwyg;
8068        md.build_visual(80);
8069        md.caret = at;
8070        md.backspace();
8071        assert_eq!(md.source, "- a\n- b\n");
8072        assert_eq!(list_items(&mut md), 2);
8073
8074        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
8075        dj.view = View::Wysiwyg;
8076        dj.build_visual(80);
8077        dj.caret = at;
8078        dj.backspace();
8079        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
8080        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
8081    }
8082
8083    #[test]
8084    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
8085        // Leaf used to spell the next item from the marker bytes it scanned, and
8086        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
8087        // and dropped out of the checklist. twig reproduces the whole
8088        // continuation, and a fresh item is always unticked however the one above
8089        // it stands.
8090        for (name, body, want) in [
8091            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
8092            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
8093        ] {
8094            let mut doc = wysiwyg_doc(name, body);
8095            doc.caret = body.trim_end_matches('\n').len();
8096            doc.newline();
8097            assert_eq!(doc.source, want, "{name}");
8098            // Both items are checklist items — the new one is a box, not the
8099            // plain bullet the old marker scan left behind — and it is unticked
8100            // whichever way the one above it faces.
8101            let boxes: Vec<Option<bool>> = doc
8102                .nodes()
8103                .iter()
8104                .filter(|n| n.kind == Kind::TaskListItem)
8105                .map(|n| n.checked)
8106                .collect();
8107            assert_eq!(boxes.len(), 2, "{name}");
8108            assert_eq!(boxes[1], Some(false), "{name}");
8109        }
8110    }
8111
8112    #[test]
8113    fn a_split_takes_the_space_the_caret_was_in_front_of() {
8114        // Splicing a break at the caret strands the space the words were parted
8115        // at on the head of the second block, where it reads as an indent nobody
8116        // typed. twig's split consumes it.
8117        for (name, body, caret, want) in [
8118            ("para", "one two\n", 3, "one\n\ntwo\n"),
8119            ("item", "- one two\n", 5, "- one\n- two\n"),
8120            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
8121            // A heading takes leaf's own path, which has to match.
8122            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
8123        ] {
8124            let mut doc = wysiwyg_doc(name, body);
8125            doc.caret = caret;
8126            doc.newline();
8127            assert_eq!(doc.source, want, "{name}");
8128        }
8129    }
8130
8131    #[test]
8132    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
8133        // The one place leaf keeps its own break: `split_block` repeats the `#`,
8134        // and Enter after a title is how the body under it is asked for.
8135        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
8136        doc.caret = "# Title".len();
8137        doc.newline();
8138        doc.insert("body");
8139        assert_eq!(doc.source, "# Title\n\nbody\n");
8140        assert_eq!(
8141            doc.nodes()
8142                .iter()
8143                .filter(|n| n.kind == Kind::Heading)
8144                .count(),
8145            1
8146        );
8147    }
8148
8149    #[test]
8150    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
8151        // A quoted item's marker doesn't open its line, so a scan that starts at
8152        // column zero finds a `>` where it wanted a bullet, calls the line "not a
8153        // list" and hands Enter to the plain-quote branch — which writes `> ` and
8154        // drops the list. The next item has to carry the whole prefix.
8155        for (name, body, want) in [
8156            ("flat", "> - a\n", "> - a\n> - \n"),
8157            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8158            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
8159            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
8160            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
8161        ] {
8162            let mut doc = wysiwyg_doc(name, body);
8163            doc.caret = body.trim_end_matches('\n').len();
8164            doc.newline();
8165            assert_eq!(doc.source, want, "{name}");
8166            // The marker isn't just spelled right, it parses as an item.
8167            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
8168        }
8169    }
8170
8171    #[test]
8172    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
8173        // Double-Enter exits the list. Unquoted that means a blank line, but a
8174        // *bare* blank line would end the quote too and drop the caret out of it,
8175        // so the separator keeps its `>` and the caret's line keeps its `> `.
8176        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
8177        doc.caret = "> - a\n> - ".len();
8178        doc.newline();
8179        assert_eq!(doc.source, "> - a\n>\n> \n");
8180        assert_eq!(list_items(&mut doc), 1);
8181        // What "still in the quote" means for the next keystroke: the caret sits
8182        // behind the prefix, and what's typed there lands inside the quote as a
8183        // paragraph of its own — not as more of item `a`.
8184        doc.insert("x");
8185        assert_eq!(doc.source, "> - a\n>\n> x\n");
8186        assert!(
8187            doc.editor
8188                .ancestors_at(doc.caret - 1)
8189                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
8190        );
8191    }
8192
8193    #[test]
8194    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
8195        // The marker is hidden block markup, so Backspace over it is structural —
8196        // but only the marker is the list's. Splicing from the line start would
8197        // take the `>` with it and silently unquote the line.
8198        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
8199        doc.caret = "> - ".len();
8200        doc.backspace();
8201        assert_eq!(doc.source, "> a\n");
8202        assert_eq!(list_items(&mut doc), 0);
8203
8204        // A nested one outdents instead, moving the bullet within the quote
8205        // rather than moving the quote.
8206        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
8207        doc.caret = "> - a\n>   - ".len();
8208        doc.backspace();
8209        assert_eq!(doc.source, "> - a\n> - b\n");
8210        assert_eq!(list_items(&mut doc), 2);
8211    }
8212
8213    #[test]
8214    fn only_a_bare_paragraph_is_parted_around_the_caret() {
8215        // The split is deliberately narrow. Parting a fenced block would leave
8216        // two fences with a rule between them, and parting a list item would
8217        // mint an item nobody asked for on the way to a rule that lands after
8218        // the list either way — so both keep the whole block intact and take the
8219        // rule after it. A caret in a quote is likewise left alone.
8220        for (name, body, caret, want) in [
8221            (
8222                "code",
8223                "```\nfn x() {}\n```\n",
8224                8,
8225                "```\nfn x() {}\n```\n\n---\n",
8226            ),
8227            ("list", "- one two\n", 6, "- one two\n\n---\n"),
8228            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
8229        ] {
8230            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
8231            d.caret = caret;
8232            d.insert_thematic_break();
8233            assert_eq!(d.source, want, "{name}: the block should stay whole");
8234        }
8235    }
8236
8237    #[test]
8238    fn insert_thematic_break_replaces_the_selection() {
8239        // Now that the rule lands *at* the caret again, replacing the selection
8240        // is coherent once more: the text goes, and the rule takes its place.
8241        // The space the deletion left leading the second half is consumed by the
8242        // split rather than opening the new paragraph with it.
8243        let mut d = doc_with("hr_sel", "one two three\n");
8244        d.anchor = Some(4);
8245        d.caret = 7; // "two"
8246        d.insert_thematic_break();
8247        assert_eq!(d.source, "one \n\n---\n\nthree\n");
8248        assert_eq!(d.selection(), None);
8249    }
8250
8251    #[test]
8252    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
8253        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
8254        // because writing `---` into one is code, not a rule — twig now walks out
8255        // to the block that owns the caret's line, so there is nothing to refuse.
8256        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
8257        code.caret = 5; // inside the fenced code
8258        code.insert_thematic_break();
8259        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
8260        assert_eq!(code.status, None, "no refusal to report any more");
8261
8262        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
8263        table.caret = 3; // in the header row
8264        table.insert_thematic_break();
8265        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
8266    }
8267
8268    #[test]
8269    fn insert_thematic_break_in_a_list_item_ends_the_list() {
8270        // The un-indented rule cannot continue the list, so it closes the list
8271        // and lands at the top level rather than nested inside it.
8272        let mut d = doc_with("hr_list", "- one\n- two\n");
8273        d.caret = "- one\n- tw".len(); // mid "two"
8274        d.insert_thematic_break();
8275        d.build_visual(80);
8276        let rule_at = d.source.find("---").unwrap();
8277        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8278        assert!(
8279            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
8280                && n.span.start <= rule_at
8281                && rule_at < n.span.end),
8282            "the rule must not be nested inside the list"
8283        );
8284    }
8285
8286    #[test]
8287    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
8288        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
8289        // which is the document the gesture was actually asked for.
8290        let mut d = doc_with("hr_quote", "> hello\n");
8291        d.caret = 4; // inside the quoted text
8292        d.insert_thematic_break();
8293        assert_eq!(d.source, "> hello\n>\n> ---\n");
8294        d.build_visual(80);
8295        let rule_at = d.source.find("---").unwrap();
8296        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8297        assert!(
8298            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
8299                && n.span.start <= rule_at
8300                && rule_at < n.span.end),
8301            "the rule belongs to the quote it was asked for"
8302        );
8303    }
8304
8305    // ── typing against a block picture ────────────────────────────────────────
8306
8307    /// A rendered-view document with the caret parked on one of the picture's two
8308    /// stops, and the map already built — the state a frontend is in between
8309    /// drawing a frame and the next keystroke.
8310    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
8311        let mut d = doc_in(View::Wysiwyg, name, src);
8312        d.build_visual_unwrapped();
8313        let start = src.find("![").unwrap();
8314        d.caret = match side {
8315            MediaStop::Before => start,
8316            MediaStop::After => start + "![](p.png)".len(),
8317        };
8318        d
8319    }
8320
8321    /// The block media the map publishes, after rebuilding it — "is this still a
8322    /// picture, or has it become a line of text with an image in it?"
8323    fn media_count(d: &mut Doc) -> usize {
8324        d.build_visual_unwrapped();
8325        d.vmap.media.len()
8326    }
8327
8328    #[test]
8329    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
8330        // The accident this prevents: tap the blank page under a photo (which
8331        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
8332        // paragraph with an *inline* image — the photo stops being drawn.
8333        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
8334        d.insert("xy");
8335        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
8336        assert_eq!(media_count(&mut d), 1, "still a picture");
8337    }
8338
8339    #[test]
8340    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
8341        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8342        d.insert("xy");
8343        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
8344        assert_eq!(media_count(&mut d), 1);
8345    }
8346
8347    #[test]
8348    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
8349        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
8350        d.insert("x");
8351        assert_eq!(d.source, "x\n\n![](p.png)\n");
8352        assert_eq!(media_count(&mut d), 1);
8353    }
8354
8355    #[test]
8356    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
8357        // The opened paragraph is part of the keystroke, not an edit the writer
8358        // made — so it undoes with the character, not a step later.
8359        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
8360        d.insert("x");
8361        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
8362        d.undo();
8363        assert_eq!(d.source, "hi\n\n![](p.png)\n");
8364    }
8365
8366    #[test]
8367    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
8368        // ⌘V dissolves the picture exactly as a keystroke does.
8369        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
8370        d.paste("pasted");
8371        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
8372        assert_eq!(media_count(&mut d), 1);
8373    }
8374
8375    #[test]
8376    fn typing_beside_an_inline_image_is_ordinary_editing() {
8377        // An inline image has no placeholder row and no stops of its own. Opening
8378        // a paragraph mid-sentence would be the bug, not the fix.
8379        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
8380        d.build_visual_unwrapped();
8381        d.caret = "see ![](p.png)".len();
8382        d.insert("!");
8383        assert_eq!(d.source, "see ![](p.png)! here\n");
8384    }
8385
8386    #[test]
8387    fn source_view_types_raw_markup_against_an_image_untouched() {
8388        // Source view is for writing the markup itself; a break inserted behind
8389        // the writer's back there would be the editor arguing with them.
8390        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
8391        d.caret = "![](p.png)".len();
8392        d.insert("x");
8393        assert_eq!(d.source, "![](p.png)x\n");
8394    }
8395
8396    #[test]
8397    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
8398        // A selection is replaced, not joined into, so there is nothing to
8399        // protect: the range takes the picture with it.
8400        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
8401        d.anchor = Some(d.caret);
8402        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
8403        d.insert("x");
8404        assert_eq!(d.source, "hi\n\nx\n");
8405    }
8406
8407    #[test]
8408    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
8409        // What this actually cost: a real vault's photo, to one stray Backspace.
8410        // The caret past `![](p.png)` was deleting the closing paren — invisible
8411        // in the rendered view — and the photo became the text `![](p.png`.
8412        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
8413        d.backspace();
8414        assert_eq!(d.source, "hi\n");
8415        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
8416        d.undo();
8417        assert_eq!(
8418            d.source, "hi\n\n![](p.png)\n",
8419            "and comes back in one piece"
8420        );
8421    }
8422
8423    #[test]
8424    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
8425        // Deleting the break here would join the picture to the paragraph above,
8426        // where it is an *inline* image and stops being drawn. Step over the
8427        // boundary; the next press deletes in the paragraph the caret reached.
8428        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8429        d.backspace();
8430        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
8431        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
8432        d.backspace();
8433        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
8434        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
8435    }
8436
8437    #[test]
8438    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
8439        // The mirror. A byte-step here eats the `!` and leaves a link.
8440        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
8441        d.delete_forward();
8442        assert_eq!(d.source, "hi\n\nbye\n");
8443        assert_eq!(media_count(&mut d), 0);
8444    }
8445
8446    #[test]
8447    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
8448        let mut d = doc_at_picture(
8449            "pic_del_after",
8450            "hi\n\n![](p.png)\n\nbye\n",
8451            MediaStop::After,
8452        );
8453        d.delete_forward();
8454        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
8455        assert_eq!(
8456            d.caret,
8457            d.source.find("bye").unwrap(),
8458            "the caret stepped down to `bye`"
8459        );
8460    }
8461
8462    #[test]
8463    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
8464        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
8465        d.backspace();
8466        assert_eq!(d.source, "\n");
8467        assert_eq!(media_count(&mut d), 0);
8468    }
8469
8470    #[test]
8471    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
8472        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
8473        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
8474        d.delete_word_back();
8475        assert_eq!(d.source, "hi there\n");
8476
8477        // And in front of one it runs *through* the paragraph break into the
8478        // prose above, which merges the picture inline — so it steps out first,
8479        // and the second press deletes the word it was aimed at.
8480        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
8481        d.delete_word_back();
8482        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
8483        d.delete_word_back();
8484        assert_eq!(
8485            d.source, "hi \n\n![](p.png)\n",
8486            "the word above went, the picture stayed"
8487        );
8488        assert_eq!(media_count(&mut d), 1);
8489    }
8490
8491    #[test]
8492    fn source_view_deletes_raw_markup_against_an_image_untouched() {
8493        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
8494        d.caret = "![](p.png)".len();
8495        d.backspace();
8496        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
8497    }
8498
8499    #[test]
8500    fn image_destination_at_caret_reads_the_image_under_the_caret() {
8501        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
8502        d.caret = 3; // inside the image markup
8503        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
8504        // Past the image, the caret is in no image.
8505        d.caret = "![a cat](cat.png)".len();
8506        assert_eq!(d.image_destination_at_caret(), None);
8507    }
8508
8509    #[test]
8510    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
8511        // The image is one placeholder row by default, and `set_media_rows` grows
8512        // it to the height the frontend measured: the label row plus blank
8513        // `decoration` fillers that hold the vertical space a raster is drawn into.
8514        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
8515        assert_eq!(d.vmap.media.len(), 1);
8516        let img_row = d.vmap.media[0].rows_span.start;
8517        assert_eq!(
8518            d.vmap.media[0].rows_span,
8519            img_row..img_row + 1,
8520            "default is one row"
8521        );
8522
8523        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
8524        d.build_visual(80);
8525        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
8526        let span = d.vmap.media[0].rows_span.clone();
8527        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
8528        // The label row carries the mark and its glyphs; the three below are blank
8529        // decoration — drawn, but no caret and no text.
8530        assert!(
8531            d.vmap.rows[span.start].media.is_some(),
8532            "mark rides the first row"
8533        );
8534        for r in (span.start + 1)..span.end {
8535            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
8536            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
8537            assert!(
8538                d.vmap.rows[r].media.is_none(),
8539                "only the first row is marked"
8540            );
8541        }
8542    }
8543
8544    #[test]
8545    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
8546        // The extra rows are pure spacers: the caret's only homes stay the stop in
8547        // front of the image and the one just past it, so walking the document top
8548        // to bottom visits the same offsets whether the image is 1 row or 5.
8549        let body = "ab\n\n![x](p.png)\n\ncd\n";
8550        let stops_at = |rows: usize| -> Vec<usize> {
8551            let mut d = wysiwyg_doc("img_stops", body);
8552            if rows > 1 {
8553                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
8554                d.build_visual(80);
8555            }
8556            d.caret = 0;
8557            let mut seen = vec![d.caret];
8558            loop {
8559                d.move_right(false);
8560                if *seen.last().unwrap() == d.caret {
8561                    break;
8562                }
8563                seen.push(d.caret);
8564            }
8565            seen
8566        };
8567        assert_eq!(
8568            stops_at(1),
8569            stops_at(5),
8570            "reserving rows must not add stops"
8571        );
8572    }
8573
8574    #[test]
8575    fn insert_link_repoints_the_link_at_a_bare_caret() {
8576        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
8577        d.caret = 3; // in the link's text, nothing selected
8578        d.insert_link("http://y.dev");
8579        assert_eq!(d.source, "[word](http://y.dev)\n");
8580        assert_eq!(d.selected_text(), Some("word"));
8581    }
8582
8583    #[test]
8584    fn insert_link_on_an_empty_range_autolinks_a_url() {
8585        // A link with no text of its own is an autolink, and twig spells it —
8586        // `<…>` is the canonical form and needs no text typed into it, so the
8587        // caret lands after it rather than selecting a finished link.
8588        let mut d = doc_with("link_empty", "\n");
8589        d.caret = 0;
8590        d.insert_link("http://x.dev");
8591        assert_eq!(d.source, "<http://x.dev>\n");
8592        assert_eq!(d.selection(), None);
8593        assert_eq!(d.caret, 14);
8594    }
8595
8596    #[test]
8597    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
8598        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
8599        // in Markdown, so a destination that can't autolink doubles as the text
8600        // instead — which is then selected, ready to be typed over.
8601        let mut d = doc_with("link_rel", "\n");
8602        d.caret = 0;
8603        d.insert_link("./notes.md");
8604        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
8605        assert_eq!(d.selection(), Some((1, 11)));
8606        d.insert("Notes");
8607        assert_eq!(d.source, "[Notes](./notes.md)\n");
8608    }
8609
8610    #[test]
8611    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
8612        // The autolink's text is its URL, so re-pointing replaces the whole
8613        // node — the caret must not splice a second link inside the first.
8614        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
8615        d.caret = 10;
8616        d.insert_link("https://y.dev");
8617        assert_eq!(d.source, "see <https://y.dev> ok\n");
8618    }
8619
8620    #[test]
8621    fn code_language_reads_and_edits_through_the_fence() {
8622        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
8623        d.caret = 10; // inside the code body
8624        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8625        assert!(d.caret_in_fenced_code());
8626
8627        d.set_code_language("python");
8628        assert!(
8629            d.source.starts_with("```python\n"),
8630            "source: {:?}",
8631            d.source
8632        );
8633        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
8634
8635        // Clearing it leaves a bare fence and no label.
8636        d.set_code_language("");
8637        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
8638        assert_eq!(d.code_language_at_caret(), None);
8639
8640        // A caret outside any code block edits nothing.
8641        let mut p = doc_with("code_lang_none", "just prose\n");
8642        assert!(!p.caret_in_fenced_code());
8643        p.set_code_language("rust");
8644        assert_eq!(p.source, "just prose\n");
8645    }
8646
8647    #[test]
8648    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
8649        // Markdown's info string ends at whitespace, so `two words` would write
8650        // a fence that reads back with a different language than the one asked
8651        // for. twig refuses it; leaf reports that and leaves the source alone.
8652        // The old splice trimmed the ends and wrote whatever was left.
8653        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
8654        d.caret = 10;
8655        d.set_code_language("two words");
8656        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
8657        assert!(d.status.is_some(), "the refusal should be reported");
8658        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8659    }
8660
8661    #[test]
8662    fn link_destination_at_caret_reads_both_spellings() {
8663        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
8664        d.caret = 5;
8665        assert_eq!(
8666            d.link_destination_at_caret().as_deref(),
8667            Some("https://x.dev")
8668        );
8669        d.caret = 0;
8670        assert_eq!(d.link_destination_at_caret(), None);
8671
8672        // An autolink has no `destination`; its text is the URL.
8673        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
8674        a.caret = 10;
8675        assert_eq!(
8676            a.link_destination_at_caret().as_deref(),
8677            Some("https://x.dev")
8678        );
8679        a.caret = 21;
8680        assert_eq!(a.link_destination_at_caret(), None);
8681    }
8682
8683    #[test]
8684    fn locate_finds_the_block_a_declared_id_names() {
8685        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
8686        // verse. The locator has to land on the *verse*, which is the whole
8687        // reason a link carries one.
8688        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
8689                   {#v2}\nYea, I make a record in the language of my father.\n";
8690        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8691        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
8692        assert_eq!(
8693            d.source[v2.start..v2.end].trim_end(),
8694            "Yea, I make a record in the language of my father."
8695        );
8696        // The attribute line is not part of it: `start` is a place to put a
8697        // caret, and `{#v2}` is markup the caret has no business landing in.
8698        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
8699        assert_eq!(d.locate("v99"), None);
8700    }
8701
8702    #[test]
8703    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
8704        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
8705        // Markdown heading is literal text. So `#the-second-part` can only be
8706        // the heading's own words, which is the rule every Markdown renderer
8707        // already follows and therefore the one a link was authored against.
8708        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
8709        let mut d = doc_with("locate_md", src);
8710        let hit = d.locate("the-second-part").expect("the heading's slug");
8711        assert!(d.source[hit.start..].starts_with("## The Second Part"));
8712        // Bounded by the next heading that isn't under it, so a peek shows the
8713        // section rather than only its title.
8714        assert_eq!(
8715            &d.source[hit.start..hit.end],
8716            "## The Second Part\n\nbody\n\n"
8717        );
8718
8719        // A subsection does not end its parent: `# Title` runs to `## Third`'s
8720        // sibling only because there is no other `#`, so it covers the lot.
8721        let title = d.locate("title").expect("the top heading");
8722        assert_eq!(title.end, d.source.len());
8723    }
8724
8725    #[test]
8726    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
8727        // djot mints `Some-Heading-Here`; a link to it is written
8728        // `#some-heading-here` by nearly everything that writes links. Both
8729        // spellings are one question.
8730        let src = "## Some Heading Here\n\nbody\n";
8731        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8732        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
8733        let slugged = d.locate("some-heading-here").expect("the link's spelling");
8734        assert_eq!(exact, slugged);
8735        // The section, not the heading line — there is more to show than a title.
8736        assert_eq!(&d.source[exact.start..exact.end], src);
8737    }
8738
8739    #[test]
8740    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
8741        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
8742        assert_eq!(d.locate(""), None);
8743        assert_eq!(d.locate("   "), None);
8744        // All punctuation: it names nothing, and must not be read as "match the
8745        // first heading whose slug is also empty".
8746        assert_eq!(d.locate("!!!"), None);
8747    }
8748
8749    #[test]
8750    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
8751        // The document's mistake, and the answer every other anchor
8752        // implementation gives — the alternative is for a link to mean whichever
8753        // of the two a walk happened to reach first.
8754        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
8755        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8756        let hit = d.locate("dup").expect("the first `{#dup}`");
8757        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
8758    }
8759
8760    #[test]
8761    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
8762        // The button's whole job: a reference where the caret was, a definition
8763        // to give it meaning, and the caret waiting in the empty note so the
8764        // next keystroke is the note's first word.
8765        let mut d = doc_with("fn_insert", "A claim and more.\n");
8766        d.caret = 7; // just past "A claim"
8767        d.insert_footnote();
8768        assert!(
8769            d.source.starts_with("A claim[^1] and more."),
8770            "{:?}",
8771            d.source
8772        );
8773        assert!(
8774            d.source.contains("[^1]:"),
8775            "the definition too: {:?}",
8776            d.source
8777        );
8778        assert_eq!(d.status, None);
8779
8780        let reference = d.source.find("[^1]").unwrap();
8781        let note = d
8782            .footnote_at(reference + 2)
8783            .expect("the reference just written");
8784        assert_eq!(note.label, "1");
8785        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
8786        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
8787        // …and typing there is typing into the note, not near it.
8788        d.insert("the note");
8789        assert_eq!(
8790            d.footnote_at(reference + 2).and_then(|f| f.text),
8791            Some("the note".to_string())
8792        );
8793    }
8794
8795    #[test]
8796    fn insert_footnote_numbers_past_the_notes_already_written() {
8797        // A second press must not hand back a label somebody else is using: twig
8798        // reuses a defined label rather than appending a rival definition, so a
8799        // repeat of `1` would quietly point the new reference at the old note.
8800        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
8801        d.caret = 7; // past `[^1]`, before " two."
8802        d.insert_footnote();
8803        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
8804        assert_eq!(d.source.matches("[^2]:").count(), 1);
8805    }
8806
8807    #[test]
8808    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
8809        // `[^2]` with no definition is still a 2 that means something to whoever
8810        // wrote it — stepping over it would mint a note for their reference. A
8811        // word label takes no number, so it blocks none.
8812        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
8813        d.caret = d.source.find(" c").unwrap();
8814        d.insert_footnote();
8815        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
8816        assert!(
8817            d.source.starts_with("a[^2] b[^why][^1] c"),
8818            "{:?}",
8819            d.source
8820        );
8821    }
8822
8823    #[test]
8824    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
8825        // A reference annotates the words before it. Consuming the selection —
8826        // which is what an insert normally does — would delete the very claim
8827        // the author selected in order to footnote.
8828        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
8829        d.anchor = Some(2);
8830        d.caret = 7; // "claim" selected
8831        d.insert_footnote();
8832        assert!(
8833            d.source.starts_with("A claim[^1] and more."),
8834            "{:?}",
8835            d.source
8836        );
8837    }
8838
8839    #[test]
8840    fn a_note_just_written_still_knows_where_its_reference_is() {
8841        // The authoring loop in one test: press the button, type the note, ask to
8842        // go back. The caret ends at the note's last byte — which is the *end* of
8843        // the definition's span, the one offset the query used to exclude — so
8844        // this is where the round trip either works or doesn't.
8845        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
8846        d.caret = 7;
8847        d.insert_footnote();
8848        d.insert("the note");
8849        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
8850        let back = d
8851            .footnote_definition_at_caret()
8852            .expect("still in the note we just typed");
8853        assert_eq!(back.label, "1");
8854        // …and following it lands on the reference's label, where a reader's
8855        // return leg lands.
8856        assert_eq!(back.offset, Some(9));
8857        assert_eq!(&d.source[9..10], "1");
8858    }
8859
8860    #[test]
8861    fn insert_footnote_takes_one_undo_for_both_halves() {
8862        // twig writes the pair as a single edit; the point of that is here.
8863        let before = "A claim and more.\n";
8864        let mut d = doc_with("fn_insert_undo", before);
8865        d.caret = 7;
8866        d.insert_footnote();
8867        assert_ne!(d.source, before);
8868        d.undo();
8869        assert_eq!(d.source, before, "one undo takes back both halves");
8870    }
8871
8872    #[test]
8873    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
8874        // HTML is authorable — it spells the inline marks — and has no footnote.
8875        // The refusal says so rather than writing brackets that would render as
8876        // brackets.
8877        let src = "<p>A claim.</p>\n";
8878        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
8879        assert!(!Capabilities::of(Format::Html).footnote);
8880        d.caret = 5;
8881        d.insert_footnote();
8882        assert_eq!(d.source, src, "nothing written");
8883        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
8884    }
8885
8886    #[test]
8887    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
8888        // The empty body is the one place this could go wrong: the definition
8889        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
8890        // byte early would draw up in the paragraph above the note it belongs to.
8891        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
8892        d.place_caret(7, false);
8893        d.insert_footnote();
8894        d.build_visual(80); // the frame a frontend draws after the edit
8895        assert_eq!(
8896            d.vmap.snap_to_stop(d.caret),
8897            d.caret,
8898            "the caret sits on a stop"
8899        );
8900        let (row, _) = d.caret_pos();
8901        assert!(
8902            drawn_rows(&d)[row].contains("[1]"),
8903            "the caret is on the note's row, not above it: {:?}",
8904            drawn_rows(&d)
8905        );
8906    }
8907
8908    #[test]
8909    fn footnote_at_caret_resolves_a_reference_to_its_note() {
8910        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
8911        // blank line, as one has to.
8912        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
8913        d.caret = 9;
8914        let f = d
8915            .footnote_at_caret()
8916            .expect("the caret stands in a reference");
8917        assert_eq!(f.label, "1");
8918        assert_eq!(f.text.as_deref(), Some("the note"));
8919        // The offset points at the note's first word, not at the definition's
8920        // `[` — the marker is decoration with no caret stop on it.
8921        assert_eq!(f.offset, Some(29));
8922        assert_eq!(&d.source[29..37], "the note");
8923        // …and `end` closes the range, so a frontend can ask which rendered rows
8924        // the note occupies rather than re-deriving them from the text.
8925        assert_eq!(f.end, Some(37));
8926        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
8927    }
8928
8929    /// Two definitions in a row: each is its own note, and neither reaches into
8930    /// the other.
8931    ///
8932    /// A djot definition's span used to run past the blank line into the first
8933    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
8934    /// offsets named the *next* note's rows too, showing a reader two footnotes
8935    /// when they had asked about one. twig 3.1 ends the span after the block's
8936    /// own last line; the test outlives the workaround leaf carried for it.
8937    #[test]
8938    fn footnote_at_stops_a_note_at_the_definition_after_it() {
8939        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
8940        for format in [Format::Markdown, Format::Djot] {
8941            let mut d = Doc::from_source(src.to_string(), format).unwrap();
8942            d.caret = 7;
8943            let f = d.footnote_at_caret().expect("a reference");
8944            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
8945            assert_eq!(
8946                &src[f.offset.unwrap()..f.end.unwrap()],
8947                "first note.",
8948                "in {format:?}"
8949            );
8950        }
8951    }
8952
8953    /// The other side of that boundary: a blank line *inside* a definition is
8954    /// interior to it, and the note keeps its second paragraph.
8955    ///
8956    /// This is what the old body scan cost. It stopped at the first line not
8957    /// indented under the note — a blank line is not — so a two-paragraph note
8958    /// came back as its first paragraph, and "go to note" framed half of it.
8959    /// Reading the span twig gives is both simpler and right.
8960    #[test]
8961    fn footnote_at_keeps_a_notes_second_paragraph() {
8962        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
8963        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8964        d.caret = 7;
8965        let f = d.footnote_at_caret().expect("a reference");
8966        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
8967        // And it stops there — `After.` is the next block, not more note.
8968        assert_eq!(
8969            &src[f.offset.unwrap()..f.end.unwrap()],
8970            f.text.as_deref().unwrap()
8971        );
8972        assert!(!f.text.as_deref().unwrap().contains("After"));
8973    }
8974
8975    #[test]
8976    fn footnote_at_bounds_a_note_whose_body_is_empty() {
8977        // `[^1]:` with nothing after it. The range is empty rather than
8978        // inverted, and still points inside the definition — which is what keeps
8979        // a frontend's row lookup from walking off into the block above.
8980        let src = "A claim[^1].\n\n[^1]:\n";
8981        let mut d = doc_with("fn_empty_body", src);
8982        d.caret = 9;
8983        let f = d.footnote_at_caret().expect("a reference");
8984        assert_eq!(f.text.as_deref(), Some(""));
8985        assert_eq!(f.offset, f.end, "an empty note is an empty range");
8986        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
8987    }
8988
8989    #[test]
8990    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
8991        let mut d = doc_with(
8992            "fn_at_caret_none",
8993            "A claim[^1] and more.\n\n[^1]: the note\n",
8994        );
8995        d.caret = 2; // in the prose
8996        assert_eq!(d.footnote_at_caret(), None);
8997    }
8998
8999    #[test]
9000    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
9001        // The two are deliberately separate: a reference names a note in this
9002        // document, a link names somewhere to leave for, and answering one with
9003        // the other is what made a reference click do nothing at all.
9004        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
9005        d.caret = 3; // the `1` of `[^1]`
9006        assert!(d.footnote_at_caret().is_some());
9007        assert_eq!(
9008            d.link_destination_at_caret(),
9009            None,
9010            "a reference is not a link"
9011        );
9012
9013        d.caret = 10; // inside the link's label
9014        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
9015        assert_eq!(
9016            d.link_destination_at_caret().as_deref(),
9017            Some("https://x.dev")
9018        );
9019    }
9020
9021    #[test]
9022    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
9023        // A `[^99]` the document never defines is a real state — a note deleted
9024        // out from under its reference — and the label is what lets a frontend
9025        // say so. `None` here would be indistinguishable from "not on a
9026        // reference", which is the wrong thing to tell a reader.
9027        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
9028        d.caret = 9;
9029        let f = d
9030            .footnote_at_caret()
9031            .expect("the reference is still a reference");
9032        assert_eq!(f.label, "99");
9033        assert_eq!(f.text, None);
9034        assert_eq!(f.offset, None);
9035    }
9036
9037    #[test]
9038    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
9039        // Labels are not always numbers, and a note's body runs past its first
9040        // line — the indented continuation belongs to the note, so it comes back
9041        // with it (source bytes, verbatim, as documented).
9042        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
9043        let mut d = doc_with("fn_word_label", src);
9044        d.caret = 6;
9045        let f = d
9046            .footnote_at_caret()
9047            .expect("the caret stands in a reference");
9048        assert_eq!(f.label, "note");
9049        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
9050    }
9051
9052    #[test]
9053    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
9054        // The point of the offset form: a pointer hovering a reference asks what
9055        // note it names, and must not drag the caret along to ask.
9056        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
9057        d.caret = 0;
9058        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
9059        assert_eq!(f.label, "1");
9060        assert_eq!(f.text.as_deref(), Some("the note"));
9061        assert_eq!(d.caret, 0, "asking must not move the caret");
9062        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
9063    }
9064
9065    #[test]
9066    fn footnote_definition_at_caret_points_back_at_the_reference() {
9067        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
9068        // the caret can rest on — is at 9.
9069        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
9070        d.caret = 30; // inside the note's body
9071        let f = d
9072            .footnote_definition_at_caret()
9073            .expect("the caret stands in a definition");
9074        assert_eq!(f.label, "1");
9075        assert_eq!(f.offset, Some(9));
9076        assert_eq!(&d.source[7..11], "[^1]");
9077    }
9078
9079    #[test]
9080    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
9081        // The two legs have to meet: wherever `footnote_at` sends the caret, the
9082        // definition query must answer for — otherwise arriving at a note leaves
9083        // the reader somewhere the way back isn't offered.
9084        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
9085        let mut d = doc_with("fn_def_marker", src);
9086        let landed = d.footnote_at(9).unwrap().offset.unwrap();
9087        assert_eq!(
9088            d.footnote_definition_at(landed).and_then(|f| f.offset),
9089            Some(9),
9090            "the note a reference sends you to offers the way back"
9091        );
9092    }
9093
9094    #[test]
9095    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
9096        // The two queries answer for disjoint places, which is what lets one
9097        // gesture mean "down to the note" in one and "back up" in the other
9098        // without either having to remember which way the reader is going.
9099        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
9100        d.caret = 2; // prose
9101        assert_eq!(d.footnote_definition_at_caret(), None);
9102        d.caret = 9; // the reference
9103        assert_eq!(d.footnote_definition_at_caret(), None);
9104        assert!(
9105            d.footnote_at_caret().is_some(),
9106            "which is the reference's own query"
9107        );
9108    }
9109
9110    #[test]
9111    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
9112        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
9113        // note", which is false and leaves a frontend unable to explain why the
9114        // way back is missing.
9115        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
9116        let mut d = doc_with("fn_def_orphan", src);
9117        d.caret = src.find("orphan").unwrap();
9118        let f = d
9119            .footnote_definition_at_caret()
9120            .expect("an orphan is still a definition");
9121        assert_eq!(f.label, "2");
9122        assert_eq!(f.offset, None);
9123    }
9124
9125    #[test]
9126    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
9127        // One label, cited twice. The first is where the reader most likely came
9128        // from, and the only answer that doesn't depend on how they got here.
9129        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
9130        let mut d = doc_with("fn_def_repeat", src);
9131        d.caret = src.find("the note").unwrap();
9132        let f = d.footnote_definition_at_caret().expect("a definition");
9133        assert_eq!(
9134            f.offset,
9135            Some(5),
9136            "the first `[^a]`'s label, not the second's"
9137        );
9138        assert_eq!(&src[3..7], "[^a]");
9139    }
9140
9141    #[test]
9142    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
9143        // Down and back up, each leg found from the document rather than from a
9144        // memory of the other — so it still works for a reader who scrolled to
9145        // the notes instead of jumping there.
9146        //
9147        // `place_caret` rather than assigning `caret`, because that is what a
9148        // frontend calls: it snaps to a real caret stop, and a jump that lands
9149        // on a byte the caret can't rest on would arrive somewhere the return
9150        // leg no longer answers for. `build_map` first, since snapping is a
9151        // no-op until the map exists — which is exactly how this went unnoticed
9152        // when the offsets pointed at the `[^` markers.
9153        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
9154        d.build_map(None);
9155        d.place_caret(9, false);
9156        let down = d
9157            .footnote_at_caret()
9158            .expect("a reference")
9159            .offset
9160            .expect("a note");
9161        d.place_caret(down, false);
9162        let up = d
9163            .footnote_definition_at_caret()
9164            .expect("a definition")
9165            .offset
9166            .expect("a reference");
9167        d.place_caret(up, false);
9168        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
9169        assert_eq!(
9170            d.footnote_at_caret().expect("back on the reference").label,
9171            "1"
9172        );
9173    }
9174
9175    #[test]
9176    fn insert_link_hands_the_destination_to_twig_raw() {
9177        // Escaping is twig's, and format-specific: Markdown ends a destination
9178        // at the first space and needs the `<…>` form, where djot would read
9179        // those angle brackets as part of the URL.
9180        let mut d = doc_with("link_space", "word\n");
9181        d.anchor = Some(0);
9182        d.caret = 4;
9183        d.insert_link("a b");
9184        assert_eq!(d.source, "[word](<a b>)\n");
9185    }
9186
9187    #[test]
9188    fn insert_link_reports_a_destination_no_format_can_carry() {
9189        let mut d = doc_with("link_bad", "word\n");
9190        d.anchor = Some(0);
9191        d.caret = 4;
9192        d.insert_link("a\nb");
9193        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
9194        assert!(
9195            d.status.is_some(),
9196            "InvalidArgument should reach the status line"
9197        );
9198        assert!(!d.dirty);
9199    }
9200
9201    #[test]
9202    fn insert_link_works_in_wysiwyg_view() {
9203        let mut d = wysiwyg_doc("link_wys", "word here\n");
9204        d.anchor = Some(0);
9205        d.caret = 4;
9206        d.insert_link("http://x.dev");
9207        assert_eq!(d.source, "[word](http://x.dev) here\n");
9208        assert_eq!(d.selected_text(), Some("word"));
9209        // The map the caret has to keep riding is rebuilt each frame; motion
9210        // over the fresh one must still land on a real stop (the debug_assert).
9211        d.build_visual(80);
9212        d.move_right(false);
9213        d.move_left(false);
9214    }
9215
9216    #[test]
9217    fn click_maps_a_row_col_to_a_byte_offset() {
9218        let mut d = doc_with("click", "ab\ncd\n");
9219        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
9220        assert_eq!(d.caret, 4);
9221    }
9222
9223    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
9224    // stop just as the `(row, col)` click path does, so the caret can never come
9225    // to rest in the blank gap between two paragraphs — where it would draw in one
9226    // place and type in another.
9227    #[test]
9228    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
9229        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
9230        // caret stop (stops are 0,1,3,4).
9231        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
9232        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
9233        d.place_caret(2, false);
9234        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9235        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
9236    }
9237
9238    #[test]
9239    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
9240        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
9241        d.place_caret(0, false); // anchor at the start of "A"
9242        d.place_caret(2, true); // drag into the gap
9243        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9244        let (s, e) = d.selection().expect("a selection");
9245        assert!(
9246            d.vmap.is_stop(s) && d.vmap.is_stop(e),
9247            "selection {s}..{e} off a stop"
9248        );
9249    }
9250
9251    #[test]
9252    fn place_caret_on_a_real_stop_is_left_untouched() {
9253        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
9254        d.place_caret(3, false); // the start of "B" — a genuine stop
9255        assert_eq!(d.caret, 3);
9256    }
9257
9258    // An *empty paragraph* (two blank lines, an intentional blank line the user
9259    // opened) is a real caret stop, unlike the gap — a click into it must stay.
9260    #[test]
9261    fn place_caret_rests_in_an_empty_paragraph() {
9262        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
9263        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
9264        assert!(d.vmap.is_stop(empty));
9265        d.place_caret(empty, false);
9266        assert_eq!(d.caret, empty);
9267    }
9268
9269    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
9270        doc_in(View::Wysiwyg, name, body)
9271    }
9272
9273    /// How many list items the source actually parses into — the check that a
9274    /// marker Leaf wrote is a marker the format agrees is one.
9275    fn list_items(doc: &mut Doc) -> usize {
9276        doc.editor
9277            .nodes()
9278            .unwrap()
9279            .iter()
9280            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
9281            .count()
9282    }
9283
9284    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
9285    /// incremental (`build_spliced` / `build_cached`) path must always match.
9286    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
9287        reference_map_revealing(source, None)
9288    }
9289
9290    /// [`reference_map`] with a reveal line — the ground truth for the
9291    /// `MarkupMode::Full` builds, where the map is a function of the caret's
9292    /// line as well as the text.
9293    fn reference_map_revealing(
9294        source: &str,
9295        reveal: Option<Range<usize>>,
9296    ) -> crate::wysiwyg::VisualMap {
9297        // The same parse `Doc` uses. With twig's plain defaults instead, the two
9298        // sides disagree on what the *document* is before the renderer is even
9299        // reached — a bare `:word` is a text directive to one and prose to the
9300        // other — and the mismatch reads as a splice bug that isn't one.
9301        let mut ed =
9302            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
9303        let nodes = ed.nodes().unwrap();
9304        crate::wysiwyg::build(
9305            &nodes,
9306            source,
9307            None,
9308            false,
9309            &std::collections::HashMap::new(),
9310            reveal,
9311        )
9312    }
9313
9314    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
9315        if a.rows.len() != b.rows.len() {
9316            return true;
9317        }
9318        for (ra, rb) in a.rows.iter().zip(&b.rows) {
9319            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
9320                return true;
9321            }
9322            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
9323                if ga.ch != gb.ch || ga.src != gb.src {
9324                    return true;
9325                }
9326            }
9327        }
9328        false
9329    }
9330
9331    #[test]
9332    fn incremental_build_matches_a_fresh_build_across_edits() {
9333        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
9334        // fast path, gated on twig's `dirty_range`) or falls back to
9335        // `build_cached`. After each edit the map must be byte-identical to a
9336        // from-scratch build — this is the correctness net under the splice.
9337        let docs = [
9338            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
9339            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
9340            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
9341            // A footnote definition is a root beside `doc`, merged back into the
9342            // top-level list by `wysiwyg::top_blocks`. The random edits below
9343            // make and unmake definitions as they go (a deleted `:` turns one
9344            // back into a paragraph, and vice versa), which is exactly the
9345            // structural churn the splice path has to notice and bail out of.
9346            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
9347        ];
9348        // A deterministic mix: mostly single characters (which stay inside one
9349        // block → splice), plus edits that reshape structure (a paragraph break,
9350        // a heading marker, a code fence → fallback), so both paths are exercised.
9351        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
9352        for src in docs {
9353            let mut d = wysiwyg_doc("diff", src);
9354            d.build_visual_unwrapped();
9355            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
9356
9357            for step in 0..60usize {
9358                let len = d.source.len();
9359                let raw = (step * 13 + 5) % (len + 1);
9360                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9361                let pre = d.source.clone();
9362                let action;
9363                if step % 3 == 0 && pos < len {
9364                    let end = (pos + 1..=len)
9365                        .find(|&i| d.source.is_char_boundary(i))
9366                        .unwrap();
9367                    action = format!("delete [{pos},{end})");
9368                    d.edit(pos, end, "");
9369                } else {
9370                    let ins = inserts[step % inserts.len()];
9371                    action = format!("insert {ins:?} @ {pos}");
9372                    d.edit(pos, pos, ins);
9373                }
9374                d.build_visual_unwrapped();
9375                if maps_differ(&d.vmap, &reference_map(&d.source)) {
9376                    panic!(
9377                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
9378                        d.source
9379                    );
9380                }
9381            }
9382        }
9383    }
9384
9385    #[test]
9386    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
9387        // The same correctness net as `incremental_build_matches_a_fresh_build_
9388        // across_edits`, under `MarkupMode::Full` — where the map depends on
9389        // the caret's *line* as well as the text, so the two caches have a new
9390        // way to be wrong. Both are exercised: the block cache can hand back
9391        // rows built for a line that is no longer the revealed one, and the
9392        // splice path can reuse a suffix that still has yesterday's line raw.
9393        //
9394        // Caret motion is interleaved with the edits deliberately, because a
9395        // caret that only ever moved with the edit would never cross a line
9396        // without also dirtying it — the case where a stale reveal survives.
9397        let docs = [
9398            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
9399            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
9400        ];
9401        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
9402        for src in docs {
9403            let mut d = wysiwyg_doc("reveal_diff", src);
9404            d.set_markup_mode(MarkupMode::Full);
9405
9406            for step in 0..60usize {
9407                let len = d.source.len();
9408                let raw = (step * 13 + 5) % (len + 1);
9409                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9410                let pre = d.source.clone();
9411                let action;
9412                if step % 3 == 0 && pos < len {
9413                    let end = (pos + 1..=len)
9414                        .find(|&i| d.source.is_char_boundary(i))
9415                        .unwrap();
9416                    action = format!("delete [{pos},{end})");
9417                    d.edit(pos, end, "");
9418                } else {
9419                    let ins = inserts[step % inserts.len()];
9420                    action = format!("insert {ins:?} @ {pos}");
9421                    d.edit(pos, pos, ins);
9422                }
9423                // Walk the caret somewhere else in the document, independently
9424                // of where the edit landed.
9425                let want = (step * 29 + 11) % (d.source.len() + 1);
9426                d.caret = (want..=d.source.len())
9427                    .find(|&i| d.source.is_char_boundary(i))
9428                    .unwrap();
9429                d.build_visual_unwrapped();
9430
9431                let want = reference_map_revealing(&d.source, d.reveal_line());
9432                if maps_differ(&d.vmap, &want) {
9433                    panic!(
9434                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
9435                        d.caret, d.source
9436                    );
9437                }
9438            }
9439        }
9440    }
9441
9442    #[test]
9443    fn caret_motion_across_lines_rebuilds_only_under_full() {
9444        // The cache-key change has to earn its keep in both directions: `Full`
9445        // must rebuild when the caret changes line (or the reveal would never
9446        // move), and the hidden modes must *not* (or every arrow key would pay
9447        // for a feature they don't use). The existing `cache_motion` test pins
9448        // the second for the default mode; this pins the pair against a mode
9449        // change alone.
9450        let body = "*one* here\n\n*two* there\n";
9451
9452        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
9453        full.set_markup_mode(MarkupMode::Full);
9454        caret_at(&mut full, "one");
9455        let before = full.revision();
9456        caret_at(&mut full, "two");
9457        assert_eq!(full.revision(), before, "motion is not an edit");
9458        assert!(
9459            drawn_rows(&full).iter().any(|r| r == "*two* there"),
9460            "the map followed the caret: {:?}",
9461            drawn_rows(&full)
9462        );
9463
9464        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
9465        caret_at(&mut hidden, "one");
9466        let key = hidden.vmap_key.clone();
9467        caret_at(&mut hidden, "two");
9468        assert_eq!(
9469            hidden.vmap_key, key,
9470            "a hidden mode rebuilds nothing on motion"
9471        );
9472    }
9473
9474    #[test]
9475    fn wysiwyg_down_crosses_a_paragraph_boundary() {
9476        // Regression: the blank separator row used to share the previous
9477        // paragraph's end offset, so Down got pinned at the boundary (while Up
9478        // still crossed). Both directions must step through it symmetrically.
9479        //
9480        // It's now stepped *over* rather than onto: the blank line between two
9481        // paragraphs is the boundary being drawn, not a line of the document, so
9482        // one press of Down crosses it. The goal column survives the crossing —
9483        // col 3 at the end of "abc" is col 3 at the end of "def".
9484        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
9485        d.caret = 3; // end of "abc" (row 0)
9486        d.move_down(false);
9487        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
9488        assert_eq!(d.caret, 8); // end of "def", col 3 kept
9489        d.move_up(false);
9490        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
9491        assert_eq!(d.caret, 3);
9492    }
9493
9494    #[test]
9495    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
9496        // The second Up and the second Down here run off the ends of the
9497        // document, which is no longer a place a press is swallowed: they carry
9498        // the caret to the start and the end of the text. The claim in the
9499        // middle — that a Down retraces the Up that crossed the paragraph gap —
9500        // is the one this test is for, and it is asserted where it is made.
9501        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
9502        d.caret = 5; // start of "def"
9503        let start = d.caret_pos();
9504        d.move_up(false);
9505        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
9506        d.move_up(false);
9507        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
9508        d.move_down(false);
9509        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
9510        d.move_down(false);
9511        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
9512    }
9513
9514    #[test]
9515    fn wysiwyg_new_paragraph_shows_before_typing() {
9516        // Regression: two Enters at the end of a paragraph produced trailing
9517        // newlines with no AST node, so the caret appeared stuck on the old line
9518        // until a character was typed. It must ride down onto the new line now.
9519        let mut d = doc_with("wys_newpara", "abc\n");
9520        d.view = View::Wysiwyg;
9521        d.caret = 3;
9522        d.insert("\n");
9523        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
9524        assert_eq!(d.source, "abc\n\n\n");
9525        d.build_visual(80);
9526        let (row, _) = d.caret_pos();
9527        assert!(
9528            row >= 2,
9529            "caret should have moved down to the new line, got row {row}"
9530        );
9531        assert!(
9532            d.vmap.num_rows() >= 3,
9533            "the blank lines should render as rows"
9534        );
9535    }
9536
9537    #[test]
9538    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
9539        // The reported bug: Enter at the end of a paragraph that has another
9540        // paragraph below put the caret at the *start of the next paragraph* —
9541        // the empty paragraph it opened had no row, so the caret snapped onto
9542        // "World". It must now sit on its own empty line, with a blank spacer
9543        // above it (the paragraph gap).
9544        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
9545        d.caret = 5; // end of "Hello"
9546        d.newline();
9547        d.build_visual(80);
9548        let (row, col) = d.caret_pos();
9549        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
9550        assert_eq!(
9551            d.vmap.row_width(row),
9552            0,
9553            "caret's row must be empty, not 'World'"
9554        );
9555        assert!(
9556            row >= 2,
9557            "a blank spacer row should sit above the caret, got row {row}"
9558        );
9559        // The row above the caret is a real (empty) gap, and "Hello" stays put.
9560        assert_eq!(
9561            d.vmap.row_width(row - 1),
9562            0,
9563            "the row above the caret is a gap"
9564        );
9565        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9566        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
9567    }
9568
9569    #[test]
9570    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
9571        // At the document end a single Enter must also show the paragraph gap —
9572        // a blank spacer row above the caret — so the layout already matches how
9573        // it will look once the new paragraph has text.
9574        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
9575        d.caret = 5; // end of "Hello", no trailing newline
9576        d.newline(); // source becomes "Hello\n\n"
9577        d.build_visual(80);
9578        let (row, col) = d.caret_pos();
9579        assert_eq!(col, 0);
9580        assert!(
9581            row >= 2,
9582            "caret should sit below a blank spacer, got row {row}"
9583        );
9584        assert_eq!(
9585            d.vmap.row_width(row - 1),
9586            0,
9587            "the row above the caret is a gap"
9588        );
9589    }
9590
9591    #[test]
9592    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
9593        // The spacer is view-only: typing the new paragraph must not reflow the
9594        // caret onto a different row — the transient view already matched the
9595        // settled one.
9596        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
9597        d.caret = 5;
9598        d.newline();
9599        d.build_visual(80);
9600        let before = d.caret_pos();
9601        d.insert("New");
9602        d.build_visual(80);
9603        let after = d.caret_pos();
9604        assert_eq!(
9605            after.0, before.0,
9606            "typing must not move the caret to another row ({before:?} -> {after:?})"
9607        );
9608    }
9609
9610    #[test]
9611    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
9612        let fm = "---\ntitle: hi\n---\n";
9613        let body = format!("{fm}# leaf\n\nbody\n");
9614        let mut d = wysiwyg_doc("wys_fm", &body);
9615        // Opening lifts the caret out of the now-hidden frontmatter.
9616        assert_eq!(
9617            d.caret,
9618            fm.len(),
9619            "caret should start at the first real block"
9620        );
9621        // Left at the content start can't step back into frontmatter.
9622        d.move_left(false);
9623        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
9624        // Doc-start lands on the content floor, not offset 0.
9625        d.move_doc_start(false);
9626        assert_eq!(d.caret, fm.len());
9627        // Select-all + copy never include the frontmatter bytes.
9628        d.select_all();
9629        let sel = d.selected_text().unwrap().to_string();
9630        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
9631        assert!(
9632            sel.starts_with("# leaf"),
9633            "selection should begin at content: {sel:?}"
9634        );
9635    }
9636
9637    #[test]
9638    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
9639        // A fresh note is frontmatter and nothing else. With no rendered block
9640        // to floor the caret it opened at offset 0 — before the opening `---` —
9641        // so the first keystroke wrote itself in front of the metadata and the
9642        // file came out as `This---\ntitle: …`.
9643        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
9644        let mut d = wysiwyg_doc("wys_fm_only", fm);
9645        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
9646        // Nothing is rendered, so the caret draws at the origin of an empty view
9647        // — the same place an empty document puts it.
9648        assert_eq!(d.caret_pos(), (0, 0));
9649        d.insert("This");
9650        assert_eq!(d.source, format!("{fm}This"));
9651    }
9652
9653    /// `select_range` is the verb for a range a host already knows the bytes of,
9654    /// so it must not snap — and must still hold every invariant `place_caret`
9655    /// holds, the frontmatter floor above all.
9656    #[test]
9657    fn select_range_takes_the_range_as_given_but_still_floors_it() {
9658        let fm = "---\ntitle: foo\n---\n\n";
9659        let body = format!("{fm}body foo here\n");
9660        let mut d = wysiwyg_doc("wys_select_range", &body);
9661
9662        // The `foo` in the body: taken exactly, not snapped to a caret stop.
9663        let at = body.rfind("foo").unwrap();
9664        d.select_range(at, at + 3);
9665        assert_eq!(d.selection(), Some((at, at + 3)));
9666        assert_eq!(d.selected_text(), Some("foo"));
9667
9668        // The `foo` in the hidden frontmatter: below the floor, so both ends
9669        // come up to it rather than parking the caret in the metadata, where a
9670        // later keystroke would rewrite `title:`.
9671        let hidden = body.find("foo").unwrap();
9672        assert!(hidden < d.vmap.content_start);
9673        d.select_range(hidden, hidden + 3);
9674        assert!(
9675            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
9676            "a range under the floor must not leave the caret in the frontmatter"
9677        );
9678
9679        // Past the end, and mid-character, are both brought back to something
9680        // sliceable rather than panicking the next reader of the range.
9681        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
9682        let mut d = multi;
9683        d.select_range(2, 9_999);
9684        assert_eq!(d.caret, d.source.len());
9685        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
9686        assert!(d.source.is_char_boundary(d.caret));
9687    }
9688
9689    /// The bug `select_range` exists for: a match butting up against a hidden
9690    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
9691    /// the one before the `**`.
9692    #[test]
9693    fn select_range_does_not_snap_off_a_hidden_delimiter() {
9694        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
9695        let at = d.source.find("needle").unwrap();
9696        d.select_range(at, at + 6);
9697        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
9698    }
9699
9700    #[test]
9701    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
9702        // Backspace deletes `prev_boundary..caret` directly; at the first real
9703        // block that boundary is inside the hidden frontmatter, so it must be a
9704        // no-op rather than eating the closing `---`.
9705        let fm = "---\ntitle: hi\n---\n";
9706        let body = format!("{fm}leaf\n");
9707        let mut d = wysiwyg_doc("wys_fm_bs", &body);
9708        assert_eq!(d.caret, fm.len());
9709        d.backspace();
9710        assert_eq!(d.source, body, "backspace must not touch frontmatter");
9711        d.delete_word_back();
9712        assert_eq!(
9713            d.source, body,
9714            "word-delete must not touch frontmatter either"
9715        );
9716    }
9717
9718    #[test]
9719    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
9720        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
9721        // fenced div, really, since core parses these on for every document
9722        // now (`parse_extensions`). The container is a `directive` node, an
9723        // `is_block_container` kind like `block_quote`, so the caret works
9724        // inside its child paragraph exactly as it would inside a quote: typing
9725        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
9726        // untouched.
9727        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
9728        let mut d = wysiwyg_doc("wys_vis", body);
9729        d.caret = body.find("hello").unwrap() + "hello".len();
9730        d.insert("!");
9731        assert_eq!(
9732            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
9733            "typing inside the block edits its content in place"
9734        );
9735        assert!(
9736            d.source.contains(":::vis{.public .family}"),
9737            "opening fence survives"
9738        );
9739        assert!(d.source.contains(":::\nafter"), "closing fence survives");
9740    }
9741
9742    #[test]
9743    fn source_view_still_reaches_frontmatter() {
9744        // The metadata is only *hidden*, never lost: the source view edits and
9745        // selects it in full, and it's always preserved on save.
9746        let fm = "---\ntitle: hi\n---\n";
9747        let body = format!("{fm}# leaf\n");
9748        let mut d = doc_with("src_fm", &body);
9749        d.select_all();
9750        let sel = d.selected_text().unwrap();
9751        assert!(
9752            sel.contains("title"),
9753            "source view should select everything"
9754        );
9755        d.move_doc_start(false);
9756        assert_eq!(d.caret, 0, "source view can reach offset 0");
9757    }
9758
9759    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
9760
9761    #[test]
9762    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
9763        // The border and padding between two cells all share one source offset,
9764        // so a column-stepping caret would sit on `│` and then stall there
9765        // forever. Right must step: end of "Name" -> start of "Qty".
9766        let mut d = wysiwyg_doc("tbl_right", TABLE);
9767        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
9768        d.move_right(false);
9769        assert_eq!(
9770            d.caret,
9771            TABLE.find("Qty").unwrap(),
9772            "should land in the next cell"
9773        );
9774        let (r, c) = d.caret_pos();
9775        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
9776    }
9777
9778    #[test]
9779    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
9780        let mut d = wysiwyg_doc("tbl_left", TABLE);
9781        d.caret = TABLE.find("Qty").unwrap();
9782        d.move_left(false);
9783        assert_eq!(
9784            d.caret,
9785            TABLE.find("Name").unwrap() + 4,
9786            "end of the previous cell"
9787        );
9788    }
9789
9790    #[test]
9791    fn wysiwyg_down_steps_over_a_table_rule() {
9792        // Between the header and the first body row sits a `├───┼───┤` rule.
9793        // It's drawn but holds no caret, so one Down must reach "Pear".
9794        let mut d = wysiwyg_doc("tbl_down", TABLE);
9795        d.caret = TABLE.find("Name").unwrap();
9796        d.move_down(false);
9797        assert_eq!(
9798            d.caret,
9799            TABLE.find("Pear").unwrap(),
9800            "one Down reaches the body row"
9801        );
9802        d.move_down(false);
9803        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
9804    }
9805
9806    #[test]
9807    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
9808        let mut d = wysiwyg_doc("tbl_tab", TABLE);
9809        d.caret = TABLE.find("Name").unwrap();
9810        // A hop lands with the destination cell's whole content selected, the
9811        // caret at its end — so typing replaces the cell like a form field.
9812        assert!(d.cell_hop(true));
9813        assert_eq!(
9814            d.selected_text(),
9815            Some("Qty"),
9816            "the target cell comes up selected"
9817        );
9818        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
9819        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
9820        assert_eq!(d.selected_text(), Some("Pear"));
9821        assert!(d.cell_hop(false));
9822        assert_eq!(d.selected_text(), Some("Qty"));
9823    }
9824
9825    #[test]
9826    fn tab_outside_a_table_is_not_a_cell_hop() {
9827        // `cell_hop` reports false so the frontend can indent as usual.
9828        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
9829        d.caret = 4;
9830        assert!(!d.cell_hop(true));
9831        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
9832    }
9833
9834    #[test]
9835    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
9836        let mut d = wysiwyg_doc("tbl_edge", TABLE);
9837        d.caret = TABLE.rfind("12").unwrap(); // the final cell
9838        assert!(!d.cell_hop(true), "no cell after the last one");
9839        d.caret = TABLE.find("Name").unwrap();
9840        assert!(!d.cell_hop(false), "no cell before the first one");
9841    }
9842
9843    #[test]
9844    fn wysiwyg_vertical_cell_motion_holds_the_column() {
9845        // Down/Up step to the cell above/below in the *same column*, not back to
9846        // the top-left the way a naive row/col motion over the picture would.
9847        let mut d = wysiwyg_doc("tbl_vert", TABLE);
9848        d.caret = TABLE.find("Qty").unwrap();
9849        // Each vertical hop selects the destination cell, holding the column.
9850        assert!(d.cell_move_vertical(true));
9851        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
9852        assert!(d.cell_move_vertical(true));
9853        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
9854        assert!(!d.cell_move_vertical(true), "no row below the last");
9855        assert!(d.cell_move_vertical(false));
9856        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
9857        assert!(d.cell_move_vertical(false));
9858        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
9859        assert!(!d.cell_move_vertical(false), "no row above the header");
9860    }
9861
9862    #[test]
9863    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
9864        let mut d = wysiwyg_doc("tbl_grow", TABLE);
9865        d.caret = TABLE.rfind("12").unwrap();
9866        let rows_before = d.source.matches('\n').count();
9867        assert!(d.cell_tab(true), "acts as a table key");
9868        assert_eq!(
9869            d.source.matches('\n').count(),
9870            rows_before + 1,
9871            "a fresh row was appended"
9872        );
9873        assert!(d.caret_in_table(), "the caret entered the new row");
9874        // The caret sits in the new row's first cell — past the old last cell.
9875        assert!(d.caret > TABLE.rfind("12").unwrap());
9876    }
9877
9878    #[test]
9879    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
9880        let mut d = wysiwyg_doc("tbl_ret", TABLE);
9881        d.caret = TABLE.find("Name").unwrap();
9882        assert!(d.cell_return(), "acts as a table key");
9883        assert_eq!(
9884            d.selected_text(),
9885            Some("Pear"),
9886            "Return drops one cell, selecting it"
9887        );
9888        // From the last row, Return appends a row and enters it.
9889        d.caret = TABLE.rfind("Fig").unwrap();
9890        let rows_before = d.source.matches('\n').count();
9891        assert!(d.cell_return());
9892        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
9893        assert!(d.caret_in_table());
9894    }
9895
9896    #[test]
9897    fn return_and_tab_outside_a_table_decline() {
9898        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
9899        d.caret = 4;
9900        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
9901        assert!(!d.cell_tab(true), "no table: the frontend indents");
9902        assert!(
9903            !d.cell_line_break(),
9904            "no table: the frontend breaks the line"
9905        );
9906    }
9907
9908    #[test]
9909    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
9910        let mut d = wysiwyg_doc("tbl_break", TABLE);
9911        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
9912        assert!(d.cell_line_break(), "acts as a table key");
9913        assert!(
9914            d.source.contains("Pear<br>"),
9915            "spelled as an inline <br>: {}",
9916            d.source
9917        );
9918        assert!(d.caret_in_table(), "still in the cell, past the break");
9919        // The break renders as a real line: the "Pear" cell now draws two lines,
9920        // so the table's picture is one row taller than a single-line table.
9921        d.build_visual(80);
9922        let table = &d.vmap.tables[0];
9923        let cell = &table.grid[1].cells[0]; // first body row, first column
9924        assert!(
9925            cell.glyphs.iter().any(|g| g.ch == '\n'),
9926            "the cell carries the break as a newline glyph for the frontend to split"
9927        );
9928    }
9929
9930    #[test]
9931    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
9932        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
9933        // back as structure — the whole point of routing through insert_line_break
9934        // instead of splicing raw `<br>` bytes.
9935        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
9936        d.caret = TABLE.find("Pear").unwrap() + 4;
9937        assert!(d.cell_line_break());
9938        let kinds: Vec<Kind> = d
9939            .editor
9940            .nodes()
9941            .unwrap()
9942            .iter()
9943            .map(|n| n.kind.clone())
9944            .collect();
9945        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
9946        assert!(
9947            !kinds.contains(&Kind::RawInline),
9948            "still raw HTML: {kinds:?}"
9949        );
9950    }
9951
9952    #[test]
9953    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
9954        // The `<br>` draws as one newline glyph, so Backspace over it must take
9955        // all four bytes — a one-byte delete would strand a visible `<br` in the
9956        // cell (the reported bug).
9957        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
9958        d.caret = TABLE.find("Pear").unwrap() + 4;
9959        assert!(d.cell_line_break());
9960        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
9961        d.backspace(); // caret sits just past the break
9962        assert!(
9963            !d.source.contains("<br"),
9964            "no half-deleted <br left: {}",
9965            d.source
9966        );
9967        assert!(
9968            d.source.contains("| Pear |"),
9969            "the cell is back to one line: {}",
9970            d.source
9971        );
9972    }
9973
9974    #[test]
9975    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
9976        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
9977        d.caret = TABLE.find("Pear").unwrap() + 4;
9978        assert!(d.cell_line_break());
9979        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
9980        d.delete_forward();
9981        assert!(
9982            !d.source.contains("<br"),
9983            "no half-deleted <br: {}",
9984            d.source
9985        );
9986        assert!(
9987            d.source.contains("| Pear |"),
9988            "cell back to one line: {}",
9989            d.source
9990        );
9991    }
9992
9993    #[test]
9994    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
9995        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
9996        // still consumed (a real newline would split the one-line row), but the
9997        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
9998        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
9999        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10000        d.caret = src.find("Pear").unwrap() + 4;
10001        assert!(d.caret_in_table(), "caret should be inside the djot table");
10002        assert!(
10003            d.cell_line_break(),
10004            "the key is consumed, not passed to the frontend"
10005        );
10006        assert_eq!(d.source, src, "the djot cell is left untouched");
10007        assert!(
10008            !d.source.contains("<br>"),
10009            "no non-idiomatic <br> spliced into djot"
10010        );
10011        assert!(
10012            d.status.is_some(),
10013            "the refusal is surfaced on the status line"
10014        );
10015    }
10016
10017    #[test]
10018    fn typing_in_a_cell_edits_that_cell() {
10019        // Editing comes free once offsets map correctly: the caret is a source
10020        // offset, so a normal splice lands inside the pipe table.
10021        let mut d = wysiwyg_doc("tbl_type", TABLE);
10022        d.caret = TABLE.find("Pear").unwrap() + 4;
10023        d.insert("s");
10024        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
10025    }
10026
10027    #[test]
10028    fn motion_and_delete_treat_an_emoji_as_one_character() {
10029        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
10030        // bytes, several codepoints. Right-arrow must clear it in one step, and
10031        // backspace must remove the whole cluster, not a stray joiner.
10032        let family = "👨‍👩‍👧";
10033        let mut d = doc_with("emoji", &format!("a{family}b\n"));
10034        d.caret = 1; // just after 'a', before the emoji
10035        d.move_right(false);
10036        assert_eq!(
10037            d.caret,
10038            1 + family.len(),
10039            "one step clears the whole cluster"
10040        );
10041        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
10042
10043        d.backspace(); // delete the emoji as a unit
10044        assert_eq!(d.source, "ab\n");
10045        assert_eq!(d.caret, 1);
10046    }
10047
10048    #[test]
10049    fn motion_handles_a_combining_accent_as_one_character() {
10050        // "e" + U+0301 (combining acute) renders as one é.
10051        let mut d = doc_with("combining", "e\u{0301}x\n");
10052        d.caret = 0;
10053        d.move_right(false);
10054        assert_eq!(
10055            d.caret,
10056            "e\u{0301}".len(),
10057            "steps past base + combining mark"
10058        );
10059    }
10060
10061    #[test]
10062    fn undo_then_redo_round_trips_an_edit() {
10063        let mut d = doc_with("undo", "hello\n");
10064        d.caret = 5;
10065        d.insert("!");
10066        assert_eq!(d.source, "hello!\n");
10067        d.undo();
10068        assert_eq!(d.source, "hello\n");
10069        assert_eq!(d.caret, 5, "undo restores the caret");
10070        d.redo();
10071        assert_eq!(d.source, "hello!\n");
10072    }
10073
10074    #[test]
10075    fn a_run_of_typing_undoes_as_one_step() {
10076        let mut d = doc_with("coalesce", "\n");
10077        d.caret = 0;
10078        d.insert("a");
10079        d.insert("b");
10080        d.insert("c");
10081        assert_eq!(d.source, "abc\n");
10082        d.undo(); // the whole typed run, not just "c"
10083        assert_eq!(d.source, "\n");
10084        d.undo(); // nothing left — the run was one step
10085        assert_eq!(d.source, "\n");
10086        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10087    }
10088
10089    // ── IME composition ──────────────────────────────────────────────────────
10090
10091    #[test]
10092    fn a_composition_run_undoes_as_one_step() {
10093        let mut d = doc_with("compose", "\n");
10094        d.caret = 0;
10095        // What an IME does: each step replaces the last one's provisional bytes.
10096        d.edit_composing(0, 0, "k");
10097        d.edit_composing(0, 1, "か");
10098        d.edit_composing(0, 3, "かん");
10099        d.edit_composing(0, 6, "感"); // the commit
10100        d.end_composition();
10101        assert_eq!(d.source, "感\n");
10102        d.undo(); // the whole composition, not its last keystroke
10103        assert_eq!(d.source, "\n");
10104        assert_eq!(d.status.as_deref(), None, "the run was a single step");
10105    }
10106
10107    #[test]
10108    fn two_compositions_are_two_undo_steps() {
10109        let mut d = doc_with("compose_two", "\n");
10110        d.caret = 0;
10111        d.edit_composing(0, 0, "か");
10112        d.edit_composing(0, 3, "蚊");
10113        d.end_composition();
10114        d.edit_composing(3, 3, "き");
10115        d.edit_composing(3, 6, "木");
10116        d.end_composition();
10117        assert_eq!(d.source, "蚊木\n");
10118        d.undo();
10119        assert_eq!(d.source, "蚊\n", "only the second composition");
10120        d.undo();
10121        assert_eq!(d.source, "\n");
10122    }
10123
10124    #[test]
10125    fn a_composition_does_not_fold_into_the_typing_around_it() {
10126        let mut d = doc_with("compose_typing", "\n");
10127        d.caret = 0;
10128        d.insert("a");
10129        d.insert("b");
10130        d.edit_composing(2, 2, "か");
10131        d.edit_composing(2, 5, "蚊");
10132        d.end_composition();
10133        d.insert("c");
10134        assert_eq!(d.source, "ab蚊c\n");
10135        d.undo();
10136        assert_eq!(d.source, "ab蚊\n");
10137        d.undo();
10138        assert_eq!(d.source, "ab\n");
10139        d.undo();
10140        assert_eq!(d.source, "\n");
10141    }
10142
10143    #[test]
10144    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
10145        let mut d = doc_with("compose_spurious", "\n");
10146        d.caret = 0;
10147        d.insert("a");
10148        d.end_composition(); // an IME unmarking unprompted
10149        d.insert("b");
10150        assert_eq!(d.source, "ab\n");
10151        d.undo();
10152        assert_eq!(d.source, "\n", "still one typed run");
10153    }
10154
10155    // ── the clipboard's rich flavor ──────────────────────────────────────────
10156
10157    #[test]
10158    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
10159        let mut d = doc_with("sel_inline", "a **bold** c\n");
10160        d.anchor = Some(2);
10161        d.caret = 10; // `**bold**`, inside the paragraph
10162        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
10163    }
10164
10165    #[test]
10166    fn a_whole_block_selection_keeps_its_paragraph() {
10167        let mut d = doc_with("sel_block", "a **bold** c\n");
10168        d.anchor = Some(0);
10169        d.caret = 12; // the entire paragraph
10170        assert_eq!(
10171            d.selection_html().as_deref(),
10172            Some("<p>a <strong>bold</strong> c</p>")
10173        );
10174    }
10175
10176    #[test]
10177    fn a_multi_block_selection_keeps_its_structure() {
10178        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
10179        d.select_all();
10180        let html = d.selection_html().expect("renders");
10181        assert!(html.contains("<p>para</p>"), "{html:?}");
10182        assert!(html.contains("<li>one</li>"), "{html:?}");
10183    }
10184
10185    #[test]
10186    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
10187        // The fragment `Head` is a paragraph standalone; the *document* says it
10188        // sits inside one block, so the wrapper is an artifact either way.
10189        let mut d = doc_with("sel_heading", "# Head line\n");
10190        d.anchor = Some(2);
10191        d.caret = 6;
10192        assert_eq!(d.selection_html().as_deref(), Some("Head"));
10193    }
10194
10195    #[test]
10196    fn no_selection_publishes_no_html() {
10197        let mut d = doc_with("sel_none", "a b\n");
10198        d.caret = 1;
10199        assert_eq!(d.selection_html(), None);
10200    }
10201
10202    #[test]
10203    fn pasting_html_converts_it_and_is_one_undo_step() {
10204        let mut d = doc_with("paste_html", "x\n");
10205        d.caret = 1;
10206        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
10207        assert_eq!(d.source, "xa **b** c\n");
10208        d.undo();
10209        assert_eq!(d.source, "x\n", "the whole paste, in one step");
10210    }
10211
10212    #[test]
10213    fn pasting_html_replaces_the_selection() {
10214        let mut d = doc_with("paste_html_sel", "keep drop\n");
10215        d.anchor = Some(5);
10216        d.caret = 9;
10217        assert!(d.paste_html("<em>new</em>"));
10218        assert_eq!(d.source, "keep *new*\n");
10219    }
10220
10221    #[test]
10222    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
10223        let mut d = doc_with("paste_html_bad", "x\n");
10224        d.caret = 1;
10225        // twig builds no table from HTML; raw `<table>` in prose is worse than
10226        // the plain flavor the caller still holds.
10227        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
10228        assert_eq!(d.source, "x\n", "declined edits nothing");
10229    }
10230
10231    #[test]
10232    fn copy_then_paste_round_trips_through_the_html_flavor() {
10233        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
10234        d.select_all();
10235        let html = d.selection_html().expect("renders");
10236        let mut into = doc_with("clip_round_dst", "\n");
10237        into.caret = 0;
10238        assert!(into.paste_html(&html));
10239        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
10240    }
10241
10242    #[test]
10243    fn moving_the_caret_starts_a_new_undo_group() {
10244        let mut d = doc_with("break", "\n");
10245        d.caret = 0;
10246        d.insert("a");
10247        d.insert("b"); // "ab\n", caret at 2
10248        d.move_left(false); // breaks the run
10249        d.insert("X"); // "aXb\n"
10250        assert_eq!(d.source, "aXb\n");
10251        d.undo();
10252        assert_eq!(
10253            d.source, "ab\n",
10254            "first undo removes only the post-move insert"
10255        );
10256        d.undo();
10257        assert_eq!(d.source, "\n", "second undo removes the earlier run");
10258    }
10259
10260    #[test]
10261    fn undo_reverses_a_format_toggle() {
10262        let mut d = doc_with("fmt_undo", "a word b\n");
10263        d.anchor = Some(2);
10264        d.caret = 6;
10265        d.toggle(InlineKind::Strong);
10266        assert_eq!(d.source, "a **word** b\n");
10267        d.undo();
10268        assert_eq!(d.source, "a word b\n");
10269    }
10270
10271    #[test]
10272    fn undo_back_to_the_saved_state_clears_dirty() {
10273        let mut d = doc_with("dirty_undo", "hello\n");
10274        assert!(!d.dirty);
10275        d.caret = 5;
10276        d.insert("!");
10277        assert!(d.dirty);
10278        d.undo();
10279        assert!(
10280            !d.dirty,
10281            "undoing to the saved source is not a modification"
10282        );
10283    }
10284
10285    #[test]
10286    fn a_new_edit_invalidates_redo() {
10287        let mut d = doc_with("redo_inv", "\n");
10288        d.caret = 0;
10289        d.insert("a");
10290        d.undo();
10291        d.insert("b"); // diverges — the redo of "a" is now gone
10292        d.redo();
10293        assert_eq!(d.source, "b\n");
10294    }
10295
10296    #[test]
10297    fn undo_on_empty_history_is_a_no_op() {
10298        let mut d = doc_with("undo_empty", "hi\n");
10299        d.undo();
10300        assert_eq!(d.source, "hi\n");
10301        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10302    }
10303
10304    #[test]
10305    fn a_one_character_paste_is_its_own_undo_step() {
10306        for view in [View::Source, View::Wysiwyg] {
10307            let mut d = doc_in(view, "paste_step", "ab\n");
10308            d.caret = 0;
10309            d.insert("x");
10310            d.insert("y"); // a run of typing
10311            d.paste("z"); // one character, but pasted — not part of that run
10312            assert_eq!(d.source, "xyzab\n");
10313            d.undo();
10314            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
10315            assert_eq!(d.caret, 2, "and hands back the caret it found");
10316            d.undo();
10317            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
10318        }
10319    }
10320
10321    #[test]
10322    fn the_same_character_typed_still_joins_the_run() {
10323        // The other half of the pair: `z` is a keystroke here and a paste above,
10324        // and the two undo differently. Nothing about the *string* says which —
10325        // which is why provenance has to come from the door the caller uses.
10326        for view in [View::Source, View::Wysiwyg] {
10327            let mut d = doc_in(view, "typed_run", "ab\n");
10328            d.caret = 0;
10329            d.insert("x");
10330            d.insert("y");
10331            d.insert("z");
10332            d.undo();
10333            assert_eq!(d.source, "ab\n", "one run, one step");
10334        }
10335    }
10336
10337    #[test]
10338    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
10339        for view in [View::Source, View::Wysiwyg] {
10340            let mut d = doc_in(view, "undo_caret", "hello world\n");
10341            d.caret = 11; // standing at the end of "world", away from the edit
10342            d.edit(0, 5, "goodbye");
10343            assert_eq!(d.source, "goodbye world\n");
10344            d.undo();
10345            assert_eq!(d.source, "hello world\n");
10346            // The undone edit ends at offset 5; the user was at 11.
10347            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
10348        }
10349    }
10350
10351    #[test]
10352    fn undo_restores_the_selection_the_edit_replaced() {
10353        for view in [View::Source, View::Wysiwyg] {
10354            let mut d = doc_in(view, "undo_sel", "a word b\n");
10355            d.anchor = Some(2);
10356            d.caret = 6; // "word" selected
10357            d.insert("X");
10358            assert_eq!(d.source, "a X b\n");
10359            d.undo();
10360            assert_eq!(d.source, "a word b\n");
10361            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
10362        }
10363    }
10364
10365    #[test]
10366    fn redo_restores_the_caret_the_edit_left_behind() {
10367        for view in [View::Source, View::Wysiwyg] {
10368            let mut d = doc_in(view, "redo_caret", "hello world\n");
10369            d.caret = 11;
10370            d.edit(0, 5, "goodbye");
10371            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
10372            d.undo();
10373            d.redo();
10374            assert_eq!(d.source, "goodbye world\n");
10375            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
10376        }
10377    }
10378
10379    #[test]
10380    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
10381        for view in [View::Source, View::Wysiwyg] {
10382            let mut d = doc_in(view, "run_caret", "hi\n");
10383            d.caret = 2;
10384            d.insert("a");
10385            d.insert("b");
10386            d.insert("c");
10387            assert_eq!(d.source, "hiabc\n");
10388            d.undo();
10389            assert_eq!(d.source, "hi\n");
10390            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
10391            d.redo();
10392            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
10393        }
10394    }
10395
10396    #[test]
10397    fn undo_restores_the_caret_across_a_format_toggle() {
10398        // A toggle reaches twig without going through `splice`, so it has to
10399        // record its own step — miss it and every stack depth below it is off by
10400        // one, and undo starts handing back another edit's caret.
10401        for view in [View::Source, View::Wysiwyg] {
10402            let mut d = doc_in(view, "fmt_caret", "a word b\n");
10403            d.caret = 8;
10404            d.anchor = Some(2);
10405            d.caret = 6;
10406            d.toggle(InlineKind::Strong);
10407            assert_eq!(d.source, "a **word** b\n");
10408            d.undo();
10409            assert_eq!(d.source, "a word b\n");
10410            assert_eq!(
10411                d.selection(),
10412                Some((2, 6)),
10413                "the toggled selection comes back"
10414            );
10415        }
10416    }
10417
10418    #[test]
10419    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
10420        // The drift that would never announce itself: twig drops its redo stack
10421        // on any fresh edit, so a leaf redo entry that outlives it would restore
10422        // a caret from the timeline that edit abandoned.
10423        for view in [View::Source, View::Wysiwyg] {
10424            let mut d = doc_in(view, "redo_trunc", "hello world\n");
10425            d.caret = 11;
10426            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
10427            d.undo();
10428            assert_eq!(d.caret, 11);
10429            d.caret = 0;
10430            d.insert("X"); // diverges: A's redo is gone from twig
10431            assert_eq!(d.source, "Xhello world\n");
10432
10433            d.redo();
10434            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
10435            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
10436            d.undo();
10437            assert_eq!(d.source, "hello world\n");
10438            assert_eq!(
10439                d.caret, 0,
10440                "the surviving step's caret, not the dropped one"
10441            );
10442        }
10443    }
10444
10445    #[test]
10446    fn indent_and_outdent_move_the_caret_line_with_its_text() {
10447        for view in [View::Source, View::Wysiwyg] {
10448            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
10449            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
10450            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
10451            // Indentation the caret is standing *in* collapses to the line start
10452            // rather than dragging the caret into the text.
10453            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
10454            // A line with none to give back is left exactly as it was.
10455            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
10456            // Less than a full level gives back what it has.
10457            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
10458            // A tab is one level however many spaces it isn't.
10459            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
10460        }
10461    }
10462
10463    #[test]
10464    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
10465        // Why the level is two spaces and not the four both frontends type
10466        // today. Four is markdown's indented-code-block marker, so a Tab on a
10467        // paragraph would silently restyle it as code — a width that changes
10468        // what the document *means* isn't an indent. Pinned because the number
10469        // is the kind of thing a later list-aware pass would reach for.
10470        let mut d = doc_with("indent_kind", "hello\n");
10471        d.caret = 2;
10472        d.indent();
10473        assert_eq!(d.source, "  hello\n");
10474        assert!(
10475            d.nodes().iter().any(|n| n.kind == Kind::Para),
10476            "still prose after a Tab"
10477        );
10478        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
10479
10480        // The four-space level this replaces, for contrast: same text, and twig
10481        // reparses the paragraph into a code block.
10482        let mut wide = doc_with("indent_kind_4", "    hello\n");
10483        wide.build_visual(80);
10484        assert!(
10485            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
10486            "four spaces is a code block, not an indented paragraph"
10487        );
10488    }
10489
10490    #[test]
10491    fn indent_nests_a_list_item_under_its_parent() {
10492        // Tab indents a list item by its own marker width, landing its marker at
10493        // the parent's content column so twig reparses it as a nested list.
10494        for view in [View::Source, View::Wysiwyg] {
10495            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
10496            d.caret = 6; // on the second item
10497            d.indent();
10498            assert_eq!(d.source, "- a\n  - b\n");
10499            let lists = d
10500                .nodes()
10501                .iter()
10502                .filter(|n| n.kind == Kind::BulletList)
10503                .count();
10504            assert_eq!(lists, 2, "the indented item is a nested list");
10505        }
10506    }
10507
10508    #[test]
10509    fn indent_nests_an_ordered_item_at_its_marker_width() {
10510        // An ordered marker `1. ` is three columns wide, so a two-space step
10511        // (which nests a bullet) leaves it flat. Regression: Tab must use the
10512        // marker width, three, so the item actually nests — and the source
10513        // renumbers so the sub-list restarts at 1 and the outer list resumes.
10514        for view in [View::Source, View::Wysiwyg] {
10515            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
10516            d.caret = d.source.find('b').unwrap();
10517            d.indent();
10518            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
10519            let lists = d
10520                .nodes()
10521                .iter()
10522                .filter(|n| n.kind == Kind::OrderedList)
10523                .count();
10524            assert_eq!(lists, 2, "the indented item is a nested ordered list");
10525        }
10526    }
10527
10528    #[test]
10529    fn indent_leaves_a_lists_first_item_put() {
10530        // The first item of a list has no sibling above it to nest under, so Tab
10531        // is a no-op there — the marker stays at column zero rather than being
10532        // shoved into indentation twig can't read as a sub-list.
10533        for view in [View::Source, View::Wysiwyg] {
10534            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
10535            d.caret = 1; // on the FIRST item
10536            d.indent();
10537            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
10538            // The sibling below still nests, proving the guard is per-item.
10539            d.caret = d.source.find('b').unwrap();
10540            d.indent();
10541            assert_eq!(d.source, "- a\n  - b\n");
10542        }
10543    }
10544
10545    #[test]
10546    fn hidden_mode_keeps_typed_markup_literal() {
10547        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
10548        // twig escapes what would open markup, so the source is `\*hi\*` and the
10549        // AST is a plain string. Formatting is the commands' job in this mode.
10550        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
10551        d.insert("*hi*");
10552        assert_eq!(d.source, "\\*hi\\*");
10553        assert!(
10554            d.nodes()
10555                .iter()
10556                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
10557        );
10558    }
10559
10560    #[test]
10561    fn hidden_mode_escapes_a_line_start_block_marker() {
10562        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
10563        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
10564        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
10565        d.insert("# hi");
10566        assert_eq!(d.source, "\\# hi");
10567        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
10568    }
10569
10570    #[test]
10571    fn authoring_modes_keep_typed_markup_live() {
10572        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
10573        // (no escape), the same as source view — escaping is `None`'s alone, and
10574        // it's the axis, not the reveal, that decides.
10575        for (view, mode) in [
10576            (View::Wysiwyg, MarkupMode::Shortcuts),
10577            (View::Wysiwyg, MarkupMode::Full),
10578            (View::Source, MarkupMode::None),
10579        ] {
10580            let mut d = doc_in(view, "live_markup", "");
10581            d.set_markup_mode(mode);
10582            d.insert("*hi*");
10583            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
10584        }
10585    }
10586
10587    #[test]
10588    fn hidden_mode_overwrite_undoes_in_one_step() {
10589        // Typing over a selection escapes the replacement *and* stays a single
10590        // undo — the selection-delete and the literal insert fold together, so
10591        // one undo brings the whole selection back, like a plain overwrite.
10592        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
10593        d.anchor = Some(2);
10594        d.caret = 6; // "word"
10595        d.insert("*");
10596        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
10597        d.undo();
10598        assert_eq!(d.source, "a word b\n");
10599        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
10600    }
10601
10602    #[test]
10603    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
10604        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
10605        // the whole visual character, never stranding the hidden `\`.
10606        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
10607        d.insert("*");
10608        assert_eq!(d.source, "\\*");
10609        d.backspace();
10610        assert_eq!(d.source, "", "the escape backslash went with the *");
10611        // A *literal* backslash (source view, no escape) is an ordinary char.
10612        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
10613        s.caret = 3; // after `b`
10614        s.backspace();
10615        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
10616    }
10617
10618    #[test]
10619    fn hidden_mode_leaves_structural_markup_alone() {
10620        // Enter continues a bullet list by writing a real `- ` marker (an
10621        // `insert_raw`, not the typing path), so Hidden mode's escaping never
10622        // touches it — the list keeps working.
10623        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
10624        d.caret = 6;
10625        d.newline();
10626        d.insert("two");
10627        assert_eq!(d.source, "- item\n- two\n");
10628    }
10629
10630    #[test]
10631    fn markup_mode_defaults_to_none_and_round_trips() {
10632        // Diaryx's default is the clean `None` surface; a markup-fluent
10633        // frontend can climb the ladder, and the choice sticks.
10634        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
10635        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
10636        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
10637            d.set_markup_mode(mode);
10638            assert_eq!(d.markup_mode(), mode);
10639        }
10640    }
10641
10642    #[test]
10643    fn full_mode_reveals_only_the_caret_line() {
10644        // The mode's whole claim: the caret's line shows its raw delimiters and
10645        // every other line stays resolved. Two paragraphs with identical markup
10646        // so the only difference between the rows is where the caret is.
10647        let mut d = doc_in(
10648            View::Wysiwyg,
10649            "reveal_caret_line",
10650            "*one* here\n\n*two* there\n",
10651        );
10652        d.set_markup_mode(MarkupMode::Full);
10653
10654        caret_at(&mut d, "one");
10655        let rows = drawn_rows(&d);
10656        assert!(
10657            rows.iter().any(|r| r == "*one* here"),
10658            "caret's line raw: {rows:?}"
10659        );
10660        assert!(
10661            rows.iter().any(|r| r == "two there"),
10662            "other line resolved: {rows:?}"
10663        );
10664
10665        // Move to the other paragraph: the reveal follows, and the line just
10666        // left goes back to being resolved.
10667        caret_at(&mut d, "two");
10668        let rows = drawn_rows(&d);
10669        assert!(
10670            rows.iter().any(|r| r == "*two* there"),
10671            "caret's line raw: {rows:?}"
10672        );
10673        assert!(
10674            rows.iter().any(|r| r == "one here"),
10675            "left line resolved: {rows:?}"
10676        );
10677    }
10678
10679    #[test]
10680    fn hidden_modes_never_reveal_wherever_the_caret_is() {
10681        // The two rungs below `Full` share a rendering: delimiters stay hidden
10682        // even under the caret. `Shortcuts` differing from `None` only in what
10683        // typing does is exactly the point of splitting the axes.
10684        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
10685            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
10686            d.set_markup_mode(mode);
10687            caret_at(&mut d, "one");
10688            let rows = drawn_rows(&d);
10689            assert!(
10690                rows.iter().any(|r| r == "one here"),
10691                "{mode:?} hides: {rows:?}"
10692            );
10693            assert!(
10694                !rows.iter().any(|r| r.contains('*')),
10695                "{mode:?} shows no `*`: {rows:?}"
10696            );
10697        }
10698    }
10699
10700    #[test]
10701    fn revealed_delimiters_are_the_authors_own_spelling() {
10702        // Delimiters are re-read from the source rather than synthesized per
10703        // kind, so a line comes back spelled the way it was written: `_em_` does
10704        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
10705        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
10706        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
10707        d.set_markup_mode(MarkupMode::Full);
10708        caret_at(&mut d, "em");
10709        let rows = drawn_rows(&d);
10710        assert!(
10711            rows.iter().any(|r| r == body.trim_end()),
10712            "the revealed line is its own source: {rows:?}"
10713        );
10714    }
10715
10716    #[test]
10717    fn revealed_heading_shows_its_hashes() {
10718        // The `# ` marker is a block-level prefix, not an inline delimiter, so
10719        // it takes its own path — but it reveals on the same rule.
10720        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
10721        d.set_markup_mode(MarkupMode::Full);
10722
10723        caret_at(&mut d, "Title");
10724        assert!(
10725            drawn_rows(&d).iter().any(|r| r == "# Title"),
10726            "{:?}",
10727            drawn_rows(&d)
10728        );
10729
10730        caret_at(&mut d, "body");
10731        let rows = drawn_rows(&d);
10732        assert!(
10733            rows.iter().any(|r| r == "Title"),
10734            "hashes hidden again: {rows:?}"
10735        );
10736    }
10737
10738    #[test]
10739    fn revealed_delimiters_are_caret_stops() {
10740        // A delimiter that is drawn but can't be reached is worse than one
10741        // that's hidden: the mode exists so the markup can be *edited*. Every
10742        // revealed byte must be somewhere the caret can stand.
10743        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
10744        d.set_markup_mode(MarkupMode::Full);
10745        caret_at(&mut d, "em");
10746        let opener = d.source.find('*').unwrap();
10747        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
10748        assert!(
10749            d.vmap.is_stop(opener + 3),
10750            "the closing `*` is a caret stop"
10751        );
10752    }
10753
10754    #[test]
10755    fn setext_heading_reveals_nothing_across_its_newline() {
10756        // A setext heading's underline is on another line, so it is not the
10757        // caret line's to reveal — and emitting it would inject a `\n` glyph
10758        // that splits the row where the author wrote no break.
10759        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
10760        d.set_markup_mode(MarkupMode::Full);
10761        caret_at(&mut d, "Title");
10762        let rows = drawn_rows(&d);
10763        assert!(
10764            rows.iter().any(|r| r == "Title"),
10765            "title renders alone: {rows:?}"
10766        );
10767        assert!(
10768            !rows.iter().any(|r| r.contains('=')),
10769            "no underline leaks in: {rows:?}"
10770        );
10771    }
10772
10773    #[test]
10774    fn markup_mode_axes_split_the_ladder() {
10775        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
10776        // that authors markup but still hides it, and it's the only rung where
10777        // the two axes disagree.
10778        assert!(!MarkupMode::None.authors());
10779        assert!(!MarkupMode::None.reveals_caret_line());
10780        assert!(MarkupMode::Shortcuts.authors());
10781        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
10782        assert!(MarkupMode::Full.authors());
10783        assert!(MarkupMode::Full.reveals_caret_line());
10784    }
10785
10786    #[test]
10787    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
10788        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
10789        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
10790        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
10791        // nested bullet and `hello` stays prose: the file round-trips instead of
10792        // hiding a heading the user never asked for.
10793        for view in [View::Source, View::Wysiwyg] {
10794            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
10795            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
10796            d.indent();
10797            assert_eq!(d.source, "- hello\n  * \n");
10798            assert!(
10799                d.nodes().iter().all(|n| n.kind != Kind::Heading),
10800                "no heading"
10801            );
10802            // And it's genuinely a nested list, not a flat one.
10803            assert_eq!(
10804                d.nodes()
10805                    .iter()
10806                    .filter(|n| n.kind == Kind::BulletList)
10807                    .count(),
10808                2
10809            );
10810        }
10811    }
10812
10813    #[test]
10814    fn indenting_a_dash_item_with_content_keeps_its_dash() {
10815        // With content, `- x` can't be a setext underline, so there's nothing to
10816        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
10817        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
10818        d.caret = d.source.find('x').unwrap();
10819        d.indent();
10820        assert_eq!(d.source, "- hello\n  - x\n");
10821    }
10822
10823    #[test]
10824    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
10825        // The dash→`*` repair coalesces into the Tab, so a single undo restores
10826        // the whole pre-Tab state rather than stranding a half-collapsed doc.
10827        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
10828        d.caret = d.source.find("- \n").unwrap() + 2;
10829        d.indent();
10830        assert_eq!(d.source, "- hello\n  * \n");
10831        d.undo();
10832        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
10833    }
10834
10835    #[test]
10836    fn indent_leaves_a_nested_lists_first_item_put_too() {
10837        // The guard is about siblings, not depth: the first item of an *inner*
10838        // list (already nested under `a`) still has nothing before it at its own
10839        // level, so Tab can't take it deeper.
10840        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
10841        d.caret = d.source.find('b').unwrap();
10842        d.indent();
10843        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
10844        // But `c` (a sibling of `b`) nests under `b`.
10845        d.caret = d.source.find('c').unwrap();
10846        d.indent();
10847        assert_eq!(d.source, "- a\n  - b\n    - c\n");
10848    }
10849
10850    #[test]
10851    fn backspace_at_a_nested_item_start_outdents_it() {
10852        // Backspace with the caret right after a nested item's marker gives back
10853        // one level of nesting, the mirror of Tab — and renumbers the flattened
10854        // ordered list back to a clean run.
10855        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
10856        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
10857        d.backspace();
10858        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10859    }
10860
10861    #[test]
10862    fn backspace_at_a_top_level_item_start_strips_the_marker() {
10863        // At the outermost level there's no nesting left to give back, so the same
10864        // keystroke drops the bullet and leaves a plain paragraph.
10865        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
10866        d.caret = d.source.find('b').unwrap(); // right after `- `
10867        d.backspace();
10868        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
10869    }
10870
10871    #[test]
10872    fn backspace_mid_item_still_deletes_a_character() {
10873        // The list behaviour is armed only at the item's content start; anywhere
10874        // else Backspace is the ordinary character delete.
10875        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
10876        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
10877        d.backspace();
10878        assert_eq!(d.source, "- b\n");
10879    }
10880
10881    #[test]
10882    fn backspace_at_a_heading_start_strips_the_marker() {
10883        // The `# ` is markup the rich view hides, so Backspace over it takes the
10884        // whole marker and leaves a paragraph. Deleting a byte of it instead left
10885        // `#Title` — no longer a heading, with the hash now literal text the user
10886        // never typed and has to delete again.
10887        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
10888        d.caret = d.source.find('T').unwrap(); // right after `## `
10889        d.backspace();
10890        assert_eq!(d.source, "Title\n");
10891        assert_eq!(
10892            d.caret, 0,
10893            "the caret stays with the text it was in front of"
10894        );
10895    }
10896
10897    #[test]
10898    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
10899        // Only the heading's own marker goes — the quote (or list) it sits in is
10900        // untouched, exactly as un-heading it should be.
10901        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
10902        d.caret = d.source.find('T').unwrap();
10903        d.backspace();
10904        assert_eq!(d.source, "> Title\n");
10905    }
10906
10907    #[test]
10908    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
10909        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
10910        // behind would surface the same stray hash the marker delete just avoided.
10911        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
10912        d.caret = d.source.find('T').unwrap();
10913        d.backspace();
10914        assert_eq!(d.source, "Title\n");
10915        // And it's one edit: a single undo puts the whole heading back.
10916        d.undo();
10917        assert_eq!(d.source, "# Title #\n");
10918    }
10919
10920    #[test]
10921    fn backspace_mid_heading_still_deletes_a_character() {
10922        // The heading behaviour is armed only at the content's start; anywhere
10923        // else Backspace is the ordinary character delete.
10924        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
10925        d.caret = d.source.find('b').unwrap();
10926        d.backspace();
10927        assert_eq!(d.source, "# b\n");
10928    }
10929
10930    #[test]
10931    fn source_view_backspace_still_edits_the_heading_marker_literally() {
10932        // In source view the `# ` is text on the screen the user is deleting a
10933        // byte of, so it keeps its literal meaning — the same split the list
10934        // ladder and Enter draw between the two views.
10935        let mut d = doc_with("bsp_head_src", "# Title\n");
10936        d.caret = d.source.find('T').unwrap();
10937        d.backspace();
10938        assert_eq!(d.source, "#Title\n");
10939    }
10940
10941    #[test]
10942    fn outdent_unnests_an_ordered_item_in_one_press() {
10943        // Shift+Tab gives back exactly the marker width the indent added, so a
10944        // nested ordered item unnests in a single press, and the flattened list
10945        // renumbers back to a clean 1, 2, 3.
10946        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
10947        d.caret = d.source.find('b').unwrap();
10948        d.outdent();
10949        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
10950        let lists = d
10951            .nodes()
10952            .iter()
10953            .filter(|n| n.kind == Kind::OrderedList)
10954            .count();
10955        assert_eq!(lists, 1, "back to one flat list");
10956    }
10957
10958    #[test]
10959    fn table_insert_row_adds_a_row_below_the_caret() {
10960        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10961        d.caret = d.source.find('1').unwrap(); // in the body row
10962        d.table_insert_row(true);
10963        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
10964    }
10965
10966    #[test]
10967    fn table_insert_and_delete_column_at_the_caret() {
10968        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
10969        d.caret = d.source.find('a').unwrap(); // column 0
10970        d.table_insert_column(true); // add a column to the right of `a`
10971        assert_eq!(
10972            d.source,
10973            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
10974        );
10975        d.caret = d.source.find('b').unwrap(); // now the third column
10976        d.table_delete_column();
10977        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
10978    }
10979
10980    // ── ragged formats ───────────────────────────────────────────────────────
10981    // No format spells every gesture. HTML writes the inline marks as a tag pair
10982    // and no heading, list, quote or link; Markdown spells three of the eight
10983    // marks; djot spells all eight and no in-cell break. leaf asks twig per
10984    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
10985    // op discover the fact on its own — one of them didn't.
10986
10987    /// An HTML document in the rich view, ready for a gesture.
10988    fn html_doc(body: &str) -> Doc {
10989        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
10990        d.view = View::Wysiwyg;
10991        d.build_visual(80);
10992        d
10993    }
10994
10995    #[test]
10996    fn a_table_gesture_leaves_an_html_table_alone() {
10997        // The regression this guard exists for. twig's table editor consults no
10998        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
10999        // HTML `<table>` as a *pipe table* and reported success: the whole
11000        // element replaced by `| a | b |`, silently, on one press of a toolbar
11001        // button. Every grid op went the same way.
11002        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
11003        // A table of named operations, which is what it looks like.
11004        #[allow(clippy::type_complexity)]
11005        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
11006            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
11007            ("delete row", &|d: &mut Doc| d.table_delete_row()),
11008            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
11009            ("delete column", &|d: &mut Doc| d.table_delete_column()),
11010            ("align", &|d: &mut Doc| {
11011                d.table_set_alignment(Alignment::Right)
11012            }),
11013            ("move row", &|d: &mut Doc| d.table_move_row(true)),
11014            ("move column", &|d: &mut Doc| d.table_move_column(true)),
11015        ];
11016        for (name, op) in ops {
11017            let mut d = html_doc(src);
11018            d.caret = d.source.find('a').unwrap();
11019            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
11020            op(&mut d);
11021            assert_eq!(d.source, src, "{name} rewrote an HTML table");
11022            assert!(
11023                !d.dirty,
11024                "{name} marked the document dirty without editing it"
11025            );
11026            assert!(d.status.is_some(), "{name} refused without saying why");
11027        }
11028    }
11029
11030    #[test]
11031    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
11032        // A heading is a wrapping tag pair carrying its level in both ends, a
11033        // quote wraps a range rather than prefixing each line, a link's
11034        // destination lives in an attribute — different *shapes*, not a
11035        // different alphabet, so twig spells none of them and neither does leaf.
11036        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
11037        // A table of named operations, which is what it looks like.
11038        #[allow(clippy::type_complexity)]
11039        let ops: [(&str, &dyn Fn(&mut Doc)); 9] = [
11040            ("heading", &|d: &mut Doc| d.toggle_heading(2)),
11041            ("paragraph", &|d: &mut Doc| {
11042                d.set_block(BlockKind::Paragraph)
11043            }),
11044            ("quote", &|d: &mut Doc| d.toggle_blockquote()),
11045            ("list", &|d: &mut Doc| d.toggle_list(false)),
11046            ("task item", &|d: &mut Doc| d.toggle_task_item()),
11047            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
11048            ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
11049            ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
11050            ("video", &|d: &mut Doc| {
11051                d.insert_media(MediaKind::Video, "clip.mp4", "")
11052            }),
11053        ];
11054        for (name, op) in ops {
11055            let mut d = html_doc(src);
11056            let at = d.source.find("Hello").unwrap();
11057            d.caret = at;
11058            d.anchor = Some(at + 5); // a selection, for the ops that want one
11059            op(&mut d);
11060            assert_eq!(d.source, src, "{name} edited an HTML document");
11061            assert!(
11062                !d.dirty,
11063                "{name} marked the document dirty without editing it"
11064            );
11065            let status = d.status.as_deref().unwrap_or("");
11066            assert!(
11067                status.contains("html"),
11068                "{name}: the refusal should name the format, got {status:?}"
11069            );
11070        }
11071    }
11072
11073    #[test]
11074    fn html_spells_the_inline_marks_and_the_rule() {
11075        // The other half, and why one per-document flag stopped being enough:
11076        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
11077        // already emits and the parser reads straight back as the same mark —
11078        // and the rule button writes an `<hr>`. Refusing these on the old
11079        // "HTML is parse-only" reading would now be leaf's own limitation.
11080        let mut d = html_doc("<p>Hello world</p>\n");
11081        let at = d.source.find("world").unwrap();
11082        d.caret = at;
11083        d.anchor = Some(at + 5);
11084        d.toggle(InlineKind::Strong);
11085        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
11086        assert!(d.dirty);
11087        assert_eq!(d.status, None, "a supported gesture reports nothing");
11088
11089        // And off again — the toggle reverses, which is the property that makes
11090        // authoring in HTML worth offering rather than a one-way trip.
11091        d.toggle(InlineKind::Strong);
11092        assert_eq!(d.source, "<p>Hello world</p>\n");
11093
11094        let mut d = html_doc("<p>Hello world</p>\n");
11095        d.caret = d.source.find("world").unwrap();
11096        d.insert_thematic_break();
11097        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
11098    }
11099
11100    #[test]
11101    fn a_mark_the_format_cannot_spell_arms_nothing() {
11102        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
11103        // sticky mark for the next text typed. Guarding only the twig call
11104        // leaves that path live, promising a highlight Markdown will never spell
11105        // and then swallowing the error inside `insert`. Markdown carries the
11106        // case now that HTML spells `<mark>`: `==mark==` is djot's alone.
11107        let mut d = doc_with("mark", "Hello world\n");
11108        d.view = View::Wysiwyg;
11109        d.build_visual(80);
11110        d.caret = d.source.find("world").unwrap();
11111        d.toggle(InlineKind::Mark);
11112        assert!(d.pending_marks.is_empty(), "no mark should be armed");
11113        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
11114        d.insert("X");
11115        assert_eq!(d.source, "Hello Xworld\n");
11116    }
11117
11118    #[test]
11119    fn html_documents_still_take_typed_text() {
11120        // The guard covers *markup* gestures and must not touch plain editing:
11121        // twig's splicer is language-neutral, and typing into an HTML document
11122        // is the thing that does work today.
11123        let mut d = html_doc("<p>Hello world</p>\n");
11124        d.caret = d.source.find("world").unwrap();
11125        d.insert("big ");
11126        assert_eq!(d.source, "<p>Hello big world</p>\n");
11127        assert!(d.dirty);
11128        d.backspace();
11129        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
11130        d.undo();
11131        d.undo();
11132        assert_eq!(d.source, "<p>Hello world</p>\n");
11133    }
11134
11135    #[test]
11136    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
11137        // `authorable` only separates "there is a door in" from "there is not",
11138        // and HTML is on the near side of that line — which is exactly why a
11139        // toolbar must not be built from it.
11140        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
11141        assert!(html.authorable());
11142        assert!(
11143            !Doc::from_source("<r>x</r>".into(), Format::Xml)
11144                .unwrap()
11145                .authorable()
11146        );
11147
11148        let caps = html.capabilities();
11149        assert!(caps.bold && caps.italic && caps.code && caps.mark);
11150        assert!(caps.thematic_break && caps.cell_line_break);
11151        assert!(!caps.heading && !caps.blockquote && !caps.bullet_list);
11152        assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
11153        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
11154        // twig's table editor would happily re-emit as `| a | b |`.
11155        assert!(!caps.table);
11156
11157        // The two lightweight formats spell everything leaf offers — and still
11158        // differ from each other, which is the other half of why one boolean
11159        // can't serve.
11160        for fmt in [Format::Markdown, Format::Djot] {
11161            let caps = Capabilities::of(fmt);
11162            assert!(
11163                caps.heading && caps.blockquote && caps.ordered_list,
11164                "{fmt:?}"
11165            );
11166            assert!(
11167                caps.task && caps.link && caps.image && caps.table,
11168                "{fmt:?}"
11169            );
11170        }
11171        assert!(Capabilities::of(Format::Djot).mark);
11172        assert!(!Capabilities::of(Format::Markdown).mark);
11173        assert!(Capabilities::of(Format::Markdown).cell_line_break);
11174        assert!(!Capabilities::of(Format::Djot).cell_line_break);
11175
11176        // A parse-only format answers no to every one of them, so the coarse
11177        // predicate and the record agree there.
11178        let caps = Capabilities::of(Format::Xml);
11179        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
11180    }
11181
11182    #[test]
11183    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
11184        // The guard exists to name the *document's* format rather than twig's
11185        // internals, so the message has to survive being one leaf writes itself.
11186        // Checked against the gesture twig also refuses, since that is the pair
11187        // most at risk of drifting apart.
11188        let mut d = html_doc("<p>Hello</p>\n");
11189        d.caret = d.source.find("Hello").unwrap();
11190        d.set_code_language("zig");
11191        assert_eq!(
11192            d.status.as_deref(),
11193            Some("code language: not supported in html")
11194        );
11195        assert!(!d.dirty);
11196    }
11197
11198    #[test]
11199    fn table_set_alignment_respells_the_delimiter() {
11200        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11201        d.caret = d.source.find('b').unwrap();
11202        d.table_set_alignment(Alignment::Right);
11203        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
11204    }
11205
11206    #[test]
11207    fn each_empty_table_cell_has_its_own_editable_home() {
11208        // Regression: an empty cell has no twig content_span, so both cells of a
11209        // `|  |  |` row collapsed onto the row's start (before the first `│`).
11210        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
11211        // tell the cells apart. Each empty cell must now have a distinct home
11212        // inside it.
11213        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
11214        let (c0, c1) = {
11215            let cells = &d.vmap.tables[0].grid[1].cells;
11216            (cells[0].start, cells[1].start)
11217        };
11218        assert!(
11219            c0 < c1,
11220            "the two empty cells have distinct homes: {c0} < {c1}"
11221        );
11222        d.caret = c0;
11223        d.insert("x");
11224        assert_eq!(
11225            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
11226            "typed inside the cell"
11227        );
11228    }
11229
11230    #[test]
11231    fn arrows_step_into_each_empty_table_cell() {
11232        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
11233        let (c0, c1) = {
11234            let cells = &d.vmap.tables[0].grid[1].cells;
11235            (cells[0].start, cells[1].start)
11236        };
11237        d.caret = d.source.find('b').unwrap(); // in the header's second cell
11238        let mut seen = std::collections::HashSet::new();
11239        for _ in 0..6 {
11240            d.move_right(false);
11241            seen.insert(d.caret);
11242        }
11243        assert!(
11244            seen.contains(&c0),
11245            "right arrow reaches the first empty cell"
11246        );
11247        assert!(
11248            seen.contains(&c1),
11249            "right arrow reaches the second empty cell"
11250        );
11251    }
11252
11253    #[test]
11254    fn table_op_off_a_table_is_a_no_op_with_a_status() {
11255        let mut d = doc_with("tbl_none", "just text\n");
11256        d.caret = 3;
11257        d.table_insert_row(true);
11258        assert_eq!(d.source, "just text\n", "nothing changed");
11259        assert!(d.status.is_some(), "a status explains why");
11260        assert!(!d.caret_in_table());
11261    }
11262
11263    #[test]
11264    fn enter_in_an_ordered_list_renumbers_the_following_items() {
11265        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
11266        // the renumber pass keeps them sequential, matching what the view draws.
11267        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
11268        d.caret = d.source.find('a').unwrap() + 1; // end of item a
11269        d.newline();
11270        d.insert("x");
11271        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
11272    }
11273
11274    #[test]
11275    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
11276        for view in [View::Source, View::Wysiwyg] {
11277            let mut d = doc_in(view, "outdent_noop", "hello\n");
11278            d.caret = 2;
11279            d.outdent();
11280            assert_eq!(d.source, "hello\n");
11281            assert!(!d.dirty, "a no-op is not a modification");
11282            d.undo();
11283            assert_eq!(
11284                d.status.as_deref(),
11285                Some("nothing to undo"),
11286                "spends no undo step"
11287            );
11288            assert_eq!(d.source, "hello\n");
11289        }
11290    }
11291
11292    #[test]
11293    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
11294        for view in [View::Source, View::Wysiwyg] {
11295            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
11296            d.anchor = Some(0);
11297            d.caret = 7; // through "two"
11298            d.indent();
11299            assert_eq!(
11300                d.source, "  one\n\n  two\n",
11301                "the blank line keeps no trailing pad"
11302            );
11303            // Selected, so a second Tab lands on the same lines rather than on
11304            // whatever the shifted offsets now cover.
11305            assert_eq!(d.selection(), Some((0, 12)));
11306            d.indent();
11307            assert_eq!(d.source, "    one\n\n    two\n");
11308        }
11309    }
11310
11311    #[test]
11312    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
11313        for view in [View::Source, View::Wysiwyg] {
11314            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
11315            d.anchor = Some(0);
11316            d.caret = 15;
11317            d.outdent();
11318            assert_eq!(d.source, "two\none\nnone\n");
11319        }
11320    }
11321
11322    #[test]
11323    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
11324        for view in [View::Source, View::Wysiwyg] {
11325            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
11326            d.anchor = Some(0);
11327            d.caret = 7;
11328            d.indent();
11329            assert_eq!(d.source, "  one\n\n  two\n");
11330            d.undo();
11331            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
11332            assert_eq!(
11333                d.selection(),
11334                Some((0, 7)),
11335                "with the selection it was aimed at"
11336            );
11337            d.redo();
11338            assert_eq!(d.source, "  one\n\n  two\n");
11339            assert_eq!(
11340                d.selection(),
11341                Some((0, 12)),
11342                "redo replays the caret the indent placed, not the one splice left"
11343            );
11344        }
11345    }
11346
11347    #[test]
11348    fn vertical_motion_keeps_the_column() {
11349        let mut d = doc_with("move", "abcd\nef\n");
11350        d.caret = 3; // "abc|d" on row 0, col 3
11351        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
11352        assert_eq!(d.caret, 7); // just after "ef"
11353    }
11354
11355    // ── goal column ──────────────────────────────────────────────────────────
11356
11357    #[test]
11358    fn vertical_motion_goal_column_survives_a_short_line() {
11359        // Regression: re-deriving the column from the clamped position on
11360        // every step permanently forgets it once a short line clamps it.
11361        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
11362        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
11363        assert_eq!(
11364            g("abcd|ef\nxy\nghijkl\n", |d| {
11365                d.move_down(false); // clamps to end of "xy"
11366                d.move_down(false); // restores col 4 on the long line
11367            }),
11368            "abcdef\nxy\nghij|kl\n"
11369        );
11370    }
11371
11372    #[test]
11373    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
11374        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
11375        assert_eq!(d.goal_col, None);
11376        d.caret = 4; // row 0, col 4
11377        d.move_down(false); // clamps into "xy"; goal stays the original col
11378        assert_eq!(d.goal_col, Some(4));
11379        assert_eq!(d.caret_pos(), (1, 2));
11380
11381        // A horizontal motion drops the goal column...
11382        d.move_left(false);
11383        assert_eq!(d.goal_col, None);
11384
11385        // ...so the next vertical motion picks up the *new* column (1), not
11386        // the stale one (4).
11387        d.move_down(false);
11388        assert_eq!(d.goal_col, Some(1));
11389        assert_eq!(d.caret_pos(), (2, 1));
11390    }
11391
11392    #[test]
11393    fn editing_clears_the_goal_column() {
11394        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
11395        d.caret = 4;
11396        d.move_down(false);
11397        assert_eq!(d.goal_col, Some(4));
11398        d.insert("Z");
11399        assert_eq!(d.goal_col, None);
11400    }
11401
11402    #[test]
11403    fn vertical_motion_on_an_empty_document_is_a_no_op() {
11404        let mut d = doc_with("empty_vert", "");
11405        d.move_down(false);
11406        assert_eq!(d.caret, 0);
11407        d.move_up(false);
11408        assert_eq!(d.caret, 0);
11409    }
11410
11411    // ── the document's edges ─────────────────────────────────────────────────
11412
11413    #[test]
11414    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
11415        // The reproduction, and the disagreement: Down on the last line ran to
11416        // the end of the document in the source view — by accident, an
11417        // out-of-range row clamping to the end of the string — and did nothing
11418        // whatever in the view leaf opens in. One rule now, in both.
11419        for (view, tag) in VIEWS {
11420            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
11421            d.caret = 1;
11422            d.move_down(false);
11423            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
11424            d.move_up(false);
11425            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
11426        }
11427    }
11428
11429    #[test]
11430    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
11431        // Down off the bottom is a motion like any other, so it latches a goal
11432        // column — and Up comes back to the column the caret left, not to the
11433        // one the document's end happened to be in.
11434        for (view, tag) in VIEWS {
11435            let gap = if view == View::Source { "\n" } else { "\n\n" };
11436            let src = format!("abcdef{gap}ghijkl");
11437            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
11438            d.caret = 2; // row 0, col 2
11439            d.move_down(false);
11440            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
11441            d.move_down(false);
11442            assert_eq!(
11443                d.caret,
11444                src.len(),
11445                "{tag}: Down off the bottom reaches the end"
11446            );
11447            d.move_up(false);
11448            assert_eq!(
11449                d.caret_pos().1,
11450                2,
11451                "{tag}: Up returns to the column Down left"
11452            );
11453        }
11454    }
11455
11456    #[test]
11457    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
11458        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
11459        // Up that did nothing still armed a goal column, and the next Down aimed
11460        // at a column the caret had never been in.
11461        for (view, tag) in VIEWS {
11462            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
11463            d.caret = 0;
11464            d.move_up(false);
11465            assert_eq!(d.caret, 0, "{tag}: already at the start");
11466            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
11467
11468            d.caret = d.source.len();
11469            d.move_down(false);
11470            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
11471            assert_eq!(
11472                d.goal_col, None,
11473                "{tag}: a no-op Down latched a goal column"
11474            );
11475        }
11476    }
11477
11478    // ── soft wrap ────────────────────────────────────────────────────────────
11479    // Every other test here builds the map at 80 columns, where no fixture is
11480    // long enough to fold. A wrap is where one offset belongs to two rows at
11481    // once, and it broke everything that asks the caret what row it is on.
11482
11483    /// The wrapped fixture these cases share, folded at 12 columns into
11484    /// `one two ` / `three four ` / `five six ` / `seven eight`.
11485    fn wrapped_doc(name: &str) -> Doc {
11486        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
11487        d.build_visual(12);
11488        d
11489    }
11490
11491    #[test]
11492    fn home_and_end_work_from_a_wrapped_row() {
11493        // The reproduction: offset 19 is the `f` of "five", the first character
11494        // of the third row — and also the offset the second row ends at. It
11495        // resolved to the *second* row, so End aimed at a place the caret was
11496        // already in and did nothing, while Home walked backwards onto a row the
11497        // caret had left.
11498        let mut d = wrapped_doc("wrap_home_end");
11499        d.caret = 19;
11500        assert_eq!(
11501            d.caret_pos(),
11502            (2, 0),
11503            "the wrap boundary opens the third row"
11504        );
11505        d.move_end(false);
11506        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
11507        d.move_home(false);
11508        assert_eq!(d.caret, 19, "Home left the row the caret was on");
11509    }
11510
11511    #[test]
11512    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
11513        // The row's end is the last offset that is only ever its own: the offset
11514        // past it opens the row below, and aiming there would send a second
11515        // press on to *that* row's end, and a third to the next — End walking
11516        // down the paragraph rather than sitting where it landed.
11517        let mut d = wrapped_doc("wrap_end_twice");
11518        d.caret = 12; // inside "three", on the second row
11519        d.move_end(false);
11520        assert_eq!(
11521            d.caret, 18,
11522            "the end of `three four`, before the space the wrap ate"
11523        );
11524        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
11525        d.move_end(false);
11526        assert_eq!(d.caret, 18, "a second End moved the caret");
11527        d.move_home(false);
11528        assert_eq!(d.caret, 8, "Home takes the row's own start");
11529    }
11530
11531    #[test]
11532    fn vertical_motion_crosses_a_soft_wrap() {
11533        // Down aimed at the row below's column 0, an offset that resolved *up*
11534        // to the row above's end — so it landed on the offset it already had and
11535        // the caret could never leave a paragraph's first row.
11536        let mut d = wrapped_doc("wrap_down");
11537        d.caret = 0;
11538        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
11539            d.move_down(false);
11540            assert_eq!(d.caret, want, "Down stalled");
11541            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
11542        }
11543        d.move_down(false);
11544        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
11545
11546        // ...and back up, one row per press. The goal column is the end of the
11547        // last row, past every other row's width, so each press clamps to the
11548        // row's own last offset rather than to the one that opens the next.
11549        let mut d = wrapped_doc("wrap_up");
11550        d.caret = 39;
11551        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
11552            d.move_up(false);
11553            assert_eq!(d.caret, want, "Up stalled");
11554            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
11555        }
11556    }
11557
11558    #[test]
11559    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
11560        // The kills take the same line Home and End do, so in WYSIWYG they take
11561        // the visual row — and a soft wrap has no newline in it to delete, so
11562        // nothing is joined by reaching the end of one.
11563        let mut d = wrapped_doc("wrap_kill");
11564        d.caret = 19; // the `f` of "five", opening the third row
11565        d.delete_to_line_end();
11566        // The space the wrap ate goes with the row it was drawn on: sparing it
11567        // would leave "four  seven", two spaces where the row had been.
11568        assert_eq!(d.source, "one two three four seven eight");
11569
11570        // Backwards from the row's last caret position — which is *before* that
11571        // space, so this one survives, being on the far side of the caret.
11572        let mut d = wrapped_doc("wrap_kill_back");
11573        d.caret = 27;
11574        d.delete_to_line_start();
11575        assert_eq!(d.source, "one two three four  seven eight");
11576    }
11577
11578    // ── document start / end ────────────────────────────────────────────────
11579
11580    #[test]
11581    fn move_doc_start_and_end_jump_to_the_edges() {
11582        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
11583        assert_eq!(
11584            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
11585            "|hello\nworld\n"
11586        );
11587        assert_eq!(
11588            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
11589            "hello\nworld\n|"
11590        );
11591        // Already at the edge: a no-op.
11592        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
11593        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
11594    }
11595
11596    #[test]
11597    fn move_doc_start_and_end_extend_the_selection() {
11598        assert_eq!(
11599            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
11600                .move_doc_end(true)),
11601            "hello wor[ld\n|]"
11602        );
11603        assert_eq!(
11604            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
11605                .move_doc_start(true)),
11606            "[|hello wor]ld\n"
11607        );
11608    }
11609
11610    #[test]
11611    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
11612        let mut d = doc_with("empty_edges", "");
11613        d.move_doc_end(false);
11614        assert_eq!(d.caret, 0);
11615        d.move_doc_start(false);
11616        assert_eq!(d.caret, 0);
11617    }
11618
11619    // ── arrow collapses an active selection ─────────────────────────────────
11620
11621    #[test]
11622    fn arrow_collapses_selection_to_its_near_edge() {
11623        let mut d = doc_with("collapse", "hello world\n");
11624
11625        // Forward selection (anchor before caret): Right -> end, Left -> start.
11626        d.anchor = Some(2);
11627        d.caret = 7;
11628        d.move_right(false);
11629        assert_eq!((d.caret, d.anchor), (7, None));
11630
11631        d.anchor = Some(2);
11632        d.caret = 7;
11633        d.move_left(false);
11634        assert_eq!((d.caret, d.anchor), (2, None));
11635
11636        // Backward selection (anchor after caret): edges are the same
11637        // regardless of which end the caret started on.
11638        d.anchor = Some(7);
11639        d.caret = 2;
11640        d.move_right(false);
11641        assert_eq!((d.caret, d.anchor), (7, None));
11642
11643        d.anchor = Some(7);
11644        d.caret = 2;
11645        d.move_left(false);
11646        assert_eq!((d.caret, d.anchor), (2, None));
11647    }
11648
11649    #[test]
11650    fn arrow_with_extend_keeps_growing_the_selection() {
11651        let mut d = doc_with("collapse_extend", "hello world\n");
11652        d.anchor = Some(2);
11653        d.caret = 7;
11654        d.move_right(true); // extend: no collapse, caret steps one further
11655        assert_eq!((d.caret, d.anchor), (8, Some(2)));
11656    }
11657
11658    #[test]
11659    fn arrow_without_a_selection_moves_one_character_as_before() {
11660        let mut d = doc_with("no_collapse", "hello\n");
11661        d.caret = 2;
11662        d.move_right(false);
11663        assert_eq!(d.caret, 3);
11664        d.move_left(false);
11665        assert_eq!(d.caret, 2);
11666    }
11667
11668    /// Press Right until it stops, collecting the offsets walked through. Every
11669    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
11670    /// two stops sharing one source offset can't be moved between, so the caret
11671    /// stalls on the first of them and the walk never reaches the rest.
11672    fn walk_right(d: &mut Doc) -> Vec<usize> {
11673        let mut seen = vec![d.caret];
11674        for _ in 0..2000 {
11675            let before = d.caret;
11676            d.move_right(false);
11677            if d.caret == before {
11678                break;
11679            }
11680            seen.push(d.caret);
11681        }
11682        seen
11683    }
11684
11685    #[test]
11686    fn the_caret_crosses_a_soft_break() {
11687        // A newline inside a paragraph is a `soft_break`, which twig gives no
11688        // span of its own — the space it renders as used to borrow the offset of
11689        // the character before it, and a caret can't move without changing
11690        // offset. Right must walk clean off the end of the first line.
11691        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
11692        d.caret = 0;
11693        let seen = walk_right(&mut d);
11694        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11695    }
11696
11697    #[test]
11698    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
11699        // The paragraph holds one soft break. Folded (the default) it lays out as
11700        // a single reflowed row; Preserve re-lays it as a row per source line.
11701        // The setter must invalidate the cached map for the change to show, and
11702        // again on the way back — so a round trip returns to the folded layout.
11703        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
11704        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
11705        d.build_visual(80);
11706        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
11707
11708        d.set_line_flow(LineFlow::Preserve);
11709        d.build_visual(80);
11710        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
11711
11712        d.set_line_flow(LineFlow::Fold);
11713        d.build_visual(80);
11714        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
11715    }
11716
11717    #[test]
11718    fn the_caret_still_crosses_a_preserved_soft_break() {
11719        // Preserve renders the soft break as a row boundary rather than a space,
11720        // but the caret must still reach every offset — the break's own offset is
11721        // the first row's end stop, so Right walks clean off the end of line one
11722        // onto line two, exactly as it does when the break is folded.
11723        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
11724        d.set_line_flow(LineFlow::Preserve);
11725        d.build_visual(80);
11726        d.caret = 0;
11727        let seen = walk_right(&mut d);
11728        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11729    }
11730
11731    #[test]
11732    fn the_caret_walks_a_code_block() {
11733        // Every glyph of a code block used to map to the block's start, so the
11734        // whole block was a single offset and the caret couldn't move inside it.
11735        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
11736        let mut d = wysiwyg_doc("code_walk", src);
11737        d.caret = 0;
11738        let seen = walk_right(&mut d);
11739        // The fences are markup: hidden, and no caret stop. The code between
11740        // them is reached a character at a time.
11741        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
11742        for off in code.clone() {
11743            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
11744        }
11745        assert!(seen.contains(&code.end), "no stop after the last line");
11746    }
11747
11748    #[test]
11749    fn the_caret_walks_an_indented_code_block() {
11750        // An indented block's text has the four-space indent stripped, so it
11751        // isn't a verbatim slice and its lines have to be re-found. The caret
11752        // lands on the code, never in the indent.
11753        let src = "    indented\n    code\n";
11754        let mut d = wysiwyg_doc("indent_code_walk", src);
11755        d.caret = 0;
11756        let seen = walk_right(&mut d);
11757        assert!(seen.contains(&src.find("indented").unwrap()));
11758        assert!(seen.contains(&src.find("code").unwrap()));
11759        assert!(
11760            !seen.contains(&0) || seen[0] == 0,
11761            "the caret starts where it was put"
11762        );
11763        // Nothing in the stripped indent is a stop.
11764        for off in [1, 2, 3] {
11765            assert!(!seen.contains(&off), "landed in the indent at {off}");
11766        }
11767    }
11768
11769    #[test]
11770    fn the_caret_leaves_a_tight_heading() {
11771        // "# H" with text directly under it: the heading row's end and the
11772        // separator row's end are the same offset. Right used to find the
11773        // separator's copy, set the caret to where it already was, and stop.
11774        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
11775        d.caret = 2; // the "H"
11776        let seen = walk_right(&mut d);
11777        assert!(
11778            seen.len() > 2,
11779            "Right stalled at the heading's end: {seen:?}"
11780        );
11781        assert!(
11782            seen.contains(&8),
11783            "never reached the end of \"text\": {seen:?}"
11784        );
11785    }
11786
11787    #[test]
11788    fn the_caret_skips_the_gap_between_two_paragraphs() {
11789        // The blank line between two paragraphs is the boundary itself. The
11790        // caret used to be able to sit on it, and typing there landed in the
11791        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
11792        // soft break, so the text visibly snapped back up.
11793        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
11794        d.caret = 1; // the end of "A"
11795        d.move_right(false);
11796        assert_eq!(d.caret, 3, "Right stopped in the gap");
11797        d.insert("x");
11798        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
11799    }
11800
11801    #[test]
11802    fn down_from_a_paragraph_lands_on_the_next_one() {
11803        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
11804        d.caret = 0;
11805        d.move_down(false);
11806        assert_eq!(d.caret, 3, "Down stopped in the gap");
11807    }
11808
11809    #[test]
11810    fn clicking_the_gap_lands_on_real_text() {
11811        // A click can still *reach* the gap — it's drawn, so it's clickable.
11812        // It has to resolve to somewhere the caret can be.
11813        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
11814        d.click(1, 0, false); // the gap row
11815        assert!(
11816            d.caret == 1 || d.caret == 3,
11817            "click left the caret in the gap at {}",
11818            d.caret
11819        );
11820        d.insert("x");
11821        // Either edge of the boundary is a fair place to land; inside it isn't.
11822        assert!(
11823            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
11824            "click in the gap typed into the boundary: {:?}",
11825            d.source
11826        );
11827    }
11828
11829    #[test]
11830    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
11831        // Enter inserts a paragraph break, which leaves a blank line spare on
11832        // either side of a new one. That middle line is a real empty paragraph:
11833        // the caret lands there, and typing makes a paragraph rather than
11834        // extending a neighbour.
11835        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
11836        d.caret = 1;
11837        d.newline();
11838        assert_eq!(d.source, "A\n\n\n\nB\n");
11839        d.build_visual(80);
11840        let (row, _) = d.caret_pos();
11841        assert!(
11842            d.vmap.row_is_navigable(row),
11843            "the caret landed on a gap row"
11844        );
11845        d.insert("x");
11846        assert_eq!(
11847            d.source, "A\n\nx\n\nB\n",
11848            "the new paragraph merged into a neighbour"
11849        );
11850    }
11851
11852    #[test]
11853    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
11854        let mut d = wysiwyg_doc("gap_eof", "A\n");
11855        d.caret = 1;
11856        d.newline();
11857        d.build_visual(80);
11858        let (row, _) = d.caret_pos();
11859        assert!(
11860            d.vmap.row_is_navigable(row),
11861            "the caret landed on a gap row"
11862        );
11863        d.insert("x");
11864        assert!(
11865            d.source.starts_with("A\n\n") && d.source.contains('x'),
11866            "typing at the end merged into A: {:?}",
11867            d.source
11868        );
11869    }
11870
11871    #[test]
11872    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
11873        // A paragraph broken over two source lines is one paragraph. Selecting
11874        // it must not stop at the newline inside it — that newline is markup the
11875        // rich-text view exists to hide.
11876        let src = "one two\nthree four\n\nnext\n";
11877        let mut d = wysiwyg_doc("triple_para", src);
11878        d.select_block_at(2);
11879        assert_eq!(
11880            d.selected_text(),
11881            Some("one two\nthree four"),
11882            "stopped at the soft break"
11883        );
11884    }
11885
11886    #[test]
11887    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
11888        // The reader scrolls down past the caret's row. Nothing moved the
11889        // caret, so the view must stay where it was put — the old code revealed
11890        // the caret every frame, which dragged the view straight back and made
11891        // the document unscrollable past the caret.
11892        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
11893        d.caret = 0;
11894        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
11895        d.scroll = 4; // the wheel
11896        d.follow_caret(0, 3, 9);
11897        assert_eq!(
11898            d.scroll, 4,
11899            "the wheel was overruled by a caret that never moved"
11900        );
11901    }
11902
11903    #[test]
11904    fn moving_the_caret_brings_the_view_back_to_it() {
11905        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
11906        d.caret = 0;
11907        d.follow_caret(0, 3, 9);
11908        d.scroll = 6; // scrolled away
11909        d.move_right(false); // ...and now the caret moves
11910        let (row, _) = d.caret_pos();
11911        d.follow_caret(row, 3, 9);
11912        assert!(
11913            d.scroll <= row && row < d.scroll + 3,
11914            "caret row {row} off screen at scroll {}",
11915            d.scroll
11916        );
11917    }
11918
11919    #[test]
11920    fn scrolling_stops_at_the_last_row() {
11921        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
11922        d.caret = 0;
11923        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
11924        d.scroll = 999; // the wheel, spun hard
11925        d.follow_caret(0, 3, 3);
11926        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
11927    }
11928
11929    #[test]
11930    fn every_cell_of_a_wide_table_is_reachable() {
11931        // A table whose cells are far wider than the surface: the columns are
11932        // cut to fit and the text wraps inside them, so no cell hangs off the
11933        // right edge where the caret can never go.
11934        let src = "| Ingredient | Notes |\n|---|---|\n\
11935                   | flour milled coarse | sift it twice before folding it in |\n";
11936        let mut d = wysiwyg_doc("wide_table_walk", src);
11937        d.build_visual(30);
11938        d.caret = 0;
11939        let seen = walk_right(&mut d);
11940        for word in ["Ingredient", "Notes", "coarse", "folding"] {
11941            let at = src.find(word).unwrap();
11942            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
11943        }
11944    }
11945
11946    // ── view parity ──────────────────────────────────────────────────────────
11947    // `doc_with` pins the source view, so everything above tests a view users
11948    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
11949    // deletion golden cases through *both*, plus the WYSIWYG cases the two
11950    // can't share: where the source carries markup the rendered text is a
11951    // different string, and the views agreeing would itself be the bug.
11952
11953    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
11954
11955    /// Run `action` in both views on one `|`-marked fixture and assert they
11956    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
11957    /// source verbatim, so the two views are looking at the same text and any
11958    /// disagreement is one of them having lost the plot.
11959    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
11960        let (src, caret) = parse_caret(marked);
11961        let run = |view: View, tag: &str| {
11962            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
11963            d.caret = caret;
11964            action(&mut d);
11965            render_caret(&d)
11966        };
11967        let source = run(VIEWS[0].0, VIEWS[0].1);
11968        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
11969        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
11970        source
11971    }
11972
11973    #[test]
11974    fn word_motion_agrees_across_the_views_on_plain_prose() {
11975        let g = both_views;
11976        assert_eq!(
11977            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
11978            "hello |world"
11979        );
11980        assert_eq!(
11981            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
11982            "|hello world"
11983        );
11984        assert_eq!(
11985            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
11986            "hello| world"
11987        );
11988        assert_eq!(
11989            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
11990            "hello world|"
11991        );
11992        assert_eq!(
11993            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
11994            "foo|.bar"
11995        );
11996        assert_eq!(
11997            g("par_ext", "hello |world", |d| d.move_word_right(true)),
11998            "hello [world|]"
11999        );
12000    }
12001
12002    #[test]
12003    fn word_deletion_agrees_across_the_views_on_plain_prose() {
12004        let g = both_views;
12005        assert_eq!(
12006            g("par_db", "hello world|", |d| d.delete_word_back()),
12007            "hello |"
12008        );
12009        assert_eq!(
12010            g("par_df", "hello |world", |d| d.delete_word_forward()),
12011            "hello |"
12012        );
12013        assert_eq!(
12014            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
12015            "|bar baz"
12016        );
12017        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
12018    }
12019
12020    #[test]
12021    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
12022        let g = both_views;
12023        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
12024        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
12025        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
12026        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
12027    }
12028
12029    #[test]
12030    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
12031        // The reproduction: the stop table was built one stop per `char`, so
12032        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
12033        // source view, which steps by grapheme, can't reach and backspace can't
12034        // survive. The two views must land on the same offset.
12035        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
12036        for (view, tag) in VIEWS {
12037            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
12038            d.caret = 1;
12039            d.move_right(false);
12040            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
12041
12042            // ...and the edit that used to sever a joiner off the front of it.
12043            d.backspace();
12044            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
12045            assert_eq!(d.caret, 1);
12046        }
12047    }
12048
12049    #[test]
12050    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
12051        for (view, tag) in VIEWS {
12052            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
12053            d.caret = 0;
12054            d.move_right(false);
12055            assert_eq!(
12056                d.caret,
12057                "e\u{0301}".len(),
12058                "{tag} stopped on the combining mark"
12059            );
12060        }
12061    }
12062
12063    #[test]
12064    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
12065        // The general form: whatever route the caret takes through a document
12066        // full of clusters, it never lands between the codepoints of one — so no
12067        // motion-then-backspace sequence can leave a dangling joiner behind.
12068        use unicode_segmentation::UnicodeSegmentation;
12069
12070        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
12071        let mut d = wysiwyg_doc("cluster_walk", src);
12072        d.caret = 0;
12073        let boundaries: Vec<usize> = src
12074            .grapheme_indices(true)
12075            .map(|(i, _)| i)
12076            .chain(std::iter::once(src.len()))
12077            .collect();
12078        for off in walk_right(&mut d) {
12079            assert!(
12080                boundaries.contains(&off),
12081                "Right stopped at {off}, inside a grapheme cluster"
12082            );
12083        }
12084    }
12085
12086    #[test]
12087    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
12088        // The reproduction: ⌥→ from inside the opening `**` computed its
12089        // boundary over the raw source and landed on byte 8 — inside the
12090        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
12091        // "bold". The caret drew past the bold word and sat inside it.
12092        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
12093        d.caret = 2;
12094        d.move_word_right(false);
12095        assert!(
12096            d.vmap.is_stop(d.caret),
12097            "landed at {}, not a caret stop",
12098            d.caret
12099        );
12100        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
12101        // The rendered row is "a bold c": column 6 is the space just past "bold",
12102        // and now the caret is really there rather than only drawn there.
12103        assert_eq!(d.caret_pos(), (0, 6));
12104
12105        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
12106        d.move_word_left(false);
12107        assert_eq!(d.caret, 4);
12108        assert_eq!(d.caret_pos(), (0, 2));
12109    }
12110
12111    #[test]
12112    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
12113        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
12114        // inside the closing `**`, and left "a ** c\n" — delimiters with no
12115        // opener. Glyph space covers the word alone, which would leave
12116        // "a **** c": markup wrapped around nothing. The word and the styling
12117        // that was only ever the word's go together.
12118        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
12119        d.caret = 10;
12120        d.delete_word_back();
12121        assert_eq!(d.source, "a  c\n");
12122        assert_eq!(d.caret, 2);
12123
12124        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
12125        d.caret = 4; // the "b"
12126        d.delete_word_forward();
12127        assert_eq!(d.source, "a  c\n");
12128    }
12129
12130    #[test]
12131    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
12132        let src = "a ***bold*** c\n";
12133        let mut d = wysiwyg_doc("wys_word_del_nest", src);
12134        d.caret = src.find(" c").unwrap();
12135        d.delete_word_back();
12136        assert_eq!(
12137            d.source, "a  c\n",
12138            "the emph inside the strong empties it too"
12139        );
12140
12141        let src = "a `code` c\n";
12142        let mut d = wysiwyg_doc("wys_word_del_code", src);
12143        d.caret = src.find(" c").unwrap();
12144        d.delete_word_back();
12145        assert_eq!(d.source, "a  c\n");
12146    }
12147
12148    #[test]
12149    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
12150        // Only an *emptied* node goes. Take one word of two and the `**` still
12151        // has a job to do — over the word that's left, with the space the delete
12152        // pushed against the opening delimiter moved out in front of it, or the
12153        // run would be no run at all (`** words**` is literal asterisks — see
12154        // the mark-edge rule on `splice`).
12155        let src = "a **two words** c\n";
12156        let mut d = wysiwyg_doc("wys_word_del_partial", src);
12157        d.caret = src.find(" words").unwrap();
12158        d.delete_word_back();
12159        assert_eq!(d.source, "a  **words** c\n");
12160    }
12161
12162    #[test]
12163    fn source_view_word_motion_still_walks_the_markup() {
12164        // The other half of the decision: in the source view the `**` are
12165        // characters like any other — they're on the screen, so word motion has
12166        // to stop at them and a word-delete has to leave them behind. Only
12167        // WYSIWYG hides them, so only WYSIWYG steps over them.
12168        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
12169        assert_eq!(
12170            g("src_word_motion", "a |**bold** c\n", |d| d
12171                .move_word_right(false)),
12172            "a **bold|** c\n"
12173        );
12174        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
12175        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
12176        assert_eq!(
12177            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
12178            "a **| c\n"
12179        );
12180    }
12181
12182    #[test]
12183    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
12184        // The single invariant both bugs violated: the caret draws and edits at
12185        // the same place only when it's on a stop. `debug_assert_on_a_stop`
12186        // makes the same claim in-place; this pins it from the outside, over a
12187        // document with every kind of thing the map has to be careful about.
12188        // At two widths: the wide one every other test builds at, where no
12189        // fixture folds, and one narrow enough that they all do. A soft wrap is
12190        // where an offset stops being on exactly one row, and testing only the
12191        // width that never wraps is how the caret came to be pinned at the first
12192        // one Down reached.
12193        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
12194                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
12195        // A table of named operations, which is what it looks like.
12196        #[allow(clippy::type_complexity)]
12197        let motions: [(&str, fn(&mut Doc)); 8] = [
12198            ("right", |d| d.move_right(false)),
12199            ("left", |d| d.move_left(false)),
12200            ("word_right", |d| d.move_word_right(false)),
12201            ("word_left", |d| d.move_word_left(false)),
12202            ("down", |d| d.move_down(false)),
12203            ("up", |d| d.move_up(false)),
12204            ("home", |d| d.move_home(false)),
12205            ("end", |d| d.move_end(false)),
12206        ];
12207        for width in [80, 12] {
12208            let mut d = wysiwyg_doc("stop_invariant", src);
12209            d.build_visual(width);
12210            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12211            assert!(stops.len() > 20, "fixture should have plenty of stops");
12212            for start in stops {
12213                for (name, motion) in &motions {
12214                    d.caret = start;
12215                    d.anchor = None;
12216                    motion(&mut d);
12217                    assert!(
12218                        d.vmap.is_stop(d.caret),
12219                        "{name} from {start} at width {width} landed at {} — not a caret stop",
12220                        d.caret
12221                    );
12222                }
12223            }
12224        }
12225    }
12226
12227    #[test]
12228    fn no_wysiwyg_motion_is_a_dead_end() {
12229        // Down held to the bottom of a document reaches the bottom, and Up held
12230        // to the top reaches the top — from anywhere, at a width that wraps. The
12231        // invariant above says a motion lands somewhere legal; this one says it
12232        // gets somewhere at all, which is what a caret pinned at a wrap boundary
12233        // was quietly failing to do while every assertion around it held.
12234        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
12235                   - item one two three four five\n\nlast\n";
12236        for width in [80, 12] {
12237            let mut d = wysiwyg_doc("no_dead_end", src);
12238            d.build_visual(width);
12239            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12240            let (first, last) = (stops[0], stops[stops.len() - 1]);
12241            for &start in &stops {
12242                for (name, motion, want) in [
12243                    (
12244                        "down",
12245                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
12246                        last,
12247                    ),
12248                    ("up", |d: &mut Doc| d.move_up(false), first),
12249                ] {
12250                    d.caret = start;
12251                    d.anchor = None;
12252                    d.goal_col = None;
12253                    // Every row, plus the presses the edges take, plus slack.
12254                    for _ in 0..d.vmap.num_rows() + 4 {
12255                        motion(&mut d);
12256                    }
12257                    assert_eq!(
12258                        d.caret, want,
12259                        "{name} held from {start} at width {width} never arrived"
12260                    );
12261                }
12262            }
12263        }
12264    }
12265    // ── display columns ──────────────────────────────────────────────────────
12266    // A `col` is a terminal cell, not a character. The two are the same number
12267    // for the ASCII the fixtures above are written in, which is how they came
12268    // apart in the first place: `你` is one character drawn in two cells, so a
12269    // column counted in characters names a cell the text isn't in — one earlier
12270    // for every wide character to its left.
12271
12272    #[test]
12273    fn a_wide_character_is_two_columns_wide() {
12274        // The reproduction: `你` is one char and two cells, so the caret just
12275        // past it drew at column 1 — inside the character it had already left.
12276        for (view, tag) in VIEWS {
12277            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
12278            d.caret = "你".len();
12279            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
12280            d.caret = "你好".len();
12281            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
12282        }
12283    }
12284
12285    #[test]
12286    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
12287        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
12288        // two-cell — measuring six cells one at a time, but the character they
12289        // spell is drawn in two. Width belongs to the cluster, not the glyph,
12290        // and the frontends measure it the same way.
12291        let family = "👨‍👩‍👧";
12292        for (view, tag) in VIEWS {
12293            let src = format!("a{family}b\n");
12294            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
12295            d.caret = 1 + family.len();
12296            assert_eq!(
12297                d.caret_pos(),
12298                (0, 3),
12299                "{tag}: 'a' is one cell, the family two"
12300            );
12301        }
12302    }
12303
12304    #[test]
12305    fn both_cells_of_a_wide_character_mean_the_character() {
12306        // Clicking the far half of `好` is still clicking `好`: half a character
12307        // is not a place the caret can be, so it comes to rest at the
12308        // character's start — the column it would have been drawn at anyway.
12309        for (view, tag) in VIEWS {
12310            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
12311            for col in [2, 3] {
12312                d.caret = 0;
12313                d.click(0, col, false);
12314                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
12315                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
12316            }
12317            // Past the last cell is the line's end, as it is for ASCII.
12318            d.click(0, 9, false);
12319            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
12320        }
12321    }
12322
12323    #[test]
12324    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
12325        // The mapping is only a mapping if it inverts: the cell the caret is
12326        // drawn in has to be the cell that brings it back to the same offset.
12327        // Over a fixture where a character may be one cell or two, and one
12328        // codepoint or five.
12329        use unicode_segmentation::UnicodeSegmentation;
12330
12331        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
12332
12333        let mut d = doc_in(View::Source, "roundtrip_source", src);
12334        // Every offset the source view's caret can occupy: it steps by grapheme
12335        // cluster, so those are its boundaries.
12336        for (off, _) in src
12337            .grapheme_indices(true)
12338            .chain(std::iter::once((src.len(), "")))
12339        {
12340            d.caret = off;
12341            let (row, col) = d.caret_pos();
12342            d.click(row, col, false);
12343            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
12344        }
12345
12346        // And in WYSIWYG, where the offsets the caret can occupy are the map's
12347        // stops rather than every boundary.
12348        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
12349        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12350        assert!(stops.len() > 20, "fixture should have plenty of stops");
12351        for off in stops {
12352            d.caret = off;
12353            let (row, col) = d.caret_pos();
12354            d.click(row, col, false);
12355            assert_eq!(
12356                d.caret, off,
12357                "wysiwyg: {off} → ({row}, {col}) → {}",
12358                d.caret
12359            );
12360        }
12361    }
12362
12363    #[test]
12364    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
12365        // Down from under `世` lands under the glyph in that cell, not two
12366        // characters further along the line. The goal is a column, so a line of
12367        // wide characters and a line of ASCII line up the way they're drawn.
12368        //
12369        // The gap differs by view: a bare newline inside a paragraph is a soft
12370        // break, which WYSIWYG draws as a space on a single row. The views share
12371        // a grid only where the source's lines are the renderer's rows too.
12372        for (view, tag) in VIEWS {
12373            let gap = if view == View::Source { "\n" } else { "\n\n" };
12374            let src = format!("你好世{gap}abcdef\n");
12375            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
12376            d.caret = "你好".len();
12377            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
12378            d.move_down(false);
12379            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
12380            assert!(
12381                d.source[d.caret..].starts_with('e'),
12382                "{tag}: landed on the wrong glyph"
12383            );
12384        }
12385    }
12386
12387    #[test]
12388    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
12389        // Down from column 3 onto `你好`, whose characters start at columns 0
12390        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
12391        // between the cells of one character, so the caret rests on it — and on
12392        // its start, which is the only offset there that is a caret stop.
12393        for (view, tag) in VIEWS {
12394            let gap = if view == View::Source { "\n" } else { "\n\n" };
12395            let src = format!("abcdef{gap}你好\n");
12396            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
12397            let line = src.find('你').unwrap();
12398            d.caret = 3;
12399            d.move_down(false);
12400            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
12401            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
12402        }
12403    }
12404
12405    #[test]
12406    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
12407        // The column the cell's text is laid out in is measured in cells, so the
12408        // caret walking that text has to be too — the two agreeing is the whole
12409        // point of the grid staying square.
12410        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
12411        let at = d.source.find("你").unwrap();
12412        d.caret = at;
12413        let (row, col) = d.caret_pos();
12414        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
12415        // cells further along.
12416        assert_eq!(col, 2, "the cell's first character");
12417        d.move_right(false);
12418        assert_eq!(
12419            d.caret_pos(),
12420            (row, 4),
12421            "`好` is drawn past `你`'s two cells"
12422        );
12423        assert_eq!(d.caret, at + "你".len());
12424    }
12425
12426    // ── active inline marks ───────────────────────────────────────────────────
12427
12428    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
12429    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
12430        let (src, caret) = parse_caret(marked);
12431        let mut d = doc_in(view, name, &src);
12432        d.caret = caret;
12433        d.active_inline_marks().iter().collect()
12434    }
12435
12436    /// The marks over the selection `[start, end)`.
12437    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
12438        let mut d = doc_in(view, name, src);
12439        d.anchor = Some(start);
12440        d.caret = end;
12441        d.active_inline_marks().iter().collect()
12442    }
12443
12444    #[test]
12445    fn a_caret_in_a_mark_reports_it() {
12446        for (view, tag) in VIEWS {
12447            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
12448            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
12449            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
12450            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
12451            // Plain text under no mark lights nothing — the toolbar's resting state.
12452            assert_eq!(m("a| **bold** b"), [], "{tag}");
12453            assert!(m("plain t|ext").is_empty(), "{tag}");
12454        }
12455    }
12456
12457    #[test]
12458    fn nested_marks_all_report() {
12459        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
12460        // the ancestor chain is a chain, and every mark on it is in force.
12461        for (view, tag) in VIEWS {
12462            assert_eq!(
12463                marks(
12464                    view,
12465                    &format!("marks_nested_{tag}"),
12466                    "**bold and *bo|th*** end"
12467                ),
12468                [InlineKind::Strong, InlineKind::Emph],
12469                "{tag}"
12470            );
12471        }
12472    }
12473
12474    #[test]
12475    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
12476        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
12477        // the first byte of its text and the byte after its last — both inside
12478        // the mark's span, both places typing lands inside the bold. The offset
12479        // past the closing delimiter is the next text, and reports nothing.
12480        let src = "a **bold** b";
12481        let inner_start = src.find("bold").unwrap(); // 4
12482        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
12483        for (view, tag) in VIEWS {
12484            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
12485            for off in [2, 3, inner_start, inner_end, 9] {
12486                d.caret = off;
12487                assert!(
12488                    d.active_inline_marks().contains(InlineKind::Strong),
12489                    "{tag}: offset {off} is inside the strong span"
12490                );
12491            }
12492            for off in [0, 1, 10, 11, 12] {
12493                d.caret = off;
12494                assert!(
12495                    !d.active_inline_marks().contains(InlineKind::Strong),
12496                    "{tag}: offset {off} is outside the strong run"
12497                );
12498            }
12499        }
12500    }
12501
12502    #[test]
12503    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
12504        // Regression: twig resolves an offset that is one node's end and the
12505        // next one's start to the node that *starts* there, so `**bold**|\n`
12506        // isn't bold. With nothing following there's no tie to break and the
12507        // chain still ended at the mark, which made a trailing `\n` — not the
12508        // text — decide whether the caret after a bold word reported bold. It's
12509        // the offset past the mark either way, and typing there is plain either
12510        // way. A blank document typed into is exactly this shape.
12511        for (view, tag) in VIEWS {
12512            let m = |name: String, marked| marks(view, &name, marked);
12513            assert_eq!(
12514                m(format!("marks_eob_{tag}"), "**bold**|"),
12515                [],
12516                "{tag}: no trailing newline"
12517            );
12518            assert_eq!(
12519                m(format!("marks_eol_{tag}"), "**bold**|\n"),
12520                [],
12521                "{tag}: with one"
12522            );
12523            // And the last offset that *is* in the mark still is.
12524            assert_eq!(
12525                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
12526                [InlineKind::Strong],
12527                "{tag}"
12528            );
12529        }
12530    }
12531
12532    #[test]
12533    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
12534        let src = "a **bold** b";
12535        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
12536        for (view, tag) in VIEWS {
12537            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
12538            // The whole bold word, and a slice of it.
12539            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
12540            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
12541            // Ending exactly at the closing delimiter's start is still all-bold:
12542            // an exclusive end sits *past* the last selected character, so the
12543            // question is asked of the character, not the boundary.
12544            assert_eq!(
12545                m(b, d_ + 2),
12546                [InlineKind::Strong],
12547                "{tag}: through the close"
12548            );
12549            // Half in, half out: Bold lit here would claim a press turns it off.
12550            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
12551            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
12552        }
12553    }
12554
12555    #[test]
12556    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
12557        // Both ends are bold, but the space between them isn't — two runs are two
12558        // nodes, which is exactly what the node id catches and a kind-only
12559        // comparison would not.
12560        let src = "**one** **two**";
12561        for (view, tag) in VIEWS {
12562            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
12563            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
12564        }
12565    }
12566
12567    #[test]
12568    fn marks_read_the_document_as_it_is_edited() {
12569        // The point of asking twig every frame instead of caching: the answer has
12570        // to follow the toggle that changed it.
12571        let mut d = wysiwyg_doc("marks_live", "one two\n");
12572        d.anchor = Some(0);
12573        d.caret = 3;
12574        assert!(d.active_inline_marks().is_empty(), "plain to start");
12575        d.toggle(InlineKind::Strong);
12576        assert_eq!(d.source, "**one** two\n");
12577        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
12578        assert!(d.active_inline_marks().contains(InlineKind::Strong));
12579        d.toggle(InlineKind::Strong);
12580        assert!(d.active_inline_marks().is_empty(), "and off again");
12581    }
12582
12583    #[test]
12584    fn a_link_is_not_an_inline_mark() {
12585        // `link`/`str` are inline nodes, but nothing on the inline toolbar
12586        // toggles them — a set with a "link mark" in it would have no button.
12587        for (view, tag) in VIEWS {
12588            assert_eq!(
12589                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
12590                [],
12591                "{tag}"
12592            );
12593        }
12594    }
12595
12596    // ── blank documents ───────────────────────────────────────────────────────
12597
12598    #[test]
12599    fn a_blank_document_is_untitled_empty_and_markdown() {
12600        let mut d = Doc::blank().unwrap();
12601        assert!(d.is_untitled());
12602        assert_eq!(d.path, PathBuf::new());
12603        assert_eq!(
12604            d.file_name(),
12605            "untitled",
12606            "the header has to show something"
12607        );
12608        assert_eq!(d.format_name(), "markdown");
12609        assert_eq!(d.source, "");
12610        assert!(!d.dirty, "nothing typed yet is nothing to lose");
12611        assert_eq!(d.disk_state(), DiskState::Untitled);
12612        // And it's a document you can be in: the default view renders it.
12613        d.build_visual(80);
12614        assert_eq!(d.caret, 0);
12615    }
12616
12617    #[test]
12618    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
12619        let mut d = Doc::blank().unwrap();
12620        d.insert("hello");
12621        assert!(d.dirty);
12622        d.save();
12623        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
12624        assert!(d.dirty, "it must not come away believing it saved");
12625        assert!(d.is_untitled(), "and it still has no file");
12626    }
12627
12628    #[test]
12629    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
12630        let p = temp_path("blank_save_as");
12631        let mut d = Doc::blank().unwrap();
12632        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
12633        // be kept literal (`\#`); this test is about save-as, not escaping (which
12634        // has its own test), so it types nothing that escaping would touch.
12635        d.insert("hi");
12636        d.save_as(p.clone());
12637        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
12638        assert!(!d.is_untitled());
12639        assert!(!d.dirty);
12640        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
12641        assert_eq!(
12642            d.disk_state(),
12643            DiskState::Unchanged,
12644            "the watermark is stamped"
12645        );
12646        // And ⌘S is a plain save from here on.
12647        d.insert("!");
12648        d.save();
12649        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
12650        let _ = std::fs::remove_file(&p);
12651    }
12652
12653    // ── a file that isn't there yet ───────────────────────────────────────────
12654
12655    /// A unique path in the temp dir with the given extension, guaranteed not to
12656    /// exist — what `leaf notes.md` is handed when the file has never been made.
12657    fn missing_path(name: &str, ext: &str) -> PathBuf {
12658        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12659        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12660        let mut p = std::env::temp_dir();
12661        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
12662        let _ = std::fs::remove_file(&p);
12663        p
12664    }
12665
12666    #[test]
12667    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
12668        let p = missing_path("named", "md");
12669        let mut d = Doc::open_or_create(p.clone()).unwrap();
12670
12671        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
12672        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
12673        assert!(
12674            !d.is_untitled(),
12675            "it has the name the user asked for — ^S must not detour to Save As"
12676        );
12677        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
12678        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
12679        assert!(!p.exists(), "and opening it wrote nothing");
12680        // And it's a document you can be in.
12681        d.build_visual(80);
12682        assert_eq!(d.caret, 0);
12683    }
12684
12685    #[test]
12686    fn a_new_file_is_created_by_its_first_save() {
12687        let p = missing_path("first_save", "md");
12688        let mut d = Doc::open_or_create(p.clone()).unwrap();
12689        d.insert("hello\n");
12690        assert!(d.dirty);
12691        d.save();
12692
12693        assert_eq!(
12694            std::fs::read_to_string(&p).unwrap(),
12695            "hello\n",
12696            "a plain ^S wrote it — no Save As, no name to invent"
12697        );
12698        assert!(!d.dirty);
12699        assert_eq!(d.disk_state(), DiskState::Unchanged);
12700        let _ = std::fs::remove_file(&p);
12701    }
12702
12703    #[test]
12704    fn a_new_file_takes_its_format_from_the_extension() {
12705        // The one thing `blank` can't do: with no name it has to assume Markdown,
12706        // and typing djot into a Markdown parse is the wrong buffer.
12707        let dj = missing_path("format", "dj");
12708        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
12709        let md = missing_path("format", "md");
12710        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
12711    }
12712
12713    #[test]
12714    fn a_new_file_reports_itself_missing_until_it_is_saved() {
12715        // Not `Untitled` — that's the answer for a document with no path, and it
12716        // would tell a frontend there is nothing a save could collide with. Here
12717        // there is a path, and the file simply isn't at it yet.
12718        let p = missing_path("disk_state", "md");
12719        let mut d = Doc::open_or_create(p.clone()).unwrap();
12720        assert_eq!(d.disk_state(), DiskState::Missing);
12721
12722        // Somebody else creates it while the buffer is open: that's an overwrite
12723        // the frontend has to be able to prompt about, exactly as for an opened
12724        // file. Their bytes, not ours, so `Changed`.
12725        std::fs::write(&p, "theirs\n").unwrap();
12726        assert_eq!(d.disk_state(), DiskState::Changed);
12727
12728        // Saving makes the file ours and re-stamps the watermark.
12729        d.insert("ours\n");
12730        d.save();
12731        assert_eq!(d.disk_state(), DiskState::Unchanged);
12732        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
12733        let _ = std::fs::remove_file(&p);
12734    }
12735
12736    #[test]
12737    fn open_or_create_still_opens_a_file_that_is_there() {
12738        let d = doc_with("open_or_create_existing", "body\n");
12739        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
12740        assert_eq!(reopened.source, "body\n");
12741        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
12742    }
12743
12744    #[test]
12745    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
12746        // A mistyped flag or a stray argument must not become a buffer promising
12747        // to save somewhere — the same refusal `open` gives a real file.
12748        let mut p = std::env::temp_dir();
12749        p.push("leaf_test_new_bad_ext.wat");
12750        assert!(Doc::open_or_create(p).is_err());
12751        let mut none = std::env::temp_dir();
12752        none.push("leaf_test_new_no_ext");
12753        assert!(Doc::open_or_create(none).is_err());
12754    }
12755
12756    #[test]
12757    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
12758        // Opening reads nothing, so there is nothing to fail on yet; the write is
12759        // where it fails, and it says so rather than claiming a save.
12760        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
12761        let mut d = Doc::open_or_create(p).unwrap();
12762        d.insert("x");
12763        d.save();
12764        assert!(
12765            d.status.as_deref().unwrap().starts_with("save failed:"),
12766            "got {:?}",
12767            d.status
12768        );
12769        assert!(d.dirty, "it must not come away believing it saved");
12770    }
12771
12772    // ── save as ───────────────────────────────────────────────────────────────
12773
12774    /// A unique path in the temp dir that no fixture wrote — a Save As target.
12775    fn temp_path(name: &str) -> PathBuf {
12776        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12777        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12778        let mut p = std::env::temp_dir();
12779        p.push(format!("leaf_test_target_{name}_{seq}.md"));
12780        let _ = std::fs::remove_file(&p);
12781        p
12782    }
12783
12784    #[test]
12785    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
12786        let mut d = doc_with("save_as_move", "original\n");
12787        let old = d.path.clone();
12788        let new = temp_path("save_as_move");
12789        d.insert("edited: ");
12790        d.save_as(new.clone());
12791
12792        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
12793        assert_eq!(
12794            std::fs::read_to_string(&old).unwrap(),
12795            "original\n",
12796            "Save As doesn't touch the file it came from"
12797        );
12798        assert_eq!(d.path, new, "the document moved");
12799        assert!(!d.dirty);
12800        assert_eq!(
12801            d.status.as_deref(),
12802            Some(&*format!("saved {}", d.file_name()))
12803        );
12804
12805        // Every later save follows it, which is the whole difference from a copy.
12806        d.caret = 0;
12807        d.insert("re-");
12808        d.save();
12809        assert_eq!(
12810            std::fs::read_to_string(&new).unwrap(),
12811            "re-edited: original\n"
12812        );
12813        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
12814        let _ = std::fs::remove_file(&new);
12815    }
12816
12817    #[test]
12818    fn save_as_overwrites_an_existing_target() {
12819        // The picker already asked; asking again down here is the same question
12820        // twice, and the second one has no way to be answered.
12821        let new = temp_path("save_as_over");
12822        std::fs::write(&new, "theirs\n").unwrap();
12823        let mut d = doc_with("save_as_over", "ours\n");
12824        d.save_as(new.clone());
12825        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
12826        let _ = std::fs::remove_file(&new);
12827    }
12828
12829    #[test]
12830    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
12831        let mut d = doc_with("save_as_fail", "body\n");
12832        let old = d.path.clone();
12833        d.insert("x");
12834        // A directory that doesn't exist: the write can't land.
12835        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
12836        d.save_as(bad);
12837
12838        assert_eq!(
12839            d.path, old,
12840            "the document must not move to a file that isn't there"
12841        );
12842        assert!(d.dirty, "and must not believe it saved");
12843        assert!(
12844            d.status.as_deref().unwrap().starts_with("save failed:"),
12845            "the same failure a plain save reports, got {:?}",
12846            d.status
12847        );
12848        // The original is still the document's file, and still saveable.
12849        d.save();
12850        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
12851        assert!(!d.dirty);
12852    }
12853
12854    #[test]
12855    fn save_as_renames_without_reparsing_the_format() {
12856        // `.dj` on the name doesn't make the buffer djot: it was parsed as
12857        // Markdown and still is, and saying otherwise would be a conversion the
12858        // user never asked for (and an undo history thrown away to do it).
12859        let mut d = doc_with("save_as_format", "**b**\n");
12860        let mut new = temp_path("save_as_format");
12861        new.set_extension("dj");
12862        d.save_as(new.clone());
12863        assert_eq!(d.format_name(), "markdown");
12864        let _ = std::fs::remove_file(&new);
12865    }
12866
12867    // ── external change / reload ──────────────────────────────────────────────
12868
12869    #[test]
12870    fn an_untouched_file_reports_unchanged() {
12871        let mut d = doc_with("disk_clean", "body\n");
12872        assert_eq!(d.disk_state(), DiskState::Unchanged);
12873        // Editing the buffer is not editing the file.
12874        d.insert("x");
12875        assert_eq!(d.disk_state(), DiskState::Unchanged);
12876        assert!(d.dirty);
12877        // Saving re-stamps the watermark rather than reporting our own bytes back.
12878        d.save();
12879        assert_eq!(d.disk_state(), DiskState::Unchanged);
12880    }
12881
12882    #[test]
12883    fn a_file_written_underneath_reports_changed() {
12884        let mut d = doc_with("disk_changed", "body\n");
12885        std::fs::write(&d.path, "someone else\n").unwrap();
12886        assert_eq!(d.disk_state(), DiskState::Changed);
12887        // Dirty *and* changed is the clobber: both halves are readable, and
12888        // leaf-core takes neither side.
12889        d.insert("x");
12890        assert!(d.dirty && d.disk_state() == DiskState::Changed);
12891        // Saving anyway is allowed — the frontend asked, or chose not to.
12892        d.save();
12893        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
12894        assert_eq!(d.disk_state(), DiskState::Unchanged);
12895    }
12896
12897    #[test]
12898    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
12899        // The hash is what makes this honest: the file was written (a fresh
12900        // mtime), and nothing about the document is stale.
12901        let d = doc_with("disk_same_bytes", "body\n");
12902        std::fs::write(&d.path, "body\n").unwrap();
12903        assert_eq!(d.disk_state(), DiskState::Unchanged);
12904    }
12905
12906    #[test]
12907    fn a_deleted_file_reports_missing() {
12908        let mut d = doc_with("disk_missing", "body\n");
12909        std::fs::remove_file(&d.path).unwrap();
12910        assert_eq!(d.disk_state(), DiskState::Missing);
12911        // A save recreates it, and the document is whole again.
12912        d.save();
12913        assert_eq!(d.disk_state(), DiskState::Unchanged);
12914        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
12915    }
12916
12917    #[test]
12918    fn reload_replaces_the_document_with_the_file() {
12919        for (view, tag) in VIEWS {
12920            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
12921            d.insert("edited ");
12922            assert!(d.dirty);
12923            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12924            d.reload();
12925
12926            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
12927            assert!(!d.dirty, "{tag}: the file is what we have");
12928            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
12929            assert_eq!(
12930                d.status.as_deref(),
12931                Some(&*format!("reloaded {}", d.file_name()))
12932            );
12933            // The reloaded tree is live, not the old parse.
12934            d.caret = d.source.find("three").unwrap();
12935            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
12936        }
12937    }
12938
12939    #[test]
12940    fn reload_clamps_the_caret_and_drops_the_selection() {
12941        let mut d = doc_with("reload_caret", "a long first line\n");
12942        d.caret = 12;
12943        d.anchor = Some(4);
12944        std::fs::write(&d.path, "short\n").unwrap();
12945        d.reload();
12946        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
12947        assert_eq!(
12948            d.anchor, None,
12949            "a selection over bytes that changed is a lie"
12950        );
12951        assert!(d.selection().is_none());
12952
12953        // A caret the file still has room for stays put.
12954        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
12955        d.caret = 2;
12956        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
12957        d.reload();
12958        assert_eq!(d.caret, 2);
12959    }
12960
12961    /// A silent reload is something that happened *to* a reader — a formatter,
12962    /// a `git checkout` — so it has to be undoable like anything else that
12963    /// changes the document, and undoable as one step rather than as however
12964    /// many the file happens to differ by.
12965    #[test]
12966    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
12967        let mut d = doc_with("reload_undo", "body\n");
12968        d.insert("x");
12969        assert_eq!(d.source, "xbody\n");
12970        std::fs::write(&d.path, "replaced\n").unwrap();
12971        d.reload();
12972        assert_eq!(d.source, "replaced\n");
12973        assert!(!d.dirty, "a reload lands clean");
12974
12975        // One ^Z takes the whole swap off, and hands back the unsaved work it
12976        // replaced — which is unsaved again, because the file no longer says it.
12977        d.undo();
12978        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
12979        assert!(d.dirty, "and what it comes back to is unsaved");
12980        // …and the history under it is still there.
12981        d.undo();
12982        assert_eq!(
12983            d.source, "body\n",
12984            "the typing before the reload undoes too"
12985        );
12986        // Redo walks back up through the reload.
12987        d.redo();
12988        d.redo();
12989        assert_eq!(d.source, "replaced\n");
12990    }
12991
12992    /// A file rewritten with the bytes it already had is not an edit, so it
12993    /// must not leave an undo step behind for something nobody did.
12994    #[test]
12995    fn reloading_identical_bytes_pushes_no_undo_step() {
12996        let mut d = doc_with("reload_same", "body\n");
12997        d.insert("x");
12998        std::fs::write(&d.path, "xbody\n").unwrap();
12999        d.reload();
13000        assert_eq!(d.source, "xbody\n");
13001        assert!(!d.dirty, "the file now says what the buffer does");
13002        d.undo();
13003        assert_eq!(
13004            d.source, "body\n",
13005            "one step back is the typing, not a no-op"
13006        );
13007    }
13008
13009    #[test]
13010    fn a_reload_that_cant_read_leaves_the_document_alone() {
13011        let mut d = doc_with("reload_gone", "body\n");
13012        d.insert("x");
13013        std::fs::remove_file(&d.path).unwrap();
13014        d.reload();
13015        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
13016        assert!(d.dirty);
13017        assert!(
13018            d.status.as_deref().unwrap().starts_with("reload failed:"),
13019            "{:?}",
13020            d.status
13021        );
13022
13023        // And an untitled document has nothing to reload from.
13024        let mut d = Doc::blank().unwrap();
13025        d.insert("typed");
13026        d.reload();
13027        assert_eq!(d.source, "typed");
13028        assert_eq!(d.status.as_deref(), Some("no file to reload"));
13029    }
13030
13031    #[test]
13032    fn a_read_only_document_refuses_every_door() {
13033        let mut d = doc_with("readonly", "one two three\n");
13034        d.insert("x");
13035        assert!(d.dirty, "writable first, so the undo step exists");
13036        d.set_read_only(true);
13037        let before = d.source.clone();
13038        d.insert("y");
13039        d.backspace();
13040        d.undo();
13041        d.redo();
13042        assert_eq!(d.source, before, "no door moved a byte");
13043        d.set_read_only(false);
13044        d.undo();
13045        assert_ne!(d.source, before, "off again, the same doors work");
13046    }
13047
13048    #[test]
13049    fn a_selection_quote_carries_its_context_on_char_boundaries() {
13050        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
13051        let start = d.source.find("exact").unwrap();
13052        d.place_caret(start, false);
13053        d.place_caret(start + "exact".len(), true);
13054        let q = d.selection_quote(3).unwrap();
13055        assert_eq!(q.exact, "exact");
13056        assert_eq!(
13057            q.prefix, "你好 ",
13058            "chars, not bytes — the multibyte pair counts as two"
13059        );
13060        assert_eq!(q.suffix, " 世界");
13061        assert_eq!(&d.source[q.start..q.end], "exact");
13062        // At the edges the context clips rather than erring.
13063        d.place_caret(0, false);
13064        d.place_caret(6, true);
13065        let q = d.selection_quote(40).unwrap();
13066        assert_eq!(q.prefix, "");
13067        assert_eq!(q.exact, "before");
13068        // No selection is no quote.
13069        d.place_caret(0, false);
13070        assert!(d.selection_quote(3).is_none());
13071    }
13072
13073    #[test]
13074    fn highlights_are_kept_sorted_and_answer_point_queries() {
13075        let mut d = doc_with("hl", "one two three\n");
13076        d.set_highlights(vec![
13077            Highlight {
13078                start: 8,
13079                end: 13,
13080                id: "b".into(),
13081                color: None,
13082                marker: None,
13083            },
13084            Highlight {
13085                start: 0,
13086                end: 3,
13087                id: "a".into(),
13088                color: Some("#ffe066".into()),
13089                marker: None,
13090            },
13091            Highlight {
13092                start: 5,
13093                end: 5,
13094                id: "empty".into(),
13095                color: None,
13096                marker: None,
13097            },
13098        ]);
13099        assert_eq!(
13100            d.highlights()
13101                .iter()
13102                .map(|h| h.id.as_str())
13103                .collect::<Vec<_>>(),
13104            ["a", "b"],
13105            "sorted by start, the empty range dropped"
13106        );
13107        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
13108        assert_eq!(d.highlight_at(3), None, "end is exclusive");
13109        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
13110        d.set_highlights(Vec::new());
13111        assert!(d.highlights().is_empty(), "a replace is a replace");
13112    }
13113
13114    /// `Highlight::covering` and the cursor over it are what both painters ask
13115    /// per glyph, so they have to answer the same as the scan they replaced —
13116    /// including in the gaps, which is where most glyphs are.
13117    #[test]
13118    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
13119        let hl = |start: usize, end: usize, id: &str| Highlight {
13120            start,
13121            end,
13122            id: id.into(),
13123            color: None,
13124            marker: None,
13125        };
13126        // Disjoint, as search hits are: in a range, in a gap, and past the end.
13127        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
13128        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
13129        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
13130        assert_eq!(
13131            Highlight::covering(&hits, 105),
13132            None,
13133            "a gap covers nothing"
13134        );
13135        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
13136        assert_eq!(Highlight::covering(&hits, 9_999), None);
13137        assert_eq!(Highlight::covering(&[], 0), None);
13138
13139        // Nested: first by start, so a hit inside an annotation still resolves
13140        // to the annotation — and the range that stops short doesn't mask it.
13141        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
13142        assert_eq!(
13143            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
13144            Some("outer")
13145        );
13146        assert_eq!(
13147            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
13148            Some("outer")
13149        );
13150    }
13151
13152    /// The cursor is an optimisation, so the only thing worth asserting is that
13153    /// it is not also a change of answer — at every offset, over a list with a
13154    /// nest in it, walked forwards and then backwards.
13155    #[test]
13156    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
13157        let hl = |start: usize, end: usize, id: &str| Highlight {
13158            start,
13159            end,
13160            id: id.into(),
13161            color: None,
13162            marker: None,
13163        };
13164        let mut list = vec![
13165            hl(0, 20, "outer"),
13166            hl(5, 10, "inner"),
13167            hl(30, 33, "hit"),
13168            hl(40, 43, "hit"),
13169        ];
13170        list.sort_by_key(|h| (h.start, h.end));
13171
13172        let mut cursor = HighlightCursor::new(&list);
13173        for offset in 0..50 {
13174            assert_eq!(
13175                cursor.at(offset).map(|h| h.id.as_str()),
13176                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
13177                "cursor disagrees at {offset}"
13178            );
13179        }
13180        // Backwards: the cursor re-seats rather than answering from where it
13181        // had got to, so a painter that revisits a row is still told the truth.
13182        for offset in (0..50).rev() {
13183            assert_eq!(
13184                cursor.at(offset).map(|h| h.id.as_str()),
13185                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
13186                "cursor disagrees walking back at {offset}"
13187            );
13188        }
13189    }
13190}