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
523/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
524/// purpose: the only useful question is whether two of them are the same map,
525/// and the tuple behind it (revision, wrap, reveal line) is core's business.
526#[derive(Clone, PartialEq, Eq, Debug)]
527pub struct VisualKey(Option<(u64, Option<usize>, Option<Range<usize>>)>);
528
529pub struct Doc {
530    editor: Editor,
531    pub format: Format,
532    pub path: PathBuf,
533    /// Current source, refreshed from the editor after every successful edit.
534    pub source: String,
535    /// The caret, as a byte offset into `source` (always on a char boundary).
536    pub caret: usize,
537    /// The selection's fixed end, if a selection is active; the moving end is
538    /// the caret. `None` means no selection.
539    pub anchor: Option<usize>,
540    pub dirty: bool,
541    pub status: Option<String>,
542    pub view: View,
543    /// Whether the document refuses to change — a *reading* surface over the
544    /// same rendering, selection, and navigation the editor has.
545    ///
546    /// Enforced here rather than by each frontend hiding its input paths,
547    /// because every mutation funnels through a few doors —
548    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
549    /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
550    /// verbs directly rather than through the splice (a typed literal, a link,
551    /// an image, a rule, a footnote, a cell's line break) — and guarded doors
552    /// are a guarantee where a frontend's suppressed keyboard is a hope. A
553    /// gated door reports exactly like a rolled-back splice, a path every
554    /// caller already handles. `a_read_only_document_refuses_every_door` is
555    /// the list; a new `self.editor.insert_*` call belongs on it.
556    read_only: bool,
557    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
558    /// State like the selection rather than like the text: no edit history,
559    /// no dirty bit, redrawn from whatever the host last set.
560    highlights: Vec<Highlight>,
561    /// How much of the source markup the rich view exposes — a frontend preference (see
562    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
563    /// [`reveal_line`](Self::reveal_line), the editing one by
564    /// [`insert`](Self::insert).
565    markup_mode: MarkupMode,
566    /// Whether soft breaks fold into the reflowed paragraph or render where
567    /// they were written (see [`LineFlow`]) — an independent frontend
568    /// preference the WYSIWYG builder consults when it lays out a block.
569    line_flow: LineFlow,
570    /// The kind of the last edit, for coalescing: twig owns the undo *history*
571    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
572    /// call, so leaf decides when a run continues and tells twig to coalesce.
573    last_edit_kind: Option<EditKind>,
574    /// The inline marks the user has toggled *at a collapsed caret* with no
575    /// selection — "start typing bold here". Held as the XOR delta from the marks
576    /// already in force at [`pending_at`](Self::pending_at): a set bit means
577    /// "flip this kind for the next typed text", so it both turns a mark on where
578    /// none is (type into bold) and off where one already covers the caret (type
579    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
580    /// freshly typed text and then clears it — a mark once realised is carried by
581    /// the caret sitting inside the run, not by this delta.
582    pending_marks: InlineMarks,
583    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
584    /// delta is live only while the caret still stands here with no selection;
585    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
586    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
587    pending_at: Option<usize>,
588    /// The source as of the last open/save — `dirty` is `source != clean_source`,
589    /// so undoing back to the saved state correctly clears the modified flag.
590    clean_source: String,
591    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
592    /// while the document has no file behind it. [`Doc::disk_state`] compares
593    /// the file against this to catch an edit made *outside* leaf before a save
594    /// silently overwrites it — `clean_source` only knows what leaf itself did.
595    ///
596    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
597    /// writes inside one filesystem timestamp tick are indistinguishable, a
598    /// clock that steps backwards (or a writer that restores an mtime) hides a
599    /// real change, and a `touch` invents one. The whole point of the watermark
600    /// is to not clobber someone's work, so it reads the bytes and compares what
601    /// is actually there. That costs a file read per question, which is why the
602    /// question is asked on a user event (focus, save) and not every frame.
603    disk_hash: Option<u64>,
604    /// The "sticky" display column vertical motion aims for, in the active
605    /// view's grid. Set on the first `move_up`/`move_down` of a run and
606    /// reused by every subsequent one in that run, so passing through a
607    /// shorter line doesn't permanently forget the original column. Any
608    /// horizontal motion or edit clears it.
609    ///
610    /// A column, not a character index: dropping down a line of `你好` onto one
611    /// of ASCII has to land under the glyph the caret was drawn beneath, which
612    /// is the only thing the user can see to aim by. Where the goal falls inside
613    /// a wide character on the target line, the mapping resolves it to that
614    /// character — the caret lands on it rather than between its cells.
615    goal_col: Option<usize>,
616    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
617    /// and clicks read it to stay in visible space.
618    pub vmap: VisualMap,
619    /// The syntax map for the source view; empty in the WYSIWYG view, which
620    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
621    /// frontend that never calls it paints raw source unstyled, which is what
622    /// every frontend did before this map existed.
623    pub smap: SourceMap,
624    /// The revision `smap` was built from, or `None` before the first build.
625    /// The map is a pure function of the text alone — no width, no caret, no
626    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
627    /// whole key.
628    smap_key: Option<u64>,
629    /// Everything the map is built from, as one number: bumped whenever the
630    /// document's text changes, and never by a motion, a selection, or a save.
631    /// A frontend can hold work against it — see [`Doc::revision`].
632    revision: u64,
633    /// How many history steps stand behind the caret, and how many ahead of
634    /// it — the answer to a native Edit menu's "may Undo be enabled?", which
635    /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
636    /// the funnel every edit comes through, and moved back and forth by
637    /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
638    /// an exact depth: a coalesced run of typing is one of twig's steps but
639    /// several of these, and twig's own cap on history is not mirrored here.
640    /// Neither error can make `can_undo` false while a step remains, which is
641    /// the only property a menu needs; the one place the bound can be wrong the
642    /// other way — the cap has retired every step — is reconciled the moment
643    /// twig reports nothing to undo.
644    undo_steps: usize,
645    redo_steps: usize,
646    /// What `vmap` was built from, or `None` before the first build. The map is
647    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
648    /// moved, rebuilding it produces the identical map — see
649    /// [`Doc::build_visual`].
650    ///
651    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
652    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
653    /// text and width alone, and a caret motion still rebuilds nothing.
654    vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
655    /// Per-block row cache backing the incremental rebuild: when the text
656    /// changes, only the top-level blocks whose bytes moved are re-rendered and
657    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
658    /// builds; a pure accelerator, so it's never read for correctness.
659    block_cache: wysiwyg::BlockCache,
660    /// How many visual rows each block image reserves, keyed by its destination —
661    /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
662    /// measured the pictures. Core does no image I/O, so this is the only way it
663    /// learns a picture's height; a destination not in the map reserves the bare
664    /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
665    /// sizes each placeholder, and folded into `vmap_key` so a height change
666    /// rebuilds the map.
667    media_rows: HashMap<String, usize>,
668
669    // View geometry the renderer stamps each frame, so mouse events can map a
670    // screen cell back to a byte offset.
671    pub scroll: usize,
672    pub body_origin: (u16, u16),
673    /// Width of the body rectangle last painted by the frontend. Zero means
674    /// unknown (used by tests or a frontend that has not drawn yet).
675    pub body_width: u16,
676    pub body_height: u16,
677    /// The caret as of the last frame drawn, or `None` before the first.
678    ///
679    /// Scrolling is the viewport's business, not the caret's: the view follows
680    /// the caret when the caret *moves*, but a wheel that doesn't touch the
681    /// caret has to be free to scroll away from it — otherwise the view is
682    /// pinned to the caret and stops dead at the edge of the document you can
683    /// see. Comparing against this is what tells the two apart, and it catches a
684    /// caret set by any route, including a frontend assigning the field itself.
685    pub drawn_caret: Option<usize>,
686}
687
688/// The Markdown extensions every leaf document is parsed with — four of them,
689/// each departing from twig's defaults for a reason leaf can state.
690///
691/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
692/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
693/// node the frontends can frame and rasterize instead of opaque `raw_block`
694/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
695/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
696/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
697/// plain tinted container, agnostic of `name`.
698///
699/// `highlight` and `highlight_colors` are the pair that makes Markdown read
700/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
701/// `data-color`. leaf already had somewhere to put both: the
702/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
703/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
704/// from a Djot one it was converted from — the button wrote `==…==` and the
705/// reparse read it straight back as text.
706/// They are on together because a colour is inert without the highlight itself,
707/// and a document that writes `==🔴 x==` means the colour by it.
708///
709/// Every flag is inert for non-Markdown formats, so it's safe to pass them
710/// unconditionally. Threading this through every constructor (not just `open`)
711/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
712/// way — twig reparses with these same flags after each edit.
713pub(crate) fn parse_extensions() -> MarkdownExtensions {
714    MarkdownExtensions {
715        html_elements: true,
716        directives: true,
717        highlight: true,
718        highlight_colors: true,
719        ..Default::default()
720    }
721}
722
723/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
724/// mapping twig's error into the `anyhow` context every constructor shares.
725fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
726    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
727}
728
729/// Does `format` spell a table as a **pipe table** — the one grid twig's table
730/// editor knows how to emit?
731///
732/// This is the single capability leaf still has to answer for itself, and the
733/// only hand-maintained format list left in this file. Every other gesture is
734/// [`Format::supports`], which is twig's own answer read across the C ABI — but
735/// twig deliberately leaves the table ops out of that query, because they read
736/// no `Syntax` table at all. They rewrite a grid that is already in the source
737/// and refuse on *position*, never on format. Handed a caret inside an HTML
738/// `<table>`, `table_insert_row` therefore re-emits the whole element as
739/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
740/// `dirty` flag, and nothing downstream able to tell it from a good edit.
741///
742/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
743/// wildcard answers "no" for a format leaf has never heard of: a new twig
744/// language that *does* spell pipe tables loses its grid controls until this
745/// line is updated, which shows up as a missing button. The other default hands
746/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
747fn spells_pipe_tables(format: Format) -> bool {
748    matches!(format, Format::Markdown | Format::Djot)
749}
750
751/// Which of leaf's authoring controls this document's format can actually
752/// spell — one flag per toolbar button, resolved once so a frontend can build
753/// its chrome instead of discovering each refusal on a click.
754///
755/// Every field but [`table`](Self::table) is `Format::supports` on the gesture
756/// the matching [`Doc`] method calls, so this record cannot drift from what the
757/// ops do; `table` is [`spells_pipe_tables`], the one answer twig doesn't
758/// export.
759///
760/// **The formats are ragged, and that is the point.** A single per-document
761/// boolean was enough while the two authorable formats were Markdown and djot
762/// and everything else spelled nothing. HTML is neither: it writes seven of the
763/// eight inline marks as a tag pair, plus `<code>`, `<hr>` and an in-cell
764/// `<br>`, and spells no heading marker, no line prefix, no fence, no task box,
765/// no link — because its versions of those have a different *shape*, not a
766/// different alphabet. So ⌘B works in an HTML document and ⌘1 does not, and no
767/// one flag can say that. Markdown and djot differ from each other too:
768/// `==mark==` is djot-only, and an in-cell `<br>` is Markdown-only.
769#[derive(Clone, Copy, Debug, Eq, PartialEq)]
770pub struct Capabilities {
771    /// ⌘B — `InlineKind::Strong`.
772    pub bold: bool,
773    /// ⌘I — `InlineKind::Emph`.
774    pub italic: bool,
775    /// Inline code — `InlineKind::Verbatim`.
776    pub code: bool,
777    /// Highlight — `InlineKind::Mark`. Djot spells it; Markdown does not.
778    pub mark: bool,
779    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
780    pub underline: bool,
781    /// Strikethrough — `InlineKind::Delete`.
782    pub strike: bool,
783    pub superscript: bool,
784    pub subscript: bool,
785    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
786    pub heading: bool,
787    pub blockquote: bool,
788    pub bullet_list: bool,
789    pub ordered_list: bool,
790    /// The checkbox controls: giving an item a box, and ticking one.
791    pub task: bool,
792    pub link: bool,
793    /// Covers [`Doc::insert_media`] too — see the note there on why the three
794    /// media kinds stand or fall together.
795    pub image: bool,
796    /// The horizontal-rule button. HTML spells this one (`<hr>`).
797    pub thematic_break: bool,
798    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
799    /// the pair; HTML has no footnote of its own, so the button goes away rather
800    /// than writing brackets that would render as brackets.
801    pub footnote: bool,
802    /// Setting a fenced block's language — a control only ever offered with the
803    /// caret already in a fence.
804    pub code_language: bool,
805    /// The grid controls: insert/delete/move a row or column, set a column's
806    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
807    /// question — an HTML `<table>` holds the caret and still can't be edited.
808    pub table: bool,
809    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
810    /// idiomatic in-cell break.
811    pub cell_line_break: bool,
812}
813
814impl Capabilities {
815    /// Resolve every flag for `format`. Pure and cheap — twig computes each from
816    /// a static table — but a frontend that wants to hold them can.
817    pub fn of(format: Format) -> Self {
818        let inline = |k| format.supports(Gesture::ToggleInline(k));
819        let container = |k| format.supports(Gesture::ToggleBlockContainer(k));
820        Self {
821            bold: inline(InlineKind::Strong),
822            italic: inline(InlineKind::Emph),
823            code: inline(InlineKind::Verbatim),
824            mark: inline(InlineKind::Mark),
825            underline: inline(InlineKind::Insert),
826            strike: inline(InlineKind::Delete),
827            superscript: inline(InlineKind::Superscript),
828            subscript: inline(InlineKind::Subscript),
829            heading: format.supports(Gesture::SetBlock),
830            blockquote: container(BlockContainerKind::BlockQuote),
831            bullet_list: container(BlockContainerKind::BulletList),
832            ordered_list: container(BlockContainerKind::OrderedList),
833            // Both halves of the checkbox story, and leaf offers no control that
834            // needs only one: the item gesture mints the box, the checked one
835            // ticks it, and a format spelling a `task_marker` spells both.
836            task: format.supports(Gesture::ToggleTaskItem)
837                && format.supports(Gesture::ToggleTaskChecked),
838            link: format.supports(Gesture::InsertLink),
839            image: format.supports(Gesture::InsertImage),
840            thematic_break: format.supports(Gesture::InsertThematicBreak),
841            footnote: format.supports(Gesture::InsertFootnote),
842            code_language: format.supports(Gesture::SetCodeLanguage),
843            table: spells_pipe_tables(format),
844            cell_line_break: format.supports(Gesture::InsertLineBreak),
845        }
846    }
847}
848
849impl Doc {
850    #[cfg(feature = "fs")]
851    pub fn open(path: PathBuf) -> Result<Self> {
852        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
853        Self::from_disk_bytes(path, bytes)
854    }
855
856    /// An empty document *named* `path`, for a file that isn't there yet — what
857    /// every other terminal editor gives you when you name a file that doesn't
858    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
859    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
860    /// the header shows the name the user asked for.
861    ///
862    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
863    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
864    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
865    /// parse is still an error: a mistyped flag or a stray argument should say
866    /// so, not open a buffer promising to save somewhere.
867    ///
868    /// The watermark is the hash of *no bytes*, not `None`, and that is the
869    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
870    /// answering [`DiskState::Untitled`] for a document that has a path and
871    /// intends to write to it. Hashing `""` instead makes the answers the true
872    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
873    /// recreates it, which is exactly what this is for), and
874    /// [`DiskState::Changed`] if somebody creates it underneath us between
875    /// launch and save, so the frontend's overwrite prompt guards a new file as
876    /// it guards an opened one.
877    ///
878    /// Nothing is written here. A buffer that is never typed into never touches
879    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
880    /// open — the write is where that fails, and it says so then.
881    #[cfg(feature = "fs")]
882    pub fn create(path: PathBuf) -> Result<Self> {
883        Self::from_disk_bytes(path, Vec::new())
884    }
885
886    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
887    /// the call a CLI frontend wants for its path argument.
888    ///
889    /// The decision is made from the failed read itself rather than a `exists()`
890    /// check first, so there is no window between the two for the file to appear
891    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
892    /// a directory in the way is still an error, because pretending those are
893    /// "no file yet" would offer to save over something leaf couldn't read.
894    #[cfg(feature = "fs")]
895    pub fn open_or_create(path: PathBuf) -> Result<Self> {
896        match std::fs::read(&path) {
897            Ok(bytes) => Self::from_disk_bytes(path, bytes),
898            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
899            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
900        }
901    }
902
903    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
904    /// stand in for) the file at `path`, parsed as the format its extension
905    /// names. Keeping the two on one path is what makes a new file's document
906    /// identical in every respect to an opened one but its contents.
907    #[cfg(feature = "fs")]
908    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
909        let format = detect_format(&path)?;
910        let editor = new_editor(&bytes, format)?;
911        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
912        let disk_hash = Some(hash_bytes(source.as_bytes()));
913        // Store the document's *absolute* path. A relative one (`leaf README.md`)
914        // has an empty parent, so a frontend can't resolve a relative image
915        // destination (`![](pic.png)`) against the document's directory and the
916        // picture silently falls back to its text placeholder. `absolute` is
917        // purely lexical — it prefixes the current directory and normalizes, but
918        // reads nothing and resolves no symlinks — so `file_name` and save are
919        // unchanged; it only gives `path.parent()` something to join against.
920        let path = std::path::absolute(&path).unwrap_or(path);
921        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
922    }
923
924    /// Build a document from an in-memory string, the format named explicitly —
925    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
926    /// path and sniffs the format from its extension). A wasm or FFI host, which
927    /// has no path to read, uses this: it hands over bytes it fetched however it
928    /// could, and later persists [`Doc::source`] however it can (a browser
929    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
930    ///
931    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
932    /// true) exactly like a [`Doc::blank`] that has been given content.
933    pub fn from_source(source: String, format: Format) -> Result<Self> {
934        let editor = new_editor(source.as_bytes(), format)?;
935        Ok(Doc::from_parts(
936            editor,
937            format,
938            PathBuf::new(),
939            source,
940            None,
941        ))
942    }
943
944    /// An untitled, empty document — the `+` button and a `leaf` launched with
945    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
946    ///
947    /// It is Markdown, because a format has to be chosen before a name exists to
948    /// read one from: `detect_format` reads the extension and an untitled
949    /// document has neither. Markdown is what leaf's own files are, what its
950    /// block markers are already written for (`insert_block_prefix`), and the
951    /// extension a Save As will overwhelmingly pick — a wrong guess here would
952    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
953    /// *doesn't* revisit this: see [`Doc::save_as`].
954    pub fn blank() -> Result<Self> {
955        let format = Format::Markdown;
956        let editor = new_editor(b"", format)?;
957        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
958        // field two frontends already read; making it an `Option` to say this
959        // would break both). `is_untitled` is the question to ask, not the
960        // representation to copy.
961        Ok(Doc::from_parts(
962            editor,
963            format,
964            PathBuf::new(),
965            String::new(),
966            None,
967        ))
968    }
969
970    /// The fields every constructor agrees on, so `open` and `blank` can't drift
971    /// apart in the ones neither of them has an opinion about.
972    fn from_parts(
973        editor: Editor,
974        format: Format,
975        path: PathBuf,
976        source: String,
977        disk_hash: Option<u64>,
978    ) -> Self {
979        Doc {
980            editor,
981            format,
982            path,
983            disk_hash,
984            clean_source: source.clone(),
985            source,
986            caret: 0,
987            anchor: None,
988            dirty: false,
989            status: None,
990            read_only: false,
991            highlights: Vec::new(),
992            // leaf opens in the rich-text (WYSIWYG) view by default — the
993            // markup-resolved surface is leaf's differentiator. Frontends can
994            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
995            // toggles at runtime.
996            view: View::Wysiwyg,
997            // `None` by default — the clean surface Diaryx ships, with typed
998            // syntax kept literal; a markup-fluent frontend can climb the
999            // ladder to `Shortcuts` or `Full`.
1000            markup_mode: MarkupMode::default(),
1001            // Fold by default — flowing prose that reflows to the viewport, the
1002            // behaviour every frontend had before this preference existed.
1003            line_flow: LineFlow::default(),
1004            last_edit_kind: None,
1005            pending_marks: InlineMarks::empty(),
1006            pending_at: None,
1007            goal_col: None,
1008            vmap: VisualMap::default(),
1009            smap: SourceMap::default(),
1010            // No map yet — the first `build_source` always builds.
1011            smap_key: None,
1012            revision: 0,
1013            undo_steps: 0,
1014            redo_steps: 0,
1015            // No map yet — the first `build_visual` always builds.
1016            vmap_key: None,
1017            block_cache: wysiwyg::BlockCache::default(),
1018            media_rows: HashMap::new(),
1019            scroll: 0,
1020            body_origin: (0, 0),
1021            body_width: 0,
1022            body_height: 0,
1023            drawn_caret: None,
1024        }
1025    }
1026
1027    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1028    /// has never been saved. The question a ⌘S handler asks to know it should
1029    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1030    /// header asks to know the name it shows is a placeholder.
1031    pub fn is_untitled(&self) -> bool {
1032        self.path.as_os_str().is_empty()
1033    }
1034
1035    pub fn toggle_view(&mut self) {
1036        self.view = match self.view {
1037            View::Source => View::Wysiwyg,
1038            View::Wysiwyg => View::Source,
1039        };
1040        self.scroll = 0;
1041        self.status = None;
1042        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1043        // lift it to the first rendered offset.
1044        self.clamp_caret();
1045    }
1046
1047    /// The current markup-exposure preference (see [`MarkupMode`]).
1048    pub fn markup_mode(&self) -> MarkupMode {
1049        self.markup_mode
1050    }
1051
1052    /// Set the markup-exposure preference. Both of its axes take effect at
1053    /// once: the editing one on the next [`insert`](Self::insert), and the
1054    /// rendering one on the next build — which is why this drops the cached
1055    /// visual map and the per-block render cache, exactly as
1056    /// [`set_line_flow`](Self::set_line_flow) does.
1057    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1058        if self.markup_mode == mode {
1059            return;
1060        }
1061        self.markup_mode = mode;
1062        // Neither cache is keyed on the mode, and moving between `Full` and the
1063        // hidden modes changes every row the caret's line renders to — so
1064        // invalidate both explicitly.
1065        self.vmap_key = None;
1066        self.block_cache = wysiwyg::BlockCache::default();
1067    }
1068
1069    /// The source byte range of the line the caret sits on, when that line
1070    /// should render its raw delimiters — `None` in every mode and view that
1071    /// hides them, which is what the builder reads as "reveal nothing".
1072    ///
1073    /// A *source* line (newline to newline), not a visual row: a wrapped
1074    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1075    /// line across several rows, and revealing half a delimiter pair because the
1076    /// other half wrapped would be worse than revealing neither. The range
1077    /// excludes the terminating newline and is empty-but-present on a blank
1078    /// line, which reveals nothing but still keys the caches correctly.
1079    ///
1080    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1081    /// there is nothing there to reveal.
1082    pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1083        if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1084            return None;
1085        }
1086        Some(source_line_range(&self.source, self.caret))
1087    }
1088
1089    /// The current soft-break flow preference (see [`LineFlow`]).
1090    pub fn line_flow(&self) -> LineFlow {
1091        self.line_flow
1092    }
1093
1094    /// Set the soft-break flow preference. The mode changes how every block lays
1095    /// out, so a change drops the cached visual map and the per-block render
1096    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1097    ///
1098    /// [`build_visual`]: Self::build_visual
1099    pub fn set_line_flow(&mut self, mode: LineFlow) {
1100        if self.line_flow == mode {
1101            return;
1102        }
1103        self.line_flow = mode;
1104        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1105        // so invalidate them explicitly, or the next build would reuse rows laid
1106        // out under the old flow.
1107        self.vmap_key = None;
1108        self.block_cache = wysiwyg::BlockCache::default();
1109    }
1110
1111    pub fn view_name(&self) -> &'static str {
1112        match self.view {
1113            View::Source => "source",
1114            View::Wysiwyg => "wysiwyg",
1115        }
1116    }
1117
1118    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1119    /// (called by the renderer each frame it's in the WYSIWYG view).
1120    /// Build the WYSIWYG map, wrapped at `width` display columns.
1121    ///
1122    /// Cheap to call every frame, which is what both frontends do: the map is a
1123    /// pure function of the document and the wrap width, so a call that would
1124    /// rebuild the same map returns the one already built. Only an edit (or a
1125    /// resize) pays.
1126    ///
1127    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1128    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1129    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1130    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1131    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1132    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1133    pub fn build_visual(&mut self, width: usize) {
1134        self.build_map(Some(width));
1135    }
1136
1137    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1138    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1139    /// than a fixed character column.
1140    pub fn build_visual_unwrapped(&mut self) {
1141        self.build_map(None);
1142    }
1143
1144    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1145    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1146    /// styling for [`View::Wysiwyg`].
1147    ///
1148    /// A frontend calls this before painting raw source. One that doesn't gets
1149    /// an empty map and paints unstyled text, so this is additive: nothing
1150    /// breaks by not calling it.
1151    ///
1152    /// Built at most once per revision, and the revision is the whole key — the
1153    /// map has no width and no caret in it, so it survives every resize, every
1154    /// motion, and every selection change.
1155    ///
1156    /// The builds it does do cost a whole-arena marshal, which is precisely what
1157    /// the WYSIWYG path works to avoid, so this has no incremental path where
1158    /// that one has two. From `cargo run --release -p leaf-core --example
1159    /// bench`, per keystroke, against the WYSIWYG build the source view is
1160    /// *not* doing:
1161    ///
1162    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1163    /// |------:|-------:|--------:|----------------:|-------------------:|
1164    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1165    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1166    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1167    ///
1168    /// Linear, two thirds of it the marshal, and the build itself five to seven
1169    /// times cheaper than the one it stands in for at every size. Comfortable
1170    /// well past any document a person edits in a terminal — a megabyte is where
1171    /// it would want [`Editor::dirty_range`] and the same splice treatment
1172    /// `build_spliced` gives the other map. The door is open; nothing has needed
1173    /// it yet.
1174    pub fn build_source(&mut self) {
1175        if self.smap_key == Some(self.revision) {
1176            return;
1177        }
1178        let nodes = self.nodes();
1179        self.smap = source::build(&nodes, &self.source);
1180        self.smap_key = Some(self.revision);
1181    }
1182
1183    /// Tell the model how many visual rows each block image should reserve, keyed
1184    /// by the image's destination. A terminal frontend calls this once it has
1185    /// decoded and measured its pictures — core does no image I/O, so this is the
1186    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1187    /// placeholder out that tall (the label row plus blank filler rows the
1188    /// frontend paints the raster over). A destination left out of the map falls
1189    /// back to the bare one-row placeholder, which is also what a frontend that
1190    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1191    /// by never calling this.
1192    ///
1193    /// Cheap to call every frame with the same map: only a *change* invalidates
1194    /// the built map (and the block-row cache, since a height isn't part of a
1195    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1196    /// no-op, so a frontend can just hand over its current measurements each frame.
1197    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1198        if self.media_rows == rows {
1199            return;
1200        }
1201        self.media_rows = rows;
1202        // A height lives outside the block's source bytes, so the content-keyed
1203        // block cache would hand back the old-height rows on a hit. Drop it (and
1204        // the splice layout it carries) so the next build re-renders every block
1205        // at the new heights, and force that build by clearing the map key.
1206        self.block_cache = wysiwyg::BlockCache::default();
1207        self.vmap_key = None;
1208    }
1209
1210    /// The revision the document's text is at — bumped by every edit, undo,
1211    /// redo, and reload, and by nothing else. A frontend caches against this to
1212    /// tell a repaint that needs new work from one that doesn't.
1213    ///
1214    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1215    /// lands on the same text two revisions later. Work is only ever rebuilt
1216    /// needlessly, never wrongly reused.
1217    pub fn revision(&self) -> u64 {
1218        self.revision
1219    }
1220
1221    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1222    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1223    /// unbuilt map before the first one.
1224    ///
1225    /// This is *not* [`revision`](Self::revision). The revision says where the
1226    /// text is; this says where the map is, and the two part company the moment
1227    /// an edit lands, until something rebuilds. A frontend that keeps its own
1228    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1229    /// under an oversized heading — compares this against the value it held when
1230    /// it took the copy, and learns whether `vmap` is still the map it stashed
1231    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1232    /// map would paint a stale document; restoring nothing hands core's
1233    /// incremental rebuild a map it never built.
1234    pub fn visual_key(&self) -> VisualKey {
1235        VisualKey(self.vmap_key.clone())
1236    }
1237
1238    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1239    /// runs on every call: the caret moves without the document changing, and
1240    /// keeping it on a legal stop is this function's job either way.
1241    fn build_map(&mut self, wrap: Option<usize>) {
1242        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1243        // as well as the text, so the line joins the key: moving within a line
1244        // still reuses the map, and crossing into another one rebuilds it. In
1245        // every other mode `reveal_line` is `None` and the key is what it was,
1246        // so caret motion goes on costing nothing.
1247        let reveal = self.reveal_line();
1248        let key = (self.revision, wrap, reveal.clone());
1249        if self.vmap_key.as_ref() != Some(&key) {
1250            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1251            // A subtree is pulled only for the block(s) that actually changed, so
1252            // the FFI marshal shrinks from O(document) to O(edited block).
1253            let top = self.top_blocks();
1254
1255            // Fast path: when twig reports a dirty byte range, try to patch the
1256            // previous map in place — a single-block edit moves the prefix,
1257            // shifts the suffix, and re-renders only one block. `build_spliced`
1258            // returns `None` (and we fall back to the always-correct full rebuild)
1259            // whenever the edit reshaped the block structure, hit a table, or
1260            // there's no previous map to patch.
1261            // Preserve soft breaks as written when the flow preference asks for
1262            // it — the builder renders each as its own visual row instead of
1263            // folding it into the reflowed paragraph.
1264            let preserve_soft = self.line_flow == LineFlow::Preserve;
1265            let spliced = match self.editor.dirty_range() {
1266                Some(dirty) => {
1267                    let prev = std::mem::take(&mut self.vmap);
1268                    let source = &self.source;
1269                    let cache = &mut self.block_cache;
1270                    let media_rows = &self.media_rows;
1271                    let editor = &mut self.editor;
1272                    wysiwyg::build_spliced(
1273                        prev,
1274                        source,
1275                        wrap,
1276                        preserve_soft,
1277                        &top,
1278                        dirty,
1279                        media_rows,
1280                        reveal.clone(),
1281                        cache,
1282                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1283                    )
1284                }
1285                None => None,
1286            };
1287            self.vmap = spliced.unwrap_or_else(|| {
1288                let source = &self.source;
1289                let cache = &mut self.block_cache;
1290                let media_rows = &self.media_rows;
1291                let editor = &mut self.editor;
1292                wysiwyg::build_cached(
1293                    &top,
1294                    source,
1295                    wrap,
1296                    preserve_soft,
1297                    media_rows,
1298                    reveal,
1299                    cache,
1300                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1301                )
1302            });
1303            // Acknowledge the dirty range so the next edit's range starts fresh.
1304            self.editor.clear_dirty();
1305            self.vmap_key = Some(key);
1306        }
1307        self.clamp_caret();
1308    }
1309
1310    fn nodes(&mut self) -> Vec<FlatNode> {
1311        self.editor.nodes().unwrap_or_default()
1312    }
1313
1314    /// The document's top-level blocks for the incremental render. See
1315    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1316    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1317        wysiwyg::top_blocks(&mut self.editor)
1318    }
1319
1320    pub fn format_name(&self) -> &'static str {
1321        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1322        // required. It also covers `Asciidoc`, which twig parses but cannot
1323        // serialize — leaf never opens a document in it (see `Doc::open`).
1324        match self.format {
1325            Format::Djot => "djot",
1326            Format::Markdown => "markdown",
1327            Format::Xml => "xml",
1328            Format::Html => "html",
1329            _ => "unknown",
1330        }
1331    }
1332
1333    /// Whether this document's format offers *any* door in — `false` only for a
1334    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1335    /// a frontend may as well open the file read-only.
1336    ///
1337    /// This is a much weaker claim than the name suggests, and driving per-button
1338    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1339    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1340    /// while a heading, a quote, a list, a task box, a link and a code fence all
1341    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1342    /// [`supports`](Self::supports) — per control.
1343    pub fn authorable(&self) -> bool {
1344        self.format.is_authorable()
1345    }
1346
1347    /// Whether this document's format can spell `gesture`, which is twig's own
1348    /// answer rather than a copy of it: `Format::supports` reads the same
1349    /// `Syntax` table the `Editor` method consults before refusing.
1350    ///
1351    /// It is a fact about the *format*, not about the caret. `true` does not
1352    /// promise the gesture succeeds where it is standing — a link over a table
1353    /// border still fails — only that it will not fail with
1354    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1355    /// will work here".
1356    pub fn supports(&self, gesture: Gesture) -> bool {
1357        self.format.supports(gesture)
1358    }
1359
1360    /// Every control's enabled state in one read — what a toolbar builds itself
1361    /// from when a document opens or its format changes. See [`Capabilities`].
1362    pub fn capabilities(&self) -> Capabilities {
1363        Capabilities::of(self.format)
1364    }
1365
1366    /// Refuse a gesture this document's format cannot spell, saying so in the
1367    /// status line. `true` means the caller must return without calling twig.
1368    ///
1369    /// Most of these refusals duplicate one twig would make anyway, and they are
1370    /// made here regardless because a message naming the *document's* format
1371    /// reads better than one naming twig's internals. Two of them are not
1372    /// duplicates and are the reason this is a guard rather than an error
1373    /// translation:
1374    ///
1375    /// - The table family (see [`table_op`](Self::table_op)) consults no
1376    ///   `Syntax` table, so twig does not refuse it at all.
1377    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1378    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1379    ///   could not keep.
1380    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1381        self.refuse_unless(what, self.supports(gesture))
1382    }
1383
1384    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1385    /// answers itself — today only [`spells_pipe_tables`].
1386    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1387        if supported {
1388            return false;
1389        }
1390        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1391        true
1392    }
1393
1394    /// The name to show for this document. An untitled one has no file to name
1395    /// it, and both frontends put this straight on screen — an empty path
1396    /// renders as an empty header, so it says so instead.
1397    pub fn file_name(&self) -> String {
1398        if self.is_untitled() {
1399            return "untitled".into();
1400        }
1401        self.path
1402            .file_name()
1403            .map(|s| s.to_string_lossy().into_owned())
1404            .unwrap_or_else(|| self.path.display().to_string())
1405    }
1406
1407    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1408    /// caret and anchor coincide (an empty selection is no selection).
1409    pub fn selection(&self) -> Option<(usize, usize)> {
1410        self.anchor
1411            .map(|a| (a.min(self.caret), a.max(self.caret)))
1412            .filter(|(s, e)| s != e)
1413    }
1414
1415    /// The selected text, or `None` when there's no selection — the source
1416    /// slice a copy/cut hands to the system clipboard.
1417    pub fn selected_text(&self) -> Option<&str> {
1418        self.selection().map(|(s, e)| &self.source[s..e])
1419    }
1420
1421    /// The selection as a quote with a little of what surrounds it — the shape
1422    /// a host that cites, annotates, or searches for a passage wants, cut from
1423    /// the **source** rather than from anything rendered, so the quote is
1424    /// findable in the document again by plain string search.
1425    ///
1426    /// `context` is a count of characters (not bytes) on each side, clipped at
1427    /// the document's edges; the slices land on char boundaries by
1428    /// construction. `None` when nothing is selected.
1429    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1430        let (start, end) = self.selection()?;
1431        let mut before = start;
1432        for _ in 0..context {
1433            match self.source[..before].chars().next_back() {
1434                Some(c) => before -= c.len_utf8(),
1435                None => break,
1436            }
1437        }
1438        let mut after = end;
1439        for _ in 0..context {
1440            match self.source[after..].chars().next() {
1441                Some(c) => after += c.len_utf8(),
1442                None => break,
1443            }
1444        }
1445        Some(Quote {
1446            exact: self.source[start..end].to_string(),
1447            prefix: self.source[before..start].to_string(),
1448            suffix: self.source[end..after].to_string(),
1449            start,
1450            end,
1451        })
1452    }
1453
1454    /// Whether the document refuses to change — see the field.
1455    pub fn read_only(&self) -> bool {
1456        self.read_only
1457    }
1458
1459    /// Turn the read-only gate on or off. A frontend preference like
1460    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1461    /// itself changes, only what may be done to it from here on.
1462    pub fn set_read_only(&mut self, on: bool) {
1463        self.read_only = on;
1464    }
1465
1466    /// The host-painted ranges, sorted by start — see [`Highlight`].
1467    pub fn highlights(&self) -> &[Highlight] {
1468        &self.highlights
1469    }
1470
1471    /// Replace the host-painted ranges wholesale. The whole set each time,
1472    /// rather than add/remove verbs: the host owns the list (it derives it
1473    /// from its own state — annotations, search hits), and a replace can
1474    /// never leave the two disagreeing about what should be on screen.
1475    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1476        highlights.retain(|h| h.start < h.end);
1477        highlights.sort_by_key(|h| (h.start, h.end));
1478        self.highlights = highlights;
1479    }
1480
1481    /// The highlight covering source `offset`, if one does — first by start
1482    /// when several overlap, which makes overlapping washes resolvable rather
1483    /// than undefined. What a frontend asks when the reader activates a spot.
1484    ///
1485    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1486    /// asking the same question per glyph, against a slice they were handed
1487    /// rather than against a `Doc`, and one answer for both is what keeps a
1488    /// wash and an activation agreeing about which range a spot is in.
1489    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1490        Highlight::covering(&self.highlights, offset)
1491    }
1492
1493    /// The AST breadcrumb at the caret (root → deepest), e.g.
1494    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1495    pub fn breadcrumb(&mut self) -> String {
1496        match self.editor.ancestors_at(self.caret) {
1497            Ok(chain) => chain
1498                .iter()
1499                .map(|m| m.kind.as_str())
1500                .collect::<Vec<_>>()
1501                .join(" › "),
1502            Err(_) => String::new(),
1503        }
1504    }
1505
1506    // ── editing ──────────────────────────────────────────────────────────────
1507
1508    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1509    /// after it. The public form of the internal splice — a pixel frontend that
1510    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1511    /// edits through this, the same twig `edit_range` the caret ops use.
1512    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1513        self.splice(start, end, text, EditKind::Other);
1514    }
1515
1516    /// Insert typed `text` at the caret, replacing the selection if there is one.
1517    /// A single typed character coalesces with the run of typing before it; a
1518    /// newline or a multi-character insert is its own undo step.
1519    ///
1520    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1521    pub fn insert(&mut self, text: &str) {
1522        // The read-only gate, up front: the paths below reach twig by several
1523        // verbs, not all of them through the splice — see the field.
1524        if self.read_only {
1525            return;
1526        }
1527        // Typing against a block picture would dissolve it — see
1528        // `open_paragraph_at_block_media`. Give the text a paragraph first, so
1529        // what the caret was standing beside stays a picture.
1530        self.open_paragraph_at_block_media(text);
1531        // Armed sticky marks (⌘b with no selection) turn the next typed text
1532        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1533        // the exception: it takes no mark of its own and keeps the delta armed
1534        // for the character behind it — see `insert_space_with_marks`.
1535        let pending = self.pending_here();
1536        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1537            if text.trim().is_empty() {
1538                self.insert_space_with_marks(self.caret, text, pending);
1539            } else {
1540                self.insert_with_marks(self.caret, text, pending);
1541            }
1542            return;
1543        }
1544        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1545        // anything that would open markup, so a Diaryx user never mints
1546        // formatting by keyboard (it comes from commands instead). The other two
1547        // rungs of the ladder author markup from what you type, which is the
1548        // whole difference between them and this one. Only in the rendered view
1549        // (source view is for typing raw markup) and only where the format has a
1550        // literal spelling at all: escaping is a backslash before a byte from the
1551        // format's own alphabet, and a format with no such alphabet (HTML escapes
1552        // with entities, XML spells nothing) would have `\&` written into it,
1553        // which is two literal characters and not an escape. Marks (⌘b) still
1554        // format — that path returned above; and leaf's own structural inserts go
1555        // through `insert_raw`, never here, so a list marker or quote gutter is
1556        // written as the markup it is.
1557        if !self.markup_mode.authors()
1558            && self.view == View::Wysiwyg
1559            && !text.is_empty()
1560            && self.supports(Gesture::InsertLiteral)
1561        {
1562            self.insert_literal_typed(text);
1563            return;
1564        }
1565        self.insert_raw(text);
1566    }
1567
1568    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1569    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1570    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1571    /// markup by design and must not be escaped.
1572    fn insert_raw(&mut self, text: &str) {
1573        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1574        self.splice(s, e, text, typed_edit_kind(text));
1575    }
1576
1577    /// Open a paragraph for text about to be inserted at one of a block media's
1578    /// two caret stops, and leave the caret standing in it.
1579    ///
1580    /// A block image is a paragraph whose entire content is the picture, and the
1581    /// caret's only homes on it are in front of it and just past it (see
1582    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1583    /// *that* paragraph — and a paragraph holding anything besides the image is
1584    /// no longer a block image but a line of text with an inline one in it. The
1585    /// frontend that was painting a photo there paints a text run instead; the
1586    /// picture is still in the file, and nothing said a word. Those two offsets
1587    /// are also exactly where a click on the picture lands, so the whole accident
1588    /// is one tap and one keystroke.
1589    ///
1590    /// So the break goes in first and the text lands in the new empty paragraph —
1591    /// what pressing Return before typing would have done, which is a habit no
1592    /// one should have to learn from losing a photo. A no-op everywhere else, and
1593    /// over a selection (which is replaced, not joined into).
1594    ///
1595    /// A picture inside a quote or a list leaves its container, because `\n\n`
1596    /// ends the block. The alternative is worse: the `\n> ` / next-item
1597    /// continuation [`newline`](Self::newline) writes stays in the same
1598    /// *paragraph*, which is the thing being prevented.
1599    ///
1600    /// Only in the rendered view. Source view is for typing raw markup, where
1601    /// putting a character against an image is exactly what it looks like.
1602    fn open_paragraph_at_block_media(&mut self, text: &str) {
1603        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1604            return;
1605        }
1606        if self.selection().is_some() {
1607            return;
1608        }
1609        // The map may be a revision behind (nothing has drawn since the last
1610        // edit), and this asks it about offsets — a stale answer would splice a
1611        // break into the wrong place. Free when it is already current, which it
1612        // is whenever a frontend drew a frame between keystrokes.
1613        self.rebuild_map();
1614        let at = self.caret;
1615        let Some((side, _)) = self.vmap.block_media_stop(at) else {
1616            return;
1617        };
1618        if !self.splice(at, at, "\n\n", EditKind::Other) {
1619            return;
1620        }
1621        // The break is part of the keystroke, not an edit of its own: leave the
1622        // run marked as typing so the character about to arrive folds into it and
1623        // one undo puts the document back the way it was found. (A paste, or a
1624        // multi-character insert, is `EditKind::Other` and stays its own step —
1625        // as it would have been anywhere else in the document.)
1626        self.last_edit_kind = Some(EditKind::Insert);
1627        if side == MediaStop::Before {
1628            // The break went in above the picture and the caret rode to the end
1629            // of it — which is still hard against the picture. Step back onto the
1630            // blank line it opened, so the text lands above rather than in front.
1631            self.caret = at;
1632        }
1633    }
1634
1635    /// A delete key pressed at one of a block picture's two caret stops, handled
1636    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1637    /// the key was consumed.
1638    ///
1639    /// The caret rests in front of a block image and just past it, never inside
1640    /// its markup — which the rendered view doesn't show. So the byte a delete
1641    /// key nominally takes there is one the writer cannot see, and taking it
1642    /// leaves the picture as broken markup rather than as anything anyone asked
1643    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1644    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1645    /// missing from a document with nobody having touched it — the same
1646    /// dissolution [`open_paragraph_at_block_media`](Self::open_paragraph_at_block_media)
1647    /// prevents from the typing side, and it cost this repository's own test vault
1648    /// a photo before it was found.
1649    ///
1650    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1651    /// when it is behind the caret, Delete when it is in front — which is what
1652    /// every editor does with an embed, and one undo away. The key aimed *away*
1653    /// from it would otherwise delete the paragraph break and merge a neighbour
1654    /// into the picture's own paragraph, which dissolves it just as surely; it
1655    /// steps the caret over the boundary instead and leaves the
1656    /// next press to delete in the block it has reached — the same "first press
1657    /// steps out of the atom, second press deletes" every delete key here gets,
1658    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1659    /// above, and reaches it on the second press rather than taking the break and
1660    /// the picture with it on the first).
1661    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1662        // The map answers about offsets, so it has to be this revision's — see
1663        // the same call in `open_paragraph_at_block_media`.
1664        self.rebuild_map();
1665        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1666            return false;
1667        };
1668        let aimed_at_it = side
1669            == if forward {
1670                MediaStop::Before
1671            } else {
1672                MediaStop::After
1673            };
1674        if !aimed_at_it {
1675            let over = if forward {
1676                self.vmap.stop_after(self.caret)
1677            } else {
1678                self.vmap.stop_before(self.caret)
1679            };
1680            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1681                self.caret = off;
1682                self.anchor = None;
1683                self.goal_col = None;
1684            }
1685            return true;
1686        }
1687        // Take the break that held the picture apart from its neighbour with it,
1688        // so the delete doesn't leave a blank paragraph standing where the
1689        // picture was. The last arm is a picture that is the whole document.
1690        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1691            (span.start - 2, span.end)
1692        } else if self.source[span.end..].starts_with("\n\n") {
1693            (span.start, span.end + 2)
1694        } else {
1695            (span.start, span.end)
1696        };
1697        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1698        true
1699    }
1700
1701    /// The Hidden-mode typing path: replace any selection, then insert `text`
1702    /// escaped so it stays literal. When it replaces a selection the two edits
1703    /// fold into one undo step, so an overwrite undoes atomically (and restores
1704    /// the selection) exactly as a plain one does.
1705    fn insert_literal_typed(&mut self, text: &str) {
1706        let kind = typed_edit_kind(text);
1707        match self.selection() {
1708            Some((s, e)) => {
1709                if !self.splice(s, e, "", EditKind::Other) {
1710                    return;
1711                }
1712                // Typing over a whole marked run takes its delimiters with it
1713                // (the empty content couldn't hold them — see
1714                // `repair_mark_edges`) and leaves its marks armed at the caret.
1715                // The text taking the run's place inherits them, exactly as it
1716                // would have by landing inside a run that survived.
1717                let pending = self.pending_here();
1718                if !pending.is_empty() && !text.trim().is_empty() {
1719                    self.insert_with_marks(self.caret, text, pending);
1720                    return;
1721                }
1722                self.insert_literal_at(self.caret, text, kind, true);
1723            }
1724            None => {
1725                self.insert_literal_at(self.caret, text, kind, false);
1726            }
1727        }
1728    }
1729
1730    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1731    /// at a collapsed caret, but only while the caret still stands where they
1732    /// were armed and nothing is selected. Empty otherwise, so a stale delta
1733    /// never styles text it wasn't meant for.
1734    fn pending_here(&self) -> InlineMarks {
1735        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1736            self.pending_marks
1737        } else {
1738            InlineMarks::empty()
1739        }
1740    }
1741
1742    /// Drop the armed sticky marks — any caret motion, selection, or edit does
1743    /// this, so "start bold here" only ever applies at the exact spot it was
1744    /// asked for.
1745    fn clear_pending(&mut self) {
1746        self.pending_marks = InlineMarks::empty();
1747        self.pending_at = None;
1748    }
1749
1750    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1751    /// force is wrapped around the freshly typed text; a mark the caret already
1752    /// stands inside is *shed* — the text is inserted past the run's end so it
1753    /// lands unmarked ("type normally again"). The caret comes to rest inside any
1754    /// added runs, so continued typing inherits the marks with no re-wrapping,
1755    /// and the delta is cleared: the marks now live in the document, not here.
1756    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1757        let base = self.mark_spans_at(at);
1758        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1759        // Nothing to shed, and a run of exactly these marks standing just behind
1760        // the caret: carry on writing *that* run rather than opening a second
1761        // one beside it.
1762        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1763            return;
1764        }
1765        // Shed the marks we're turning off: step the insertion point past the
1766        // end of each run the caret sits in, so the new text falls outside it.
1767        let mut ins_at = at;
1768        for (kind, span) in &base {
1769            if marks.contains(*kind) {
1770                ins_at = ins_at.max(span.end);
1771            }
1772        }
1773        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1774            return;
1775        }
1776        // The plain splice inserted exactly `text` at `ins_at`; that byte range
1777        // is the content every added mark wraps.
1778        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1779        for kind in marks.iter() {
1780            if !base_set.contains(kind) {
1781                let (ncs, nce) = self.wrap_span(cs, ce, kind);
1782                cs = ncs;
1783                ce = nce;
1784            }
1785        }
1786        self.caret = ce.min(self.source.len());
1787        self.anchor = None;
1788        self.last_edit_kind = None;
1789        // Realised: the marks are in the document now, and the caret sits inside
1790        // them, so there is no delta left to carry. Arm nothing, but remember the
1791        // spot so a *further* toggle before typing starts a clean delta here.
1792        self.pending_marks = InlineMarks::empty();
1793        self.pending_at = Some(self.caret);
1794        self.clamp_caret();
1795        self.record_caret();
1796    }
1797
1798    /// Carry on the marked run just behind `at` — moving its closing delimiters
1799    /// out past the new text — instead of opening a second run of the same marks
1800    /// beside it. Returns whether it did.
1801    ///
1802    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1803    /// A space typed after a bold word steps the caret out of the run, because
1804    /// `**bold **` is not bold; the next character has to step back *in*, or the
1805    /// writer who typed one bold phrase is left with `**bold** **and**` — two
1806    /// runs that read the same to a reader but spell the file in a way nobody
1807    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1808    /// words it isn't marking), and the marks behind it must be exactly the ones
1809    /// armed — a run of *some* other kind is a neighbour, not this phrase.
1810    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1811        if text.is_empty() || text.trim() != text {
1812            return false;
1813        }
1814        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1815        // Walk in through the delimiters stacked at that point, innermost last:
1816        // `***both*** ` closes two runs with one `***`, and rejoining means
1817        // getting behind all of them.
1818        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1819        while let Some((kind, content_end)) = self
1820            .editor
1821            .ancestors_at(prev_boundary(&self.source, cut))
1822            .unwrap_or_default()
1823            .into_iter()
1824            .filter(|m| m.span.end == cut)
1825            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1826        {
1827            if content_end >= cut {
1828                break; // a mark with no closing delimiter to step behind
1829            }
1830            kinds.insert(kind);
1831            cut = content_end;
1832        }
1833        if cut == gap_at || kinds != marks {
1834            return false;
1835        }
1836        // Re-spell the tail: the gap, then the new text, then the delimiters that
1837        // used to close in front of them — read out of the document rather than
1838        // written from a table, so whatever twig spells them with is what moves.
1839        let tail = format!(
1840            "{}{text}{}",
1841            &self.source[gap_at..at],
1842            &self.source[cut..gap_at]
1843        );
1844        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1845            return false;
1846        }
1847        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1848        self.anchor = None;
1849        self.last_edit_kind = None;
1850        self.pending_marks = InlineMarks::empty();
1851        self.pending_at = Some(self.caret);
1852        self.clamp_caret();
1853        self.record_caret();
1854        true
1855    }
1856
1857    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1858    /// never itself wrapped: a mark around a space draws nothing a reader can
1859    /// see, and in Markdown and Djot it draws its own delimiters instead
1860    /// (`** **`). So the space goes in unmarked — outside any run the armed
1861    /// marks are shedding — and the marks stay armed for the character after it,
1862    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1863    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1864        let base = self.mark_spans_at(at);
1865        // What the *next* character carries: the armed delta resolved against the
1866        // marks in force here, which the space must not quietly drop.
1867        let want = base
1868            .iter()
1869            .map(|(k, _)| *k)
1870            .collect::<InlineMarks>()
1871            .xor(marks);
1872        let mut ins_at = at;
1873        for (kind, span) in &base {
1874            if marks.contains(*kind) {
1875                ins_at = ins_at.max(span.end);
1876            }
1877        }
1878        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
1879            return;
1880        }
1881        self.rearm(want);
1882        self.record_caret();
1883    }
1884
1885    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
1886    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
1887    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
1888    /// added split evenly around the content — half the growth on each side.
1889    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
1890        // The read-only gate — this door reaches twig without the splice.
1891        if self.read_only {
1892            return (s, e);
1893        }
1894        match self.editor.toggle_inline(s, e, kind) {
1895            Ok(change) => {
1896                self.last_edit_kind = None;
1897                self.refresh();
1898                self.dirty = self.source != self.clean_source;
1899                let added = (change.new.end - change.new.start).saturating_sub(e - s);
1900                let half = added / 2;
1901                (change.new.start + half, change.new.end - half)
1902            }
1903            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
1904            // rather than lose the keystroke.
1905            Err(e2) => {
1906                self.status = Some(format!("{kind:?}: {e2}"));
1907                (s, e)
1908            }
1909        }
1910    }
1911
1912    /// The safe offset to splice a block-level break at, given a caret that may
1913    /// sit exactly between an inline mark's content and its own closing
1914    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
1915    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
1916    /// with nothing following it on the line: the closing `**` renders no
1917    /// glyph of its own, so the caret's "end of line" offset lands right
1918    /// before it). Splicing a paragraph/list/quote break at `off` itself would
1919    /// sever the delimiter from its content, stranding it alone on the new
1920    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
1921    /// nested marks closing at the same point (`**_x_**`) all clear together.
1922    /// A no-op everywhere else — mid-run, or past real trailing content, no
1923    /// mark's `content_span` ends exactly at `off`.
1924    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
1925        let off = off.min(self.source.len());
1926        self.editor
1927            .ancestors_at(off)
1928            .unwrap_or_default()
1929            .into_iter()
1930            .filter(|m| inline_kind(&m.kind).is_some())
1931            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
1932            .map(|m| m.span.end)
1933            .max()
1934            .unwrap_or(off)
1935    }
1936
1937    /// The offset a *delete* aimed at the character before `off` should stop at,
1938    /// when `off` is the start of a run's text and the bytes behind it are that
1939    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
1940    /// byte behind the caret at the start of a bold word is not a character the
1941    /// writer can see, let alone one they aimed Backspace at: taking it leaves
1942    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
1943    /// delete steps over the whole delimiter to the visible character in front of
1944    /// it instead. Walks out to the *outermost* mark opening there, so
1945    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
1946    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
1947        let off = off.min(self.source.len());
1948        self.editor
1949            .ancestors_at(off)
1950            .unwrap_or_default()
1951            .into_iter()
1952            .filter(|m| inline_kind(&m.kind).is_some())
1953            .filter(|m| {
1954                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
1955            })
1956            .map(|m| m.span.start)
1957            .min()
1958            .unwrap_or(off)
1959    }
1960
1961    /// `off` moved *inside* the run whose closing delimiters end there — the
1962    /// other offset the rich view draws in the same place, since a `**` renders
1963    /// no glyph of its own. `**bold**` has a caret home on each side of its
1964    /// closing delimiter, one column apart on screen and eight bytes and a whole
1965    /// run apart in the file, and a plain ← lands on the outer one whenever a
1966    /// space follows the phrase. The inner one is what the writer is pointing at
1967    /// there: the end of their bold word. Walks in through every mark closing at
1968    /// that point, innermost last, so `***both***` lands inside both. A no-op
1969    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
1970    fn step_inside_close_delims(&mut self, off: usize) -> usize {
1971        let mut off = off.min(self.source.len());
1972        loop {
1973            let inner = self
1974                .editor
1975                .ancestors_at(prev_boundary(&self.source, off))
1976                .unwrap_or_default()
1977                .into_iter()
1978                .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
1979                .filter_map(|m| m.content_span.clone().map(|c| c.end))
1980                .filter(|&end| end < off)
1981                .max();
1982            match inner {
1983                Some(end) => off = end,
1984                None => return off,
1985            }
1986        }
1987    }
1988
1989    /// The mirror at the opening edge: `off` moved inside the run whose
1990    /// delimiters *start* there, onto the first character of its text. See
1991    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
1992    fn step_inside_open_delims(&mut self, off: usize) -> usize {
1993        let mut off = off.min(self.source.len());
1994        loop {
1995            let inner = self
1996                .editor
1997                .ancestors_at(off)
1998                .unwrap_or_default()
1999                .into_iter()
2000                .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
2001                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2002                .filter(|&start| start > off)
2003                .min();
2004            match inner {
2005                Some(start) => off = start,
2006                None => return off,
2007            }
2008        }
2009    }
2010
2011    /// The inline mark kinds whose span covers `off`, each with that span — the
2012    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2013    /// ids instead. Used to shed a mark by stepping past the end of its run.
2014    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2015        let off = off.min(self.source.len());
2016        self.editor
2017            .ancestors_at(off)
2018            .unwrap_or_default()
2019            .into_iter()
2020            .filter(|m| off < m.span.end)
2021            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2022            .collect()
2023    }
2024
2025    /// Insert clipboard `text` at the caret, replacing the selection if there is
2026    /// one — always its own undo step, whatever its length.
2027    ///
2028    /// Provenance is the whole point, and only the caller has it. `insert` reads
2029    /// a lone character as a keystroke and folds it into the run around it,
2030    /// which is right for typing and wrong for a one-character paste: that paste
2031    /// would vanish mid-run on an undo it was never part of, and the characters
2032    /// the user actually typed would go with it. Length can't tell the two
2033    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2034    /// caller comes through is what says which happened.
2035    pub fn paste(&mut self, text: &str) {
2036        // Pasting against a block picture dissolves it exactly as typing does,
2037        // and for the same reason — see `open_paragraph_at_block_media`.
2038        self.open_paragraph_at_block_media(text);
2039        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2040        self.splice(s, e, text, EditKind::Other);
2041    }
2042
2043    /// Replace `[start, end)` with `text` as one step of an IME composition —
2044    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2045    /// folds into a single undo.
2046    ///
2047    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2048    /// dozen calls here, each replacing the last one's provisional bytes, and an
2049    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2050    /// unspools backwards through kana — the intermediate states were never text
2051    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2052    /// look like any other edit), so the door the caller comes through is what
2053    /// says so, exactly as it is for [`paste`](Self::paste) versus
2054    /// [`insert`](Self::insert).
2055    ///
2056    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2057    /// composition folds into this one.
2058    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2059        self.splice(start, end, text, EditKind::Compose);
2060    }
2061
2062    /// Close the open composition run, so the next one is its own undo step.
2063    /// Call when the IME commits or withdraws a composition.
2064    ///
2065    /// Only clears a *composition* run: a frontend that reports an end it never
2066    /// began (some IMEs unmark unprompted) would otherwise split the run of
2067    /// typing around it into two undo steps for no reason the user can see.
2068    pub fn end_composition(&mut self) {
2069        if self.last_edit_kind == Some(EditKind::Compose) {
2070            self.last_edit_kind = None;
2071        }
2072    }
2073
2074    // ── the clipboard's rich flavor ──────────────────────────────────────────
2075
2076    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2077    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2078    /// nothing is selected, or when the selection doesn't render (the caller
2079    /// still has [`selected_text`](Self::selected_text), which is what to publish
2080    /// as `text/plain` either way).
2081    ///
2082    /// **The fragment is a source substring, and that is the honest limit here.**
2083    /// It's parsed standalone, so a selection whose meaning depends on its
2084    /// surroundings converts as what it literally says rather than what it looks
2085    /// like on screen: half a list item is a paragraph, a row torn out of a table
2086    /// is the text of a row, the `**` of a bold run selected without its closing
2087    /// `**` is two asterisks. Every one of those still *renders* — there's no
2088    /// error to report — it just renders as the fragment and not as the document.
2089    /// Widening the range to whole blocks would publish text the user didn't
2090    /// select, which is a worse lie than a fragment being a fragment; the plain
2091    /// flavor has the same substring, so the two flavors at least agree.
2092    pub fn selection_html(&mut self) -> Option<String> {
2093        let (start, end) = self.selection()?;
2094        let inline = self.selection_is_inline(start, end);
2095        let html = html::render_fragment(&self.source[start..end], self.format)?;
2096        Some(match inline {
2097            true => html::strip_sole_paragraph(html),
2098            false => html,
2099        })
2100    }
2101
2102    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2103    /// format first. Its own undo step, like any [`paste`](Self::paste).
2104    ///
2105    /// Returns whether it landed. `false` means the HTML didn't convert to
2106    /// anything worth pasting — the caller should fall back to the plain flavor
2107    /// rather than treat it as an error. The `html` module has the full list of
2108    /// what that covers: a table twig won't build, markup it doesn't recognise,
2109    /// an empty result.
2110    pub fn paste_html(&mut self, html: &str) -> bool {
2111        match html::parse_fragment(html, self.format) {
2112            Some(source) => {
2113                self.paste(&source);
2114                true
2115            }
2116            None => false,
2117        }
2118    }
2119
2120    /// Does the selection live *inside* a single top-level block?
2121    ///
2122    /// The question [`selection_html`](Self::selection_html) needs and the
2123    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2124    /// whether the user selected one word of a sentence or a whole paragraph, and
2125    /// only the document knows which. Selecting a word and pasting into Docs
2126    /// should extend the line you paste into; selecting the paragraph should make
2127    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2128    /// is an artifact of standalone parsing), and one that covers a whole block —
2129    /// or spans two — keeps its structure.
2130    ///
2131    /// Reads the block from twig rather than guessing from the bytes:
2132    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2133    /// block containing an offset, and two ends inside the same one cannot have
2134    /// crossed a block boundary.
2135    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2136        // The last *character*, not `end - 1`: the selection's end is exclusive
2137        // and may sit mid-codepoint's-worth of bytes past the last char.
2138        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2139            return false;
2140        };
2141        let (Some(head), Some(tail)) =
2142            (self.top_block_span(start), self.top_block_span(start + off))
2143        else {
2144            return false;
2145        };
2146        head == tail && !(start <= head.start && end >= head.end)
2147    }
2148
2149    /// The byte span of the top-level block containing `offset`, or `None` at an
2150    /// offset that belongs to no block (the blank line between two of them).
2151    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2152        self.editor
2153            .ancestors_at(offset)
2154            .ok()?
2155            .get(1)
2156            .map(|m| m.span.clone())
2157    }
2158
2159    // ── indentation ──────────────────────────────────────────────────────────
2160
2161    /// One indent level.
2162    ///
2163    /// Two spaces, not the four both frontends type for Tab today, because in a
2164    /// markdown document four columns isn't a width — it's a *meaning*. Four
2165    /// spaces at the head of a line is markdown's indented-code-block marker, so
2166    /// one Tab on a paragraph would reparse it into code and style it as such;
2167    /// two cannot, and the line stays the prose it was. Two is also exactly
2168    /// where a `- ` bullet's content starts, so an indented line lands under its
2169    /// parent item's text instead of beside it — the column a list-aware indent
2170    /// has to hit anyway, which keeps this width from being relitigated later.
2171    const INDENT: &'static str = "  ";
2172
2173    /// Indent the selected lines — or the caret's line, with no selection — by
2174    /// one level (Tab).
2175    pub fn indent(&mut self) {
2176        self.reindent(true);
2177        // Nesting changes an ordered list's numbering (the nested item restarts,
2178        // its old siblings resume) — keep the source markers in step.
2179        self.renumber_here();
2180        // Nesting an empty `-` item under a text line reparses that text as a
2181        // setext heading; swap the dash for a `*` before it can (a no-op unless
2182        // the collapse actually happened).
2183        self.avoid_setext_collapse();
2184    }
2185
2186    /// Take one indent level back off the selected lines, or the caret's line
2187    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2188    ///
2189    /// A line with *less* than a full level gives back what it has rather than
2190    /// refusing: outdent's job is to walk a line left, and real documents — hand
2191    /// written, or reflowed by some other editor — are full of indentation that
2192    /// was never a clean multiple of anything. Refusing there would strand the
2193    /// line at a depth Shift+Tab couldn't undo.
2194    pub fn outdent(&mut self) {
2195        self.reindent(false);
2196        self.renumber_here();
2197    }
2198
2199    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2200    ///
2201    /// One splice across the whole line range, never one per line: a Tab is one
2202    /// thing the user did, so it has to be one undo step and one reparse. Per
2203    /// line, twig would reparse the document once per line and leave a stack of
2204    /// steps that Shift+⌘Z walks back one line at a time.
2205    fn reindent(&mut self, add: bool) {
2206        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2207        let start = source_line_range(&self.source, sel_start).start;
2208        let end = source_line_range(&self.source, sel_end).end;
2209        let region = self.source[start..end].to_string();
2210        let lines: Vec<&str> = region.split('\n').collect();
2211        // A blank line has no text to move, and padding it would leave nothing
2212        // but trailing whitespace — but Tab on a blank line *is* a request for
2213        // indentation to type into, so the skip only applies where the op has
2214        // other lines to do real work on.
2215        let skip_blank = add && lines.len() > 1;
2216
2217        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2218        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2219        let mut line_off = start;
2220        for (i, full) in lines.iter().enumerate() {
2221            if i > 0 {
2222                out.push('\n');
2223            }
2224            // A list item moves by having its whole leading prefix *replaced*,
2225            // never by having spaces pushed in front of the line. twig spells
2226            // both prefixes, so the quote markers, the parent's indent and an
2227            // ordered marker's extra column all come out right without leaf
2228            // measuring any of them — and a line that only looks like an item
2229            // (a Djot continuation) reports no marker and is left to the plain
2230            // path, where a Tab is just a Tab.
2231            let marker = self.list_marker_on_line(line_off);
2232            let own = marker
2233                .as_ref()
2234                .map(|m| m.marker_start - m.line_start)
2235                .unwrap_or(0);
2236            let delta = if add {
2237                if skip_blank && full.trim().is_empty() {
2238                    out.push_str(full);
2239                    0
2240                } else if marker.is_some() && self.first_item_of_list(line_off) {
2241                    // The first item of a list has no preceding sibling to nest
2242                    // under, so a Tab here can't spell a sub-list — twig would
2243                    // reparse the shoved-over marker as the same list, only
2244                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2245                    // item where it is, the way every list editor refuses to
2246                    // over-indent a list's first line.
2247                    out.push_str(full);
2248                    0
2249                } else if marker.is_some() {
2250                    // Nesting means standing where a *continuation* of this line
2251                    // would stand: past the parent's marker, inside its content
2252                    // column. That is `continuation_prefix`, less a checkbox.
2253                    let new = self.nesting_prefix_at(line_off);
2254                    let delta = new.len() as isize - own as isize;
2255                    out.push_str(&new);
2256                    out.push_str(&full[own..]);
2257                    delta
2258                } else {
2259                    out.push_str(Self::INDENT);
2260                    out.push_str(full);
2261                    Self::INDENT.len() as isize
2262                }
2263            } else if marker.is_some() {
2264                // Unnesting is the mirror: stand where the parent item's own
2265                // line starts, which drops exactly the level it contributed.
2266                let new = self.outdent_prefix_at(line_off);
2267                let delta = new.len() as isize - own as isize;
2268                out.push_str(&new);
2269                out.push_str(&full[own..]);
2270                delta
2271            } else {
2272                // A plain line gives back the ordinary step.
2273                let strip = outdent_width(full, Self::INDENT.len());
2274                out.push_str(&full[strip..]);
2275                -(strip as isize)
2276            };
2277            deltas.push(delta);
2278            line_off += full.len() + 1;
2279        }
2280        // Nothing to give back. Returning before the splice keeps an outdent at
2281        // column zero from spending an undo step on a document it never changed.
2282        if deltas.iter().all(|d| *d == 0) {
2283            return;
2284        }
2285
2286        // Every line's text keeps its offset *within the line*, so the caret is
2287        // remapped by its column, not by its byte offset — which the prefixes on
2288        // the lines above it have already invalidated.
2289        let remap = |off: usize| -> usize {
2290            let (mut old_ls, mut new_ls) = (start, start);
2291            for (line, delta) in lines.iter().zip(&deltas) {
2292                let old_le = old_ls + line.len();
2293                let new_len = (line.len() as isize + delta) as usize;
2294                if off <= old_le {
2295                    let col = (off - old_ls) as isize;
2296                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2297                }
2298                old_ls = old_le + 1;
2299                new_ls += new_len + 1;
2300            }
2301            start + out.len()
2302        };
2303        let placed = match self.selection() {
2304            // Keep the rewritten region selected, the way a container toggle
2305            // keeps its own: it leaves a second Tab aimed at the same lines
2306            // rather than at whatever the shifted offsets now happen to cover.
2307            Some(_) => (start + out.len(), Some(start)),
2308            None => (remap(self.caret), None),
2309        };
2310
2311        // A rolled-back splice leaves the old source in place, where every offset
2312        // computed above addresses text that was never written.
2313        if !self.splice(start, end, &out, EditKind::Other) {
2314            return;
2315        }
2316        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2317        // rewrite is the last line's end — nowhere the caret was. Place it, then
2318        // re-record the caret so this is the state redo restores, not the one
2319        // `splice` left behind from the `Change`.
2320        self.caret = placed.0.min(self.source.len());
2321        self.anchor = placed.1;
2322        self.clamp_caret();
2323        self.record_caret();
2324    }
2325
2326    /// The Enter key.
2327    ///
2328    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2329    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2330    /// caret is in decides what actually gets written.
2331    ///
2332    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2333    ///                            block at the caret and reopens its container
2334    ///   - list item            → likewise: the next item, its indent, quote
2335    ///                            prefix and `[ ]` box all reproduced by twig —
2336    ///                            except an *empty* item, which exits the list
2337    ///   - block quote          → likewise: a new paragraph inside the quote
2338    ///   - heading              → a new *paragraph*, not another heading
2339    ///   - code block           → a literal newline (stay in the block)
2340    ///   - blank line           → a literal newline (one Backspace undoes it)
2341    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2342    ///                            visible line
2343    ///
2344    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2345    /// and it is better at it: it drops the whitespace the caret was sitting in
2346    /// front of instead of stranding it at the head of the second half, and it
2347    /// knows continuations leaf's marker scan never covered — a checklist item
2348    /// continues as an *unchecked* checklist item rather than a plain bullet.
2349    ///
2350    /// The exceptions above are exceptions because `split_block` is either wrong
2351    /// there or refuses: parting a fence yields two fences with the code split
2352    /// between them, parting a heading yields a second heading where every editor
2353    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2354    /// table all report an error rather than a split.
2355    pub fn newline(&mut self) {
2356        if self.view == View::Source {
2357            self.insert_raw("\n");
2358            return;
2359        }
2360        // Enter over a selection replaces it with a paragraph break.
2361        if let Some((s, e)) = self.selection() {
2362            self.splice(s, e, "\n\n", EditKind::Other);
2363            return;
2364        }
2365        // A caret resting exactly between an inline mark's content and its own
2366        // closing delimiter (`**bold**` with nothing after it on the line —
2367        // the WYSIWYG caret's natural end-of-line position) must not splice a
2368        // block break there: every path below eventually does via
2369        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2370        // delimiter would strand it alone on the new line.
2371        self.caret = self.skip_trailing_close_delims(self.caret);
2372        // The block the caret is in. `block_offset_for_caret` nudges off a line
2373        // end (where the caret sits at the doc level); on a bare line (e.g. an
2374        // empty list item) fall back to the caret so the enclosing list/quote is
2375        // still visible in the ancestors.
2376        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2377        let kinds: Vec<Kind> = self
2378            .editor
2379            .ancestors_at(off)
2380            .map(|c| c.into_iter().map(|m| m.kind).collect())
2381            .unwrap_or_default();
2382        let has = |k: Kind| kinds.contains(&k);
2383
2384        if has(Kind::CodeBlock) {
2385            self.insert_raw("\n");
2386            return;
2387        }
2388        // An *empty* list item exits the list — the standard double-Enter — which
2389        // `split_block` reports as an error rather than a split (there is no
2390        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2391        // the AST gate — it answers from the tree, so a `- ` that reads as a
2392        // marker byte-for-byte but opens no item (a setext underline, a Djot
2393        // continuation line) never reaches here.
2394        if let Some(marker) = self.list_marker_on_line(self.caret)
2395            && self.item_is_empty(&marker)
2396        {
2397            self.exit_list(&marker);
2398            return;
2399        }
2400        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2401        // not another full paragraph break — so it moves down one line and one
2402        // Backspace undoes it, not two. (`split_block` errors here too.)
2403        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2404        let line_end = self.source[self.caret..]
2405            .find('\n')
2406            .map_or(self.source.len(), |i| self.caret + i);
2407        if self.source[line_start..line_end].trim().is_empty() {
2408            self.insert_raw("\n");
2409            return;
2410        }
2411        // In `Preserve` flow a soft break is a *visible* line the author means to
2412        // make, so Enter writes a single `\n` and typing continues the same
2413        // paragraph on the next line — the behaviour of an ordinary text editor.
2414        // A second Enter then lands on the blank line above and takes the
2415        // empty-line branch, so double-Enter still promotes to a full paragraph
2416        // break; and Backspace, which deletes a lone `\n` over a soft break,
2417        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2418        // render as an invisible space, so Enter keeps making the paragraph break
2419        // that actually shows.
2420        //
2421        // Only in running prose. A list or a quote has a continuation of its own
2422        // to write, and a `\n` there is not a soft line but a lost container.
2423        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2424        if self.line_flow == LineFlow::Preserve && !in_container {
2425            self.insert_raw("\n");
2426            return;
2427        }
2428        // A heading gets a *paragraph*, never a second heading: Enter at the end
2429        // of a title is how every editor is asked for the body under it, and
2430        // `split_block` would repeat the `#` instead. Whitespace at the split
2431        // point goes with the break rather than opening the new paragraph, which
2432        // is what `split_block` does everywhere else.
2433        if has(Kind::Heading) {
2434            let mut end = self.caret;
2435            while self.source.as_bytes().get(end) == Some(&b' ') {
2436                end += 1;
2437            }
2438            self.splice(self.caret, end, "\n\n", EditKind::Other);
2439            return;
2440        }
2441        self.split_block_here();
2442    }
2443
2444    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2445    /// the caret in the second half.
2446    ///
2447    /// twig reopens whatever the first half was inside of — the bullet with its
2448    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2449    /// reason this replaced the markup leaf used to spell from the line's bytes.
2450    /// It renumbers nothing, though: a new item mid-list is written with its
2451    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2452    /// behind it, folded into the same undo step.
2453    ///
2454    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2455    /// shape still moves the caret down rather than swallowing the keystroke.
2456    fn split_block_here(&mut self) {
2457        // The read-only gate — this door reaches twig without the splice.
2458        if self.read_only {
2459            return;
2460        }
2461        match self.editor.split_block(self.caret) {
2462            Ok(change) => {
2463                self.last_edit_kind = None;
2464                self.refresh();
2465                self.anchor = None;
2466                self.caret = change.new.end;
2467                self.dirty = self.source != self.clean_source;
2468                self.status = None;
2469                self.clamp_caret();
2470                self.record_caret();
2471                // Aimed at the new block's *start*: the caret twig leaves is one
2472                // past the marker it wrote, where there is no list in reach.
2473                self.renumber_at(change.new.start);
2474            }
2475            Err(_) => self.insert_raw("\n\n"),
2476        }
2477    }
2478
2479    /// Whether the item on the marker's line carries no content — the shape
2480    /// double-Enter reads as "I'm done with this list."
2481    fn item_is_empty(&self, line: &ListMarker) -> bool {
2482        let content_start = line.content_start().min(self.source.len());
2483        let line_end = self.source[self.caret..]
2484            .find('\n')
2485            .map(|i| self.caret + i)
2486            .unwrap_or(self.source.len());
2487        self.source[content_start..line_end.max(content_start)]
2488            .trim()
2489            .is_empty()
2490    }
2491
2492    /// Leave the list: replace the empty item's marker with a blank line, so the
2493    /// caret lands in a fresh paragraph below it.
2494    ///
2495    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2496    /// quote), and the caret's new line keeps the `> ` it was already behind —
2497    /// leaving the list without also leaving the quote.
2498    fn exit_list(&mut self, line: &ListMarker) {
2499        let prefix = self.quote_prefix_at(line.marker_start);
2500        let blank = prefix.trim_end();
2501        self.splice(
2502            line.line_start,
2503            self.caret,
2504            &format!("{blank}\n{prefix}"),
2505            EditKind::Other,
2506        );
2507    }
2508
2509    /// What a line continuing the containers at `off` has to open with — the
2510    /// quote markers reproduced, each enclosing item's marker as its width in
2511    /// spaces. Also the column a nested item's marker stands in, which is what
2512    /// makes it Tab's answer.
2513    fn continuation_prefix_at(&mut self, off: usize) -> String {
2514        self.editor
2515            .document()
2516            .and_then(|mut d| d.continuation_prefix(off))
2517            .map(|p| p.text)
2518            .unwrap_or_default()
2519    }
2520
2521    /// The column a *nested list* may open at inside the item at `off` — which
2522    /// is not always where the item's own text continues.
2523    ///
2524    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2525    /// it is markup a rich view hides, and the item's own wrapped text does
2526    /// stand past it. But a nested list may only open at the *list* marker's
2527    /// column, and four columns further in is an indented continuation of the
2528    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2529    /// So the box's own width goes back.
2530    ///
2531    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2532    /// reports the list marker's column apart from the box; `checked` is what
2533    /// says a box is there at all, so only its width is being measured here.
2534    fn nesting_prefix_at(&mut self, off: usize) -> String {
2535        let cont = self.continuation_prefix_at(off);
2536        let Some(item) = self.innermost_list_item(off) else {
2537            return cont;
2538        };
2539        if item.checked.is_none() {
2540            return cont;
2541        }
2542        let box_width = item
2543            .marker_span
2544            .and_then(|m| self.source.get(m))
2545            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2546            .unwrap_or(0);
2547        // The trailing columns are the ones the item's own marker contributed,
2548        // so trimming from the end leaves any quote prefix standing.
2549        cont[..cont.len().saturating_sub(box_width)].to_string()
2550    }
2551
2552    /// Where the line of the item *containing* the item at `off` begins — the
2553    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2554    /// parent contributed. The quote prefix alone for a top-level item, which
2555    /// has no level left to give.
2556    fn outdent_prefix_at(&mut self, off: usize) -> String {
2557        let items: Vec<usize> = self
2558            .editor
2559            .document()
2560            .and_then(|mut d| d.ancestors_at_caret(off))
2561            .map(|c| {
2562                c.into_iter()
2563                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2564                    .map(|m| m.span.start)
2565                    .collect()
2566            })
2567            .unwrap_or_default();
2568        // The second-innermost item is the parent; its own line's indent is the
2569        // target. `list_marker_on_line` gives that line's prefix directly.
2570        let parent = items.len().checked_sub(2).map(|i| items[i]);
2571        match parent.and_then(|p| self.list_marker_on_line(p)) {
2572            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2573            None => self.quote_prefix_at(off),
2574        }
2575    }
2576
2577    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2578    /// inside one, `"> > "` inside two.
2579    ///
2580    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2581    /// the `>` and the space after it are twig's spelling rather than leaf's.
2582    /// The whole line prefix can't answer this: it also carries the indent of
2583    /// whatever the quote holds, which a blank separator line must *not* repeat.
2584    fn quote_prefix_at(&mut self, off: usize) -> String {
2585        let Ok(chain) = self
2586            .editor
2587            .document()
2588            .and_then(|mut d| d.ancestors_at_caret(off))
2589        else {
2590            return String::new();
2591        };
2592        let quotes: Vec<usize> = chain
2593            .iter()
2594            .filter(|m| m.kind == Kind::BlockQuote)
2595            .map(|m| m.node_id as usize)
2596            .collect();
2597        let Ok(nodes) = self.editor.nodes() else {
2598            return String::new();
2599        };
2600        quotes
2601            .iter()
2602            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2603            .filter_map(|s| self.source.get(s))
2604            .collect()
2605    }
2606
2607    /// Whether the item at `off` sits inside another one — the test Backspace
2608    /// uses to choose between outdenting and dropping the marker.
2609    ///
2610    /// Counted from the AST rather than from the line's leading whitespace,
2611    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2612    fn item_is_nested(&mut self, off: usize) -> bool {
2613        self.editor
2614            .document()
2615            .and_then(|mut d| d.ancestors_at_caret(off))
2616            .map(|c| {
2617                c.into_iter()
2618                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2619                    .count()
2620                    > 1
2621            })
2622            .unwrap_or(false)
2623    }
2624
2625    /// The innermost list item containing `probe`, under twig's **caret**
2626    /// containment rule — a block's end is inside it.
2627    ///
2628    /// Half-open containment can't answer this. An empty item's span is exactly
2629    /// its marker, so the caret sitting after `- ` is one past the end and the
2630    /// item it is plainly in tests as out of reach; that is the shape
2631    /// double-Enter has to recognise to leave the list.
2632    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2633        let chain = self
2634            .editor
2635            .document()
2636            .and_then(|mut d| d.ancestors_at_caret(probe))
2637            .ok()?;
2638        let id = chain
2639            .iter()
2640            .rev()
2641            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2642            .node_id as usize;
2643        self.editor.nodes().ok()?.get(id).cloned()
2644    }
2645
2646    /// The list marker opening `off`'s line, per twig — `None` when that line
2647    /// opens no list item.
2648    ///
2649    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2650    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2651    /// and it is `None` on a *continuation* line, which opens nothing. That last
2652    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2653    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2654    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2655    /// parser knows which document it is looking at.
2656    ///
2657    /// The item's own marker is separated out via its
2658    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2659    /// the containers around it contribute and what the item does.
2660    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2661        let off = off.min(self.source.len());
2662        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2663        // The prefix belongs to a list only when an item's marker closes it —
2664        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2665        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2666        let marker = item.marker_span.clone()?;
2667        if marker.end != prefix.end {
2668            return None;
2669        }
2670        Some(ListMarker {
2671            line_start: prefix.start,
2672            marker_start: marker.start,
2673            text: self.source.get(prefix)?.to_string(),
2674        })
2675    }
2676
2677    /// Whether the list item on `line_start`'s line is the **first item** of its
2678    /// list — the one Tab must not nest, because nesting needs a preceding
2679    /// sibling to become the new parent and a first item has none. `false` for a
2680    /// line that isn't a list item, and for an item with a sibling above it (the
2681    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2682    /// the same in a setext underline that opens no list at all.
2683    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2684        let Some(marker) = self.list_marker_on_line(line_start) else {
2685            return false;
2686        };
2687        // Probe just inside the marker, where the item's own node is in reach —
2688        // the marker offset itself can resolve to the enclosing list, not the
2689        // `list_item`, whose span starts at the marker.
2690        let probe = marker.content_start().min(self.source.len());
2691        let Some(item) = self.innermost_list_item(probe) else {
2692            return false;
2693        };
2694        let Ok(nodes) = self.editor.nodes() else {
2695            return false;
2696        };
2697        match item.parent {
2698            // First when the parent list opens with this very item.
2699            Some(pid) => nodes
2700                .get(pid.0 as usize)
2701                .is_some_and(|p| p.first_child == Some(item.id)),
2702            // A parentless item is trivially the first (and only) one.
2703            None => true,
2704        }
2705    }
2706
2707    pub fn backspace(&mut self) {
2708        if let Some((s, e)) = self.selection() {
2709            self.splice(s, e, "", EditKind::Other);
2710            return;
2711        }
2712        // WYSIWYG: Backspace at the very start of a list item's content is a
2713        // structural key, not a character delete — it walks the "un-indent, then
2714        // un-list" ladder every list editor gives that keystroke (outdent a
2715        // nested item, strip a top-level one's marker to a paragraph). In source
2716        // view the `- ` is visible text the user is deleting a byte of, so it
2717        // keeps its literal meaning there, like Enter does.
2718        if self.view != View::Source && self.backspace_list_start() {
2719            return;
2720        }
2721        // WYSIWYG: and the same at the start of a heading's content — the `# `
2722        // there is markup the rich view hides, not text the user typed.
2723        if self.view != View::Source && self.backspace_heading_start() {
2724            return;
2725        }
2726        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2727        // the markup apart under a caret that cannot see it — see
2728        // `delete_around_block_media`.
2729        if self.view != View::Source && self.delete_around_block_media(false) {
2730            return;
2731        }
2732        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2733        // stop, not a single newline. On a line with no text of its own, the byte
2734        // before the caret is a `\n` that spells part of a block boundary — the gap
2735        // between two blocks, drawn but never a caret home. Removing just it strands
2736        // the caret in that gap and leaves an odd blank line the eye reads as one
2737        // separator but the caret can't land on: the "extra newline" left behind
2738        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2739        // Deleting to the previous stop instead collapses the whole break at once,
2740        // landing the caret at the end of the block above. Two blank lines in a row
2741        // are one stop apart, so this still removes exactly one — the lone-Enter /
2742        // lone-Backspace symmetry the empty-line case is built on is untouched.
2743        if self.view != View::Source
2744            && self.caret > self.caret_floor()
2745            && self.caret_on_blank_line()
2746            && let Some(stop) = self.vmap.stop_before(self.caret)
2747        {
2748            let stop = stop.max(self.caret_floor());
2749            if stop < self.caret {
2750                self.splice(stop, self.caret, "", EditKind::Delete);
2751                return;
2752            }
2753        }
2754        if self.caret > self.caret_floor() {
2755            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2756            // takes the whole tag — a single-byte step would leave a broken `<br`
2757            // showing in the cell. Rich view only (source view edits the literal).
2758            if self.view != View::Source
2759                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2760            {
2761                let start = start.max(self.caret_floor());
2762                if start < end {
2763                    self.splice(start, end, "", EditKind::Delete);
2764                    return;
2765                }
2766            }
2767            // Aim the delete at the character the writer can *see* behind the
2768            // caret, never at a delimiter the rich view drew nothing for. Two
2769            // steps, and either can apply: from the far side of a run's closing
2770            // `**` step back into the run (the caret is drawn at the end of its
2771            // word), and at the start of a run's text step out past its opening
2772            // `**` to the character in front of it, leaving the run standing.
2773            // Without them a plain Backspace unspells the phrase it is editing
2774            // and leaves a literal asterisk on screen.
2775            let end = if self.view == View::Source {
2776                self.caret
2777            } else {
2778                let inside = self.step_inside_close_delims(self.caret);
2779                self.skip_leading_open_delims(inside)
2780                    .max(self.caret_floor())
2781            };
2782            // Never delete back across the floor — that would eat hidden
2783            // frontmatter the WYSIWYG caret can't even see.
2784            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2785            // Take a hidden escape backslash with the char it escapes: the rich
2786            // view draws `\*` as a single `*`, so Backspace over it must delete
2787            // both bytes, never strand the `\` as a lone visible backslash (the
2788            // mirror of the Hidden-mode typing that wrote the escape). Source view
2789            // shows the `\`, so there it is an ordinary character.
2790            if self.view != View::Source
2791                && prev > self.caret_floor()
2792                && self.is_hidden_escape(prev - 1)
2793            {
2794                prev -= 1;
2795            }
2796            if prev < end {
2797                self.splice(prev, end, "", EditKind::Delete);
2798            }
2799        }
2800    }
2801
2802    /// Whether the caret's own source line holds nothing but whitespace — an
2803    /// empty paragraph, or the blank line a block boundary is spelled with. The
2804    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2805    /// no text of its own, so the newline before the caret belongs to the gap
2806    /// between blocks rather than to any word the caret is editing.
2807    fn caret_on_blank_line(&self) -> bool {
2808        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2809        let line_end = self.source[self.caret..]
2810            .find('\n')
2811            .map_or(self.source.len(), |i| self.caret + i);
2812        self.source[line_start..line_end].trim().is_empty()
2813    }
2814
2815    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2816    /// `edge` side — the byte range to delete whole. A table row is one source
2817    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2818    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2819    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2820    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2821    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2822    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
2823    /// apart — no ancestor walk needed. Rich view only; source view shows the
2824    /// literal tag and deletes it a byte at a time.
2825    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2826        let caret = self.caret;
2827        let nodes = self.nodes();
2828        let src = self.source.as_bytes();
2829        nodes
2830            .iter()
2831            .find(|n| {
2832                n.kind == Kind::HardBreak
2833                    && n.span.start < n.span.end
2834                    && src.get(n.span.start) == Some(&b'<')
2835                    && match edge {
2836                        BreakEdge::Backward => n.span.end == caret,
2837                        BreakEdge::Forward => n.span.start == caret,
2838                    }
2839            })
2840            .map(|n| (n.span.start, n.span.end))
2841    }
2842
2843    /// Whether the source byte at `off` is a backslash twig consumed as an escape
2844    /// (hidden in the rich view), as against a literal backslash (drawn). A
2845    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
2846    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
2847    /// round-trip needed.
2848    fn is_hidden_escape(&self, off: usize) -> bool {
2849        let b = self.source.as_bytes();
2850        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
2851    }
2852
2853    /// Backspace's list behaviour: when the caret sits exactly at the start of a
2854    /// list item's content (right after its marker), outdent the item if it's
2855    /// nested, else strip the marker so it becomes a paragraph. Returns whether
2856    /// it acted — `false` leaves Backspace its ordinary character delete.
2857    fn backspace_list_start(&mut self) -> bool {
2858        let Some(marker) = self.list_marker_on_line(self.caret) else {
2859            return false;
2860        };
2861        // Only right after the marker. That the line opens a real item is
2862        // already settled: `list_marker_on_line` answers from the tree.
2863        if self.caret != marker.content_start() {
2864            return false;
2865        }
2866        if self.item_is_nested(marker.marker_start) {
2867            // Nested: give back one level, keeping the marker and carrying the
2868            // caret with it.
2869            self.outdent();
2870        } else {
2871            // Top level: drop the marker, leaving a paragraph, then renumber the
2872            // siblings the removed item was counted among. Only the marker goes —
2873            // a quote prefix in front of it still has a quote to hold up.
2874            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
2875            self.renumber_here();
2876        }
2877        true
2878    }
2879
2880    /// Backspace's heading behaviour: with the caret exactly at the start of an
2881    /// ATX heading's content — right after the `#` marker the rich view hides —
2882    /// strip the marker so the line becomes a paragraph. The peer of
2883    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
2884    /// reasoning: hidden block markup is structure, so the keystroke over it is
2885    /// structural.
2886    ///
2887    /// Without this the ordinary delete takes the space out of `# Title` and
2888    /// leaves `#Title`, which is no longer a heading at all — the hash the view
2889    /// had been hiding surfaces as literal text the user has to delete a second
2890    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
2891    /// other end) goes with the marker for the same reason.
2892    ///
2893    /// Returns whether it acted; `false` leaves Backspace its character delete.
2894    fn backspace_heading_start(&mut self) -> bool {
2895        let caret = self.caret;
2896        // The heading whose content opens exactly at the caret. A bare `#` has no
2897        // content span at all — its content starts (and ends) where the line does.
2898        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
2899            let (start, end) = match &n.content_span {
2900                Some(c) => (c.start, c.end),
2901                None => (n.span.end, n.span.end),
2902            };
2903            (n.kind == Kind::Heading && start == caret)
2904                .then(|| (n.span.clone(), end, n.marker_span.clone()))
2905        }) else {
2906            return false;
2907        };
2908        // twig reports the marker's own extent, so there is nothing to walk back
2909        // over and no `#` in this file. A setext heading has no marker — its
2910        // content opens the line — so it falls through to the ordinary delete,
2911        // as does anything else sitting at a content start.
2912        // `m.end == caret` is what excludes a setext heading, whose marker is the
2913        // underline *after* the content rather than a prefix before it.
2914        let Some(marker) = marker.filter(|m| m.end == caret) else {
2915            return false;
2916        };
2917        let start = marker.start;
2918        // A closing `#` sequence is hidden too, so it can't be left behind. Only
2919        // when the tail really is one: trailing spaces alone are nothing to strip.
2920        let tail = &self.source[content_end..span.end];
2921        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
2922            let kept = self.source[caret..content_end].to_string();
2923            self.splice(start, span.end, &kept, EditKind::Other);
2924            // The splice leaves the caret past the text it re-wrote; the caret
2925            // belongs where the content now starts, which is where it already was.
2926            self.caret = start;
2927            self.record_caret();
2928        } else {
2929            self.splice(start, caret, "", EditKind::Other);
2930        }
2931        true
2932    }
2933
2934    pub fn delete_forward(&mut self) {
2935        if let Some((s, e)) = self.selection() {
2936            self.splice(s, e, "", EditKind::Other);
2937        } else if self.caret < self.source.len() {
2938            // The mirror of Backspace's: forward-delete in front of a picture
2939            // would eat the `!` off its markup and leave a link where a photo was.
2940            if self.view != View::Source && self.delete_around_block_media(true) {
2941                return;
2942            }
2943            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
2944            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
2945            // strands a broken `<br` in the cell.
2946            if self.view != View::Source
2947                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
2948            {
2949                self.splice(start, end, "", EditKind::Delete);
2950                return;
2951            }
2952            // The mirror of Backspace's two steps: from in front of a run's
2953            // opening `**` step into it, onto the first letter of its text, and
2954            // at the end of a run's text step out past its closing `**` to the
2955            // character beyond. Either way Delete takes the character it looks
2956            // like it is pointing at, and never a delimiter drawn as nothing.
2957            // The caret then settles back inside the run it was standing in —
2958            // see `settle_inside_close_delims`.
2959            let from = if self.view == View::Source {
2960                self.caret
2961            } else {
2962                let inside = self.step_inside_open_delims(self.caret);
2963                self.skip_trailing_close_delims(inside)
2964            };
2965            let next = next_boundary(&self.source, from);
2966            if from < next {
2967                self.splice(from, next, "", EditKind::Delete);
2968            }
2969        }
2970    }
2971
2972    /// Delete from the caret back to the start of the previous word (⌥⌫ /
2973    /// Ctrl+⌫). Deletes the selection instead when one is active.
2974    pub fn delete_word_back(&mut self) {
2975        if let Some((s, e)) = self.selection() {
2976            self.splice(s, e, "", EditKind::Other);
2977        } else {
2978            // A word back from just past a picture is a word *of its markup*, and
2979            // a word back from in front of one runs through the paragraph break
2980            // into the prose above — dissolving the picture either way. See
2981            // `delete_around_block_media`.
2982            if self.view != View::Source && self.delete_around_block_media(false) {
2983                return;
2984            }
2985            let start = self.word_left_from(self.caret).max(self.caret_floor());
2986            if start < self.caret {
2987                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
2988                self.splice(s, e, "", EditKind::Delete);
2989            }
2990        }
2991    }
2992
2993    /// Delete from the caret forward to the end of the next word (⌥⌦ /
2994    /// Ctrl+Del). Deletes the selection instead when one is active.
2995    pub fn delete_word_forward(&mut self) {
2996        if let Some((s, e)) = self.selection() {
2997            self.splice(s, e, "", EditKind::Other);
2998        } else {
2999            // The mirror: a word forward from in front of a picture is its markup.
3000            if self.view != View::Source && self.delete_around_block_media(true) {
3001                return;
3002            }
3003            let end = self.word_right_from(self.caret);
3004            if end > self.caret {
3005                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3006                self.splice(s, e, "", EditKind::Delete);
3007            }
3008        }
3009    }
3010
3011    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3012    /// selection instead when one is active, as every other delete here does.
3013    ///
3014    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3015    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3016    /// stops at the first character and this takes the indentation with it, the
3017    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3018    /// leave an indent behind that nothing can then ask to delete, where a caret
3019    /// left at column 0 is one press of Home away from either.
3020    pub fn delete_to_line_start(&mut self) {
3021        if let Some((s, e)) = self.selection() {
3022            self.splice(s, e, "", EditKind::Other);
3023            return;
3024        }
3025        // Never back across the floor: hidden frontmatter isn't on this line, or
3026        // on any line the WYSIWYG caret can see.
3027        let (start, _) = self.line_span();
3028        let start = start.max(self.caret_floor());
3029        if start < self.caret {
3030            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3031            self.splice(s, e, "", EditKind::Delete);
3032        }
3033    }
3034
3035    /// Kill from the caret to the end of its line (^K). Deletes the selection
3036    /// instead when one is active.
3037    ///
3038    /// At the end of the line it does nothing, rather than pulling the line
3039    /// below up into this one. Joining has no meaning to give it in both views
3040    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3041    /// there is nothing there to delete, while the newline a *source* line ends
3042    /// with is only half of the blank line that separates two paragraphs —
3043    /// deleting one leaves a soft break, which is not the join it looks like.
3044    /// The views agreeing is worth more than emacs' second press, and Delete is
3045    /// already the key that joins.
3046    pub fn delete_to_line_end(&mut self) {
3047        if let Some((s, e)) = self.selection() {
3048            self.splice(s, e, "", EditKind::Other);
3049            return;
3050        }
3051        let (_, end) = self.line_span();
3052        if end > self.caret {
3053            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3054            self.splice(s, e, "", EditKind::Delete);
3055        }
3056    }
3057
3058    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3059    ///
3060    /// A glyph-space range covers what the user can see, which for `**bold**` is
3061    /// the word and never the delimiters around it — so deleting the word on its
3062    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3063    /// word, and the styling was the word's; the two go together. Only the
3064    /// node's delimiters are taken, and those are hidden here anyway, so nothing
3065    /// visible outside the range is lost.
3066    ///
3067    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3068    /// the strong, and only then is the strong empty too.
3069    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3070        if self.view == View::Source {
3071            return (start, end);
3072        }
3073        let nodes = self.nodes();
3074        let (mut s, mut e) = (start, end);
3075        loop {
3076            let mut grew = false;
3077            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3078                let Some(text) = inline_content_span(n, &self.source) else {
3079                    continue;
3080                };
3081                // Some of its text survives, so the node still has a job.
3082                if text.start < s || text.end > e {
3083                    continue;
3084                }
3085                if n.span.start < s || n.span.end > e {
3086                    s = s.min(n.span.start);
3087                    e = e.max(n.span.end);
3088                    grew = true;
3089                }
3090            }
3091            if !grew {
3092                return (s, e);
3093            }
3094        }
3095    }
3096
3097    /// One splice of document text, keeping the **mark-edge rule**: an inline
3098    /// mark's content never begins or ends with whitespace. In Markdown and Djot
3099    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3100    /// is four literal asterisks around a word, and a rich view drawing the
3101    /// document faithfully has no choice but to show them. That is correct
3102    /// rendering of what the file says, and nobody typing a space after a bold
3103    /// word meant to say it.
3104    ///
3105    /// So the space goes *outside* the run instead — `**bold** ` — which is the
3106    /// same document to a reader and a live one to a parser. The caret follows it
3107    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3108    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3109    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3110    ///
3111    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3112    /// through here, so the rule holds however the whitespace arrives at the
3113    /// edge. The repair is decided *after* the plain edit, by asking whether the
3114    /// mark actually died: a code span's backticks aren't whitespace-sensitive
3115    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3116    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3117        let fix = self.mark_edge_fix(start, end, text);
3118        if !self.splice_exact(start, end, text, kind) {
3119            return false;
3120        }
3121        if let Some(fix) = fix {
3122            self.repair_mark_edges(fix);
3123        }
3124        if text.is_empty() && end > start {
3125            self.settle_inside_close_delims();
3126        }
3127        true
3128    }
3129
3130    /// After a delete, take a caret left standing past a run's closing delimiters
3131    /// back inside the run.
3132    ///
3133    /// A delete leaves the caret where the deleted bytes began, and when those
3134    /// bytes were the last thing after a marked phrase — the space the mark-edge
3135    /// rule pushed out of `**bold** `, say — that spot is the far side of the
3136    /// closing `**`. The rich view has nothing to draw there: the delimiters are
3137    /// hidden, so the caret shows at the end of the word either way, and the two
3138    /// offsets are one place on screen with two different meanings. Typing at the
3139    /// outer one lands past the run, so the writer who backspaced a space out of
3140    /// their bold phrase watches the next character come out plain, and the
3141    /// toolbar button go dark, with the caret never appearing to move.
3142    ///
3143    /// The end of the run's text is the caret's home there — a delete that took
3144    /// away everything after a phrase leaves the caret at the end of that phrase,
3145    /// which is inside it — so it settles onto that
3146    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3147    /// walk, through every mark closing at the point): the word stays bold, the
3148    /// button stays lit, and the next character carries on the phrase.
3149    ///
3150    /// Rich view only, and only where a mark really closes at the caret — mid-run
3151    /// or in plain prose no span ends there and the caret stays put. The opening
3152    /// edge is left alone on purpose: a caret in front of a run inherits from the
3153    /// text on its left, which is the plain text outside.
3154    fn settle_inside_close_delims(&mut self) {
3155        if self.view != View::Wysiwyg {
3156            return;
3157        }
3158        let at = self.step_inside_close_delims(self.caret);
3159        if at != self.caret {
3160            self.caret = at;
3161            self.clear_pending();
3162            self.record_caret();
3163        }
3164    }
3165
3166    /// The splice exactly as asked, with no mark-edge repair — for the callers
3167    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3168    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3169    /// the bytes they inserted.
3170    ///
3171    /// One `edit_range` through twig, then re-anchor the caret from the returned
3172    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3173    /// Markdown/Djot) leaves the document untouched and reports.
3174    ///
3175    /// Returns whether the edit landed — for a caller that has offsets of its
3176    /// own to place afterwards, which a rolled-back splice would leave pointing
3177    /// into text that never came to exist.
3178    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3179        // The read-only gate, for every edit at once — see the field.
3180        if self.read_only {
3181            return false;
3182        }
3183        // twig records an undo step for every edit; when this one continues a
3184        // run of the same kind (typing, deleting), tell twig to fold it into the
3185        // step before it so the whole run undoes at once.
3186        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3187        // Hand twig the pre-edit caret before the splice, so the undo step it
3188        // retires carries where the caret was standing.
3189        self.record_caret();
3190        match self.editor.edit_range(start, end, text) {
3191            Ok(change) => {
3192                if coalesce {
3193                    let _ = self.editor.coalesce_last_undo();
3194                }
3195                self.last_edit_kind = Some(kind);
3196                self.refresh();
3197                self.caret = change.new.end;
3198                self.anchor = None;
3199                self.goal_col = None;
3200                self.clear_pending();
3201                self.dirty = self.source != self.clean_source;
3202                self.status = None;
3203                // And the post-edit caret, so a later redo restores it.
3204                self.record_caret();
3205                true
3206            }
3207            // The edit was rolled back, so twig's history did not move and
3208            // neither may ours: pushing here would leave a step with no edit
3209            // under it and shift every later undo onto the wrong caret.
3210            Err(e) => {
3211                self.status = Some(format!("edit: {e}"));
3212                false
3213            }
3214        }
3215    }
3216
3217    /// The re-spelling that would keep the mark-edge rule for the edit
3218    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3219    /// against a delimiter and the plain splice is already right. Computed
3220    /// *before* the edit, while the run's spans and delimiters can still be read
3221    /// off the document; applied afterwards, and only if the mark really died —
3222    /// see [`repair_mark_edges`](Self::repair_mark_edges).
3223    ///
3224    /// Rich view only. Source view is for typing raw markup, where a space put
3225    /// against a `**` is exactly the character it looks like.
3226    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3227        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3228            return None;
3229        }
3230        // Every inline mark standing over the edit, outermost first, with the
3231        // content span that says where its delimiters are.
3232        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3233            .editor
3234            .ancestors_at(start)
3235            .unwrap_or_default()
3236            .into_iter()
3237            .filter_map(|m| {
3238                let kind = inline_kind(&m.kind)?;
3239                let content = m.content_span.clone()?;
3240                Some((kind, m.span.clone(), content))
3241            })
3242            .collect();
3243        // The innermost run whose *content* holds the whole edit: the one whose
3244        // text is being changed, rather than one the edit merely sits under.
3245        let (kind, span, content) = chain
3246            .iter()
3247            .rev()
3248            .find(|(_, _, c)| c.start <= start && end <= c.end)?
3249            .clone();
3250        // What that content becomes. Whitespace at either end of it is what
3251        // would put out the mark.
3252        let body = format!(
3253            "{}{text}{}",
3254            &self.source[content.start..start],
3255            &self.source[end..content.end]
3256        );
3257        let (lead, trail) = if body.trim().is_empty() {
3258            // Nothing but whitespace left: there is no content to mark at all,
3259            // and the delimiters go with it rather than closing on a space.
3260            (body.len(), 0)
3261        } else {
3262            (
3263                body.len() - body.trim_start().len(),
3264                body.len() - body.trim_end().len(),
3265            )
3266        };
3267        // Nothing against a delimiter, and something still between them: the
3268        // plain edit stands. An emptied run is broken just as surely (`**b**`
3269        // with the `b` deleted is the literal `****`) and is re-spelt as the
3270        // nothing it now says.
3271        if lead == 0 && trail == 0 && !body.is_empty() {
3272            return None;
3273        }
3274        // Marks that open or close exactly where this one does — `***both***` is
3275        // two runs sharing an edge — spell their delimiters as one run of bytes,
3276        // so the whitespace has to clear all of them together.
3277        let (mut open_at, mut close_at) = (span.start, span.end);
3278        for _ in 0..chain.len() {
3279            match chain.iter().find(|(_, _, c)| c.start == open_at) {
3280                Some((_, s, _)) => open_at = s.start,
3281                None => break,
3282            }
3283        }
3284        for _ in 0..chain.len() {
3285            match chain.iter().find(|(_, _, c)| c.end == close_at) {
3286                Some((_, s, _)) => close_at = s.end,
3287                None => break,
3288            }
3289        }
3290        let open = &self.source[open_at..content.start];
3291        let close = &self.source[content.end..close_at];
3292        let core = &body[lead..body.len() - trail];
3293        let respelt = if core.is_empty() {
3294            body.clone()
3295        } else {
3296            format!(
3297                "{}{open}{core}{close}{}",
3298                &body[..lead],
3299                &body[body.len() - trail..]
3300            )
3301        };
3302        // The caret sits just past the inserted text within the new content —
3303        // which, when that lands in the whitespace, is now outside the delimiters.
3304        let pos = (start - content.start) + text.len();
3305        let caret = if core.is_empty() || pos <= lead {
3306            open_at + pos
3307        } else if pos >= lead + core.len() {
3308            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3309        } else {
3310            open_at + lead + open.len() + (pos - lead)
3311        };
3312        Some(MarkEdgeFix {
3313            kind,
3314            probe: content.start,
3315            start: open_at,
3316            end: close_at + text.len() - (end - start),
3317            text: respelt,
3318            caret,
3319            // The marks in force here, resolved against any armed sticky delta —
3320            // what the writer is typing in, and so what has to still be true on
3321            // the far side of the delimiter the caret just stepped over.
3322            want: chain
3323                .iter()
3324                .filter(|(_, s, _)| start < s.end)
3325                .map(|(k, _, _)| *k)
3326                .collect::<InlineMarks>()
3327                .xor(self.pending_here()),
3328        })
3329    }
3330
3331    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3332    /// did break the mark. Whether whitespace at a delimiter is fatal is the
3333    /// format's business, not leaf's: `**bold **` is no longer strong, while
3334    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3335    /// don't care either. Asking the parser afterwards settles it for every kind
3336    /// and format at once, and costs a re-spelling only where one is due.
3337    ///
3338    /// The repair rides along with the edit that caused it — one undo step puts
3339    /// back what the writer typed, not a delimiter shuffle they never saw.
3340    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3341        if fix.end > self.source.len() {
3342            return;
3343        }
3344        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3345            return; // still a mark: these delimiters don't mind the whitespace
3346        }
3347        let resumed = self.last_edit_kind;
3348        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3349            return;
3350        }
3351        let _ = self.editor.coalesce_last_undo();
3352        // The keystroke owns the undo step, so the run of typing it belongs to
3353        // keeps coalescing over the repair rather than breaking in two here.
3354        self.last_edit_kind = resumed;
3355        self.caret = fix.caret.min(self.source.len());
3356        self.anchor = None;
3357        self.goal_col = None;
3358        self.rearm(fix.want);
3359        self.clamp_caret();
3360        self.record_caret();
3361    }
3362
3363    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3364    /// writer is typing in, carried across an edit that moved the caret out of
3365    /// the run holding them. Arms nothing when the caret already stands in
3366    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3367    /// a clean delta here (see [`toggle`](Self::toggle)).
3368    fn rearm(&mut self, want: InlineMarks) {
3369        let here: InlineMarks = self
3370            .marks_at(self.caret)
3371            .into_iter()
3372            .map(|(k, _)| k)
3373            .collect();
3374        self.pending_marks = want.xor(here);
3375        self.pending_at = Some(self.caret);
3376    }
3377
3378    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3379    /// which backslash-escapes any character that would otherwise open markup in
3380    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3381    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3382    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3383    /// a collapsed point — a selection is deleted by the caller first, since
3384    /// `insert_literal` inserts rather than replaces.
3385    fn insert_literal_at(
3386        &mut self,
3387        at: usize,
3388        text: &str,
3389        kind: EditKind,
3390        force_coalesce: bool,
3391    ) -> bool {
3392        // The read-only gate: this door goes to twig directly, not through
3393        // `splice_exact`, so it guards itself — see the field.
3394        if self.read_only {
3395            return false;
3396        }
3397        // `force_coalesce` folds this into the immediately preceding edit (the
3398        // selection-delete of an overwrite) so the pair is one undo step; else it
3399        // coalesces only when it continues a run of the same-kind typing.
3400        let coalesce =
3401            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3402        // The mark-edge rule holds for typed text however it is spelled — see
3403        // `splice`. Only an insert twig passed through unchanged can use it,
3404        // since a fix is measured in the bytes that actually land, and an escape
3405        // adds bytes this couldn't have counted.
3406        let fix = self.mark_edge_fix(at, at, text);
3407        self.record_caret();
3408        match self.editor.insert_literal(at, text) {
3409            Ok(change) => {
3410                if coalesce {
3411                    let _ = self.editor.coalesce_last_undo();
3412                }
3413                self.last_edit_kind = Some(kind);
3414                self.refresh();
3415                self.caret = change.new.end;
3416                self.anchor = None;
3417                self.goal_col = None;
3418                self.clear_pending();
3419                self.dirty = self.source != self.clean_source;
3420                self.status = None;
3421                self.record_caret();
3422                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3423                    self.repair_mark_edges(fix);
3424                }
3425                true
3426            }
3427            Err(e) => {
3428                self.status = Some(format!("edit: {e}"));
3429                false
3430            }
3431        }
3432    }
3433
3434    /// After a structural list edit (a new item, a nest/unnest), renumber the
3435    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3436    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3437    /// renumber as its own edit; fold it into the edit that triggered it so the
3438    /// two undo as one, and only when it actually changed the source (a no-op or
3439    /// a caret outside any ordered list must not coalesce the real edit into the
3440    /// step before it).
3441    fn renumber_here(&mut self) {
3442        self.renumber_at(self.caret);
3443    }
3444
3445    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3446    /// caret — for an edit that leaves the caret one past the item it just wrote,
3447    /// where twig resolves no list to renumber.
3448    fn renumber_at(&mut self, off: usize) {
3449        // The read-only gate — this door reaches twig without the splice.
3450        if self.read_only {
3451            return;
3452        }
3453        let before = self.source.clone();
3454        if self.editor.renumber_ordered_lists(off).is_err() {
3455            return; // not inside an ordered list — nothing to renumber
3456        }
3457        self.refresh();
3458        if self.source != before {
3459            let _ = self.editor.coalesce_last_undo();
3460            self.dirty = self.source != self.clean_source;
3461            self.clamp_caret();
3462            self.record_caret();
3463        }
3464    }
3465
3466    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3467    /// sub-item written directly beneath a text line reparses that text as a
3468    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
3469    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3470    /// bullets can't underline anything, so swap the dash for a `*`: the item
3471    /// stays an empty nested bullet, the parent stays prose, and the source
3472    /// round-trips instead of hiding a heading the user never asked for. Folded
3473    /// into the triggering edit's undo step, the way renumbering is.
3474    ///
3475    /// Gated on the collapse having actually happened (the swapped dash was
3476    /// swallowed into a `heading`), so a real setext heading the author wrote —
3477    /// or a `- x` with content, which can't underline anything — is never
3478    /// touched. This has to live in the *edit*, not the renderer: leaving the
3479    /// hazardous bytes on disk and only painting over them would ship a file
3480    /// every other CommonMark tool reads as a heading.
3481    ///
3482    /// This one keeps its own byte scan, and has to: the hazard is precisely
3483    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3484    /// which asks twig which lines open an item — reports nothing here. There is
3485    /// no node to ask about. It is also the last Markdown spelling leaf writes on
3486    /// purpose rather than for want of an answer; once twig spells continuations
3487    /// itself, avoiding the trap becomes twig's, and this goes.
3488    ///
3489    /// [`list_marker_on_line`]: Self::list_marker_on_line
3490    fn avoid_setext_collapse(&mut self) {
3491        let caret = self.caret.min(self.source.len());
3492        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3493        let bytes = self.source.as_bytes();
3494        let mut dash = line_start;
3495        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3496            dash += 1;
3497        }
3498        // A dash bullet is the only marker that doubles as a setext underline.
3499        if bytes.get(dash) != Some(&b'-') {
3500            return;
3501        }
3502        // Only an *empty* item is a bare underline; `- x` carries content and
3503        // can't fold the line above into a heading.
3504        let line_end = self.source[dash..]
3505            .find('\n')
3506            .map_or(self.source.len(), |i| dash + i);
3507        if !self.source[dash + 1..line_end].trim().is_empty() {
3508            return;
3509        }
3510        // The tell: that dash was swallowed into a `heading`. A properly nested
3511        // empty item sits under a `list_item`, with no heading in reach. Probe
3512        // the dash byte itself (well inside the heading), not the caret, whose
3513        // end-of-line offset can fall on the half-open span boundary.
3514        let collapsed = self
3515            .editor
3516            .ancestors_at(dash)
3517            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3518            .unwrap_or(false);
3519        if !collapsed {
3520            return;
3521        }
3522        let caret = self.caret;
3523        if self.splice(dash, dash + 1, "*", EditKind::Other) {
3524            // Same width, so the caret keeps its column; fold into the edit that
3525            // triggered this so Tab stays one undo step.
3526            let _ = self.editor.coalesce_last_undo();
3527            self.caret = caret.min(self.source.len());
3528            self.clamp_caret();
3529            self.record_caret();
3530        }
3531    }
3532
3533    fn snapshot(&self) -> CaretState {
3534        CaretState {
3535            caret: self.caret,
3536            anchor: self.anchor,
3537        }
3538    }
3539
3540    /// Hand twig the current caret and selection as the blob for the live
3541    /// document state. Called before an edit — so the step twig retires records
3542    /// where the caret was, and undo can restore it — and again once the op has
3543    /// placed the caret, so redo restores where the edit left it.
3544    ///
3545    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3546    /// caret through its own history, so coalescing falls out for free (folding
3547    /// two twig steps into one drops the intermediate blob, keeping the run's
3548    /// first) and the parallel stacks that had to march in lockstep — and could
3549    /// silently drift out of it — are gone.
3550    fn record_caret(&mut self) {
3551        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3552    }
3553
3554    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3555    /// the toggled region selected so a second press cleanly reverses it.
3556    pub fn toggle(&mut self, kind: InlineKind) {
3557        // The read-only gate — this door reaches twig without the splice.
3558        if self.read_only {
3559            return;
3560        }
3561        // Ahead of the no-selection branch below: arming a mark for text not yet
3562        // typed is a promise `insert` cannot keep in a format with no delimiters
3563        // to spell it with. Per *kind*, not per format — Markdown spells three
3564        // of the eight marks, djot all eight, HTML seven.
3565        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3566            return;
3567        }
3568        let Some((s, e)) = self.selection() else {
3569            // No selection: arm the mark for the next text typed here, the way a
3570            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3571            // in the flow of typing without ever selecting anything — the delta
3572            // is realised onto the freshly typed text by `insert`. A fresh caret
3573            // position starts the delta over from the marks actually in force.
3574            if self.pending_at != Some(self.caret) {
3575                self.pending_marks = InlineMarks::empty();
3576                self.pending_at = Some(self.caret);
3577            }
3578            self.pending_marks.flip(kind);
3579            self.status = None;
3580            return;
3581        };
3582        // Whitespace at the edge of a selection is not part of what was chosen —
3583        // a double-click takes the space after the word with it — and a mark
3584        // cannot close against one anyway: `**word **` is four literal asterisks
3585        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3586        let picked = &self.source[s..e];
3587        let (s, e) = (
3588            s + (picked.len() - picked.trim_start().len()),
3589            e - (picked.len() - picked.trim_end().len()),
3590        );
3591        if s >= e {
3592            self.status = Some(format!("{kind:?}: nothing selected to mark"));
3593            return;
3594        }
3595        // Styling a selection is a one-shot act, not a sticky mode.
3596        self.clear_pending();
3597        self.record_caret();
3598        match self.editor.toggle_inline(s, e, kind) {
3599            Ok(change) => {
3600                self.last_edit_kind = None; // structural edit is its own undo step
3601                self.refresh();
3602                self.anchor = Some(change.new.start);
3603                self.caret = change.new.end;
3604                self.dirty = self.source != self.clean_source;
3605                self.status = None;
3606                self.record_caret();
3607            }
3608            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3609        }
3610    }
3611
3612    /// Convert the block at the caret to a heading level or paragraph.
3613    pub fn set_block(&mut self, kind: BlockKind) {
3614        // The read-only gate — this door reaches twig without the splice.
3615        if self.read_only {
3616            return;
3617        }
3618        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
3619            return;
3620        }
3621        self.record_caret();
3622        // A blank line has no node to convert, and twig opens a block there
3623        // rather than declining — so the caret's own offset is the right thing
3624        // to hand it when `block_offset_for_caret` finds nothing.
3625        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
3626        match self.editor.set_block(offset, kind) {
3627            Ok(change) => {
3628                self.last_edit_kind = None;
3629                self.refresh();
3630                // Opening a block on a blank line writes a marker the caret
3631                // belongs *after*; converting an existing one moves nothing.
3632                self.caret = self.caret.max(change.new.end);
3633                self.clamp_caret();
3634                self.anchor = None;
3635                self.dirty = self.source != self.clean_source;
3636                self.status = None;
3637                self.record_caret();
3638            }
3639            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3640        }
3641    }
3642
3643    /// Whether `off` is inside a text block (paragraph, heading, code block…).
3644    fn has_block_at(&mut self, off: usize) -> bool {
3645        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
3646            chain
3647                .iter()
3648                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
3649        })
3650    }
3651
3652    /// The offset to hand twig's `set_block`: the caret when it is already inside
3653    /// a block, otherwise nudged onto the previous character (a caret at a line
3654    /// end sits at the doc level, outside the block). `None` when the caret is on
3655    /// a blank line — a new paragraph with no block node to convert.
3656    fn block_offset_for_caret(&mut self) -> Option<usize> {
3657        let caret = self.caret.min(self.source.len());
3658        if self.has_block_at(caret) {
3659            return Some(caret);
3660        }
3661        // Nudge to the previous character — but never across a newline: that would
3662        // target the previous block, and a blank line genuinely has no block.
3663        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
3664            && ch != '\n'
3665            && self.has_block_at(i)
3666        {
3667            return Some(i);
3668        }
3669        None
3670    }
3671
3672    /// The heading level of the text block at the caret, or `None` when that
3673    /// block is not a heading.
3674    pub fn current_heading_level(&mut self) -> Option<u32> {
3675        let caret = self.caret;
3676        self.nodes()
3677            .into_iter()
3678            .filter(|n| n.kind == Kind::Heading)
3679            .find(|n| n.span.start <= caret && caret <= n.span.end)
3680            .and_then(|n| n.level)
3681    }
3682
3683    /// The inline marks in force at the caret (or over the selection) — what a
3684    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
3685    /// inline counterpart. Cheap enough to call every frame: one twig
3686    /// `ancestors_at` query per caret (two with a selection), each walking root
3687    /// → deepest node at one offset. It never snapshots the tree the way
3688    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
3689    /// the only allocation is twig's own small ancestor `Vec`.
3690    ///
3691    /// **A selection reports a mark only when the mark covers *all* of it.**
3692    /// That's what every real toolbar means by an active button — Bold lit over
3693    /// a half-bold selection would claim a press turns bold *off*, when
3694    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
3695    /// Whole-coverage is asked as "is the same mark node standing over both the
3696    /// first and the last character?": inline nodes are contiguous, so one node
3697    /// covering both ends covers every byte between them. Two touching runs
3698    /// (`**a****b**`) are two nodes, and correctly light nothing.
3699    ///
3700    /// At a bare caret a mark is active when the caret stands inside the mark's
3701    /// span — `span.start <= caret < span.end`, delimiters included, which is
3702    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
3703    /// opening `*` (2) through the last byte of the closing `**` (9) are all
3704    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
3705    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
3706    /// typing would actually land inside the marked run. The offset one past the
3707    /// mark (10) is the text after it and reports nothing, at the end of the
3708    /// buffer exactly as in the middle.
3709    pub fn active_inline_marks(&mut self) -> InlineMarks {
3710        let Some((start, end)) = self.selection() else {
3711            // The marks actually in force at the caret, flipped by any armed
3712            // sticky delta — so `⌘b` at a bare caret lights the Bold button
3713            // immediately, before a single character is typed.
3714            let base: InlineMarks = self
3715                .marks_at(self.caret)
3716                .into_iter()
3717                .map(|(k, _)| k)
3718                .collect();
3719            return base.xor(self.pending_here());
3720        };
3721        // The selection's *last character*, not its exclusive end: `end` is the
3722        // offset one past the selection, which for a selection ending exactly at
3723        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
3724        // entirely bold, but offset 10 is the space after).
3725        let last = prev_boundary(&self.source, end);
3726        let head = self.marks_at(start);
3727        let tail = self.marks_at(last);
3728        head.into_iter()
3729            .filter(|m| tail.contains(m))
3730            .map(|(k, _)| k)
3731            .collect()
3732    }
3733
3734    /// The inline marks whose span covers `off`, each with the id of the node
3735    /// carrying it — the id is what lets a selection tell one mark node from
3736    /// another of the same kind.
3737    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
3738        let off = off.min(self.source.len());
3739        self.editor
3740            .ancestors_at(off)
3741            .unwrap_or_default()
3742            .into_iter()
3743            // `span.end` is the offset one *past* the mark, so it isn't in it.
3744            // twig already resolves a boundary to whatever starts there — in
3745            // `**bold** x` offset 8 is the following text, not the strong — but
3746            // when nothing follows, the tie has nobody to break for and the
3747            // chain still ends at the mark. That would make the answer at the
3748            // last offset of the document depend on whether the file happens to
3749            // end in a newline; the rule is `span.start <= off < span.end`, and
3750            // it's the same rule at the end of a buffer as in the middle.
3751            .filter(|m| off < m.span.end)
3752            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
3753            .collect()
3754    }
3755
3756    /// Toggle a heading at the caret: if the block is already this heading level,
3757    /// revert it to a paragraph; otherwise convert it to this heading level.
3758    /// This gives the heading commands the same toggle feel as bold/italic/code —
3759    /// re-applying a heading a line already has turns it back into body text.
3760    pub fn toggle_heading(&mut self, level: u32) {
3761        if self.current_heading_level() == Some(level) {
3762            self.set_block(BlockKind::Paragraph);
3763        } else {
3764            self.set_block(BlockKind::Heading(level));
3765        }
3766    }
3767
3768    /// Toggle a block quote around the selection, or around the block at the
3769    /// caret — the toolbar's Quote button.
3770    pub fn toggle_blockquote(&mut self) {
3771        self.toggle_container(BlockContainerKind::BlockQuote);
3772    }
3773
3774    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
3775    /// over the block at the caret — one op with the kind as a flag, the way
3776    /// `toggle_heading` takes its level, so a frontend needs no twig type to
3777    /// name the two buttons.
3778    ///
3779    /// Pressing the *other* list's button while in a list converts in place
3780    /// rather than nesting, so the pair reads as one three-state control
3781    /// (bulleted / numbered / neither) rather than two independent wrappers.
3782    pub fn toggle_list(&mut self, ordered: bool) {
3783        self.toggle_container(if ordered {
3784            BlockContainerKind::OrderedList
3785        } else {
3786            BlockContainerKind::BulletList
3787        });
3788    }
3789
3790    // ── Task list items ──────────────────────────────────────────────────────
3791    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
3792    // inline content of the item's first paragraph rather than part of its
3793    // marker, so adding or removing one must leave the item's continuation
3794    // indentation alone, and an item inside a quote is found past the quote
3795    // markers. leaf names the gesture and the offset; the spelling is twig's.
3796
3797    /// Whether the list item at the caret carries a checkbox, and which way it
3798    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
3799    /// item or no item at all. What a toolbar reads to light its checkbox button.
3800    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
3801        self.task_checked_at(self.caret)
3802    }
3803
3804    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
3805    /// offset — what a frontend asks before deciding a click landed on a box.
3806    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
3807        self.innermost_list_item(offset.min(self.source.len()))?
3808            .checked
3809    }
3810
3811    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
3812    /// A no-op with a reported reason when the caret is in no task item — minting
3813    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
3814    pub fn toggle_task_checked(&mut self) {
3815        self.toggle_task_at(self.caret);
3816    }
3817
3818    /// Tick or untick the task item covering `offset` — what a *click* on a
3819    /// rendered checkbox is. Separate from the caret form because a click carries
3820    /// its own offset and must not first move the caret there: ticking a box
3821    /// three paragraphs away should not take the cursor with it.
3822    pub fn toggle_task_at(&mut self, offset: usize) {
3823        // The read-only gate — this door reaches twig without the splice.
3824        if self.read_only {
3825            return;
3826        }
3827        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
3828            return;
3829        }
3830        let offset = offset.min(self.source.len());
3831        self.record_caret();
3832        match self.editor.toggle_task_checked(offset) {
3833            Ok(_) => self.after_task_edit(),
3834            Err(e) => self.status = Some(format!("task: {e}")),
3835        }
3836    }
3837
3838    /// Give the list item at the caret a checkbox, or take its checkbox away —
3839    /// the gesture that converts between a plain bullet and a task. A new box
3840    /// arrives unticked.
3841    pub fn toggle_task_item(&mut self) {
3842        // The read-only gate — this door reaches twig without the splice.
3843        if self.read_only {
3844            return;
3845        }
3846        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
3847            return;
3848        }
3849        let caret = self.caret.min(self.source.len());
3850        self.record_caret();
3851        match self.editor.toggle_task_item(caret) {
3852            Ok(_) => self.after_task_edit(),
3853            Err(e) => self.status = Some(format!("task: {e}")),
3854        }
3855    }
3856
3857    /// Settle after a task gesture. The caret rides its old byte offset and is
3858    /// clamped back in: a box is three or four bytes on the item's first line, so
3859    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
3860    /// real stop either way.
3861    fn after_task_edit(&mut self) {
3862        self.last_edit_kind = None;
3863        self.refresh();
3864        self.anchor = None;
3865        self.dirty = self.source != self.clean_source;
3866        self.status = None;
3867        self.clamp_caret();
3868        self.record_caret();
3869    }
3870
3871    // ── Tables ───────────────────────────────────────────────────────────────
3872    // A table is a grid, and twig edits it as one — add/remove/move a row or
3873    // column, set a column's alignment — re-spelling the whole table in a single
3874    // splice. Every gesture is anchored at the caret's cell. leaf just names the
3875    // gesture and re-reads the result; the whole table's numbering, borders, and
3876    // delimiter are twig's to keep straight.
3877
3878    /// Whether the caret is inside a table — what a frontend asks to enable or
3879    /// disable its table controls.
3880    ///
3881    /// An HTML `<table>` still answers `true`: the caret really is in a table,
3882    /// and the reason the grid controls stay dark there is
3883    /// [`Capabilities::table`], which is a fact about the document's format
3884    /// rather than about the caret. A frontend needs both.
3885    pub fn caret_in_table(&mut self) -> bool {
3886        let caret = self.caret.min(self.source.len());
3887        self.editor
3888            .ancestors_at(caret)
3889            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
3890            .unwrap_or(false)
3891    }
3892
3893    /// One grid op, guarded and settled — the shared body of the seven below.
3894    ///
3895    /// The guard is why this exists rather than seven copies of the same three
3896    /// lines, and it is the one guard leaf cannot delegate to twig. The table
3897    /// editor is the gesture family that consults no `Syntax` table (it spells a
3898    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
3899    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
3900    /// grid as a *pipe table* and reports success, swapping the element out for
3901    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
3902    /// downstream could tell that from a successful edit — the splice is real,
3903    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
3904    /// stopping at the door rather than detecting after the fact. See
3905    /// [`spells_pipe_tables`].
3906    fn table_op(
3907        &mut self,
3908        what: &str,
3909        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
3910    ) {
3911        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
3912            return;
3913        }
3914        self.record_caret();
3915        let at = self.caret;
3916        let r = op(&mut self.editor, at);
3917        self.apply_table(r, what);
3918    }
3919
3920    /// Insert an empty row below (`below`) or above the caret's row.
3921    pub fn table_insert_row(&mut self, below: bool) {
3922        self.table_op("table row", |e, at| e.table_insert_row(at, below));
3923    }
3924
3925    /// Delete the caret's row (not the header, not the last body row).
3926    pub fn table_delete_row(&mut self) {
3927        self.table_op("table row", |e, at| e.table_delete_row(at));
3928    }
3929
3930    /// Insert an empty column right (`right`) or left of the caret's column.
3931    pub fn table_insert_column(&mut self, right: bool) {
3932        self.table_op("table column", |e, at| e.table_insert_column(at, right));
3933    }
3934
3935    /// Delete the caret's column (unless it is the only one).
3936    pub fn table_delete_column(&mut self) {
3937        self.table_op("table column", |e, at| e.table_delete_column(at));
3938    }
3939
3940    /// Set the caret's column to `alignment`.
3941    pub fn table_set_alignment(&mut self, alignment: Alignment) {
3942        self.table_op("table alignment", |e, at| {
3943            e.table_set_alignment(at, alignment)
3944        });
3945    }
3946
3947    /// Move the caret's row one place down (`down`) or up, within the body rows.
3948    pub fn table_move_row(&mut self, down: bool) {
3949        self.table_op("table row", |e, at| e.table_move_row(at, down));
3950    }
3951
3952    /// Move the caret's column one place right (`right`) or left.
3953    pub fn table_move_column(&mut self, right: bool) {
3954        self.table_op("table column", |e, at| e.table_move_column(at, right));
3955    }
3956
3957    /// Settle the caret and document flags after a table op (or report its
3958    /// error). twig re-spells the whole table, so the caret rides its old byte
3959    /// offset and is clamped back into the rebuilt bytes — near enough to where
3960    /// it was, since the op preserves the cells' content and order around it.
3961    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
3962        match result {
3963            Ok(()) => {
3964                self.last_edit_kind = None;
3965                self.refresh();
3966                self.anchor = None;
3967                self.clamp_caret();
3968                self.dirty = self.source != self.clean_source;
3969                self.status = None;
3970                self.record_caret();
3971            }
3972            Err(e) => self.status = Some(format!("{what}: {e}")),
3973        }
3974    }
3975
3976    /// One `toggle_block_container` over the block-level target.
3977    ///
3978    /// leaf says *where*; twig decides everything else — which blocks the range
3979    /// covers, whether that means wrapping, unwrapping, nesting or converting,
3980    /// and how this document's format spells the prefix. The rule that a
3981    /// container only comes off when the range covers every block it holds is
3982    /// what the re-anchoring below is built around.
3983    fn toggle_container(&mut self, kind: BlockContainerKind) {
3984        // The read-only gate — this door reaches twig without the splice.
3985        if self.read_only {
3986            return;
3987        }
3988        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
3989            return;
3990        }
3991        let selected = self.selection();
3992        // A blank line holds no block, and twig opens an *empty* container on one
3993        // — since 3.2.0; it used to decline the range with `NotFound`, which is
3994        // why this used to lend it a scratch paragraph to wrap. Worth knowing
3995        // here because the line-for-line caret mapping below cannot describe it:
3996        // opening one under a paragraph writes the blank line the format needs
3997        // above the marker too, so the rewritten region has a line the old one
3998        // didn't, and "the same line, the same distance from its end" lands on
3999        // that new blank instead of in the container.
4000        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
4001        // Without a selection the target is the caret's own block, resolved the
4002        // way `set_block` resolves it — a caret at a line end sits at the doc
4003        // level and has to be nudged back onto the block it looks like it's in.
4004        // An empty range is enough: twig widens to the whole lines it touches.
4005        let (start, end) = match selected {
4006            Some(range) => range,
4007            None => {
4008                let off = self.block_offset_for_caret().unwrap_or(self.caret);
4009                (off, off)
4010            }
4011        };
4012        self.record_caret();
4013        match self.editor.toggle_block_container(start, end, kind) {
4014            Ok(change) => {
4015                // Read the caret's place out of the *pre-edit* source, before
4016                // `refresh` swaps that source out from under it.
4017                let place = (selected.is_none() && !opened_empty)
4018                    .then(|| self.caret_line_tail(&change.old));
4019                self.last_edit_kind = None; // structural edit is its own undo step
4020                self.refresh();
4021                match place {
4022                    // Both land the caret at the far end of what twig wrote, and
4023                    // differ only in what they leave selected.
4024                    //
4025                    // From a selection: select what the container now holds, the
4026                    // way `toggle` keeps its marked region selected — and for a
4027                    // stronger reason than symmetry: a container comes *off* only
4028                    // a range covering every block it holds, so a selection left
4029                    // on its old bytes (now short by a prefix per line) would nest
4030                    // on the second press instead of reversing the first.
4031                    //
4032                    // From a blank line: nothing to select, and the end of the
4033                    // region is exactly past the bare `> ` / `- ` twig wrote —
4034                    // the caret standing inside the container that was asked for.
4035                    None => {
4036                        self.anchor = (!opened_empty).then_some(change.new.start);
4037                        self.caret = change.new.end;
4038                    }
4039                    Some(place) => {
4040                        self.anchor = None;
4041                        self.caret = self.line_tail_offset(&change.new, place);
4042                    }
4043                }
4044                self.dirty = self.source != self.clean_source;
4045                self.status = None;
4046                self.clamp_caret();
4047                self.record_caret();
4048            }
4049            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4050        }
4051    }
4052
4053    /// The caret's place inside the region a container toggle is rewriting, in
4054    /// the only terms the rewrite preserves: which of the region's lines it sits
4055    /// on, and how many bytes of that line lie ahead of it.
4056    ///
4057    /// A container's markup goes in at column 0 and never touches what follows
4058    /// on the line, so that pair survives the edit exactly where a byte offset
4059    /// does not — a caret left on its old offset slides back by one prefix per
4060    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
4061    /// `> ` it just asked for.
4062    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
4063        let caret = self.caret.clamp(old.start, old.end);
4064        let line = self.source[old.start..caret].matches('\n').count();
4065        let end = self.source[caret..old.end]
4066            .find('\n')
4067            .map_or(old.end, |i| caret + i);
4068        (line, end - caret)
4069    }
4070
4071    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
4072    /// region: the offset `tail` bytes back from the end of the region's `line`.
4073    ///
4074    /// Both walks are clamped rather than trusted, because the one op that does
4075    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
4076    /// the items back apart with blank lines between them — and a caret landing
4077    /// on the nearest line of the right item beats one landing out of the region
4078    /// entirely.
4079    fn line_tail_offset(
4080        &self,
4081        new: &std::ops::Range<usize>,
4082        (line, tail): (usize, usize),
4083    ) -> usize {
4084        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
4085        let mut start = 0;
4086        for _ in 0..line {
4087            match region[start..].find('\n') {
4088                Some(i) => start += i + 1,
4089                None => break,
4090            }
4091        }
4092        let end = region[start..]
4093            .find('\n')
4094            .map_or(region.len(), |i| start + i);
4095        new.start + end.saturating_sub(tail).max(start)
4096    }
4097
4098    /// Link the selection to `destination` — the toolbar's Link button. With no
4099    /// selection it acts at the caret, which re-points a link the caret is
4100    /// already standing in (twig replaces an existing link's destination and
4101    /// keeps its text) and otherwise spells a link that has no text of its own:
4102    /// an autolink (`<https://x.dev>`) where the destination is one, and
4103    /// `[destination](destination)` where it isn't.
4104    ///
4105    /// `destination` reaches twig raw. Escaping it is format knowledge and the
4106    /// two formats genuinely disagree — Markdown ends a destination at the first
4107    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
4108    /// itself — so the side holding the document is the side that gets to spell
4109    /// it. A destination twig can't carry at all (one with a newline) comes back
4110    /// as an error rather than a quietly rewritten URL.
4111    pub fn insert_link(&mut self, destination: &str) {
4112        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
4113            return;
4114        }
4115        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4116        self.record_caret();
4117        match self.editor.insert_link(start, end, destination) {
4118            Ok(change) => {
4119                self.last_edit_kind = None;
4120                self.refresh();
4121                match self.link_text_span(change.new.start) {
4122                    // A link with text of its own: select it, so typing replaces
4123                    // a `[dest](dest)`'s stand-in label and a second press
4124                    // re-points what the first one linked.
4125                    Some(text) => {
4126                        self.anchor = (text.start != text.end).then_some(text.start);
4127                        self.caret = text.end;
4128                    }
4129                    // An autolink is finished the moment it's written — its text
4130                    // *is* the URL. Leaving it selected would aim the next press
4131                    // at the one shape twig still wraps instead of re-points.
4132                    None => {
4133                        self.anchor = None;
4134                        self.caret = change.new.end;
4135                    }
4136                }
4137                self.dirty = self.source != self.clean_source;
4138                self.status = None;
4139                self.clamp_caret();
4140                self.record_caret();
4141            }
4142            Err(e) => self.status = Some(format!("link: {e}")),
4143        }
4144    }
4145
4146    /// Insert a block-level image at the caret: `![alt](destination)`. Any
4147    /// selection becomes the alt text (so "select a caption, insert image" labels
4148    /// it); with no selection, `alt` is used — empty for none. The caret lands
4149    /// just past the inserted image.
4150    ///
4151    /// Both halves go through twig (`insert_literal` for the alt text,
4152    /// `insert_image` for the image), so neither is spelled here. That used to be a
4153    /// `format!`, and it was wrong the first time an app inserted a real filename:
4154    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
4155    /// not an image at all — and the fix is per-format, since moving into the
4156    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
4157    pub fn insert_image(&mut self, destination: &str, alt: &str) {
4158        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
4159            return;
4160        }
4161        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4162        self.record_caret();
4163        // With no selection and an explicit `alt`, the alt text has to exist in the
4164        // document before it can be the image's — and it is raw caller input, so
4165        // it goes in through `insert_literal`, which escapes it for the format
4166        // rather than letting a `]` in someone's caption close the image early.
4167        let (start, end) = if start == end && !alt.is_empty() {
4168            match self.editor.insert_literal(start, alt) {
4169                Ok(change) => (change.new.start, change.new.end),
4170                Err(e) => {
4171                    self.status = Some(format!("image: {e}"));
4172                    return;
4173                }
4174            }
4175        } else {
4176            (start, end)
4177        };
4178        match self.editor.insert_image(start, end, destination) {
4179            Ok(change) => {
4180                self.last_edit_kind = None;
4181                self.refresh();
4182                // Just past the image, nothing selected — where a caret belongs
4183                // after inserting one.
4184                self.anchor = None;
4185                self.caret = change.new.end;
4186                self.dirty = self.source != self.clean_source;
4187                self.status = None;
4188                self.clamp_caret();
4189                self.record_caret();
4190            }
4191            Err(e) => self.status = Some(format!("image: {e}")),
4192        }
4193    }
4194
4195    /// Insert a block-level image, video, or audio at the caret. The image case
4196    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
4197    /// HTML elements, which is the only spelling Markdown and Djot have for them:
4198    ///
4199    /// ```text
4200    /// <video src="clip.mp4" controls>alt</video>
4201    /// <audio src="take.mp3" controls>alt</audio>
4202    /// ```
4203    ///
4204    /// HTML rather than a `::video{…}` directive deliberately. A directive means
4205    /// something only to an app that knows the vocabulary, so the document would
4206    /// read as literal punctuation everywhere else; `<video>` is what every other
4207    /// renderer already understands, and what leaf's own reader picks back up
4208    /// through `html_elements` promotion (see [`parse_extensions`]).
4209    ///
4210    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
4211    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
4212    /// `html_elements`. Before that only the multi-line form parsed as a block at
4213    /// all, and this wrote three lines to work around it.
4214    ///
4215    /// `controls` is always written: a player with no transport is a still frame
4216    /// the reader can't do anything with. Any selection becomes the element's
4217    /// fallback text, exactly as it becomes an image's alt.
4218    ///
4219    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
4220    /// applies, and bites harder here: a `"` in `destination` closes the
4221    /// attribute. A frontend taking these from a file picker is fine; one taking
4222    /// them from free text should keep them tame.
4223    ///
4224    /// [`MediaInfo`]: crate::MediaInfo
4225    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
4226        if kind == MediaKind::Image {
4227            return self.insert_image(destination, alt);
4228        }
4229        // Gated on the *image* gesture, not on one of its own — there isn't one,
4230        // since the bytes below are spelled here rather than by twig, and an HTML
4231        // document would in fact parse them. The button is one control with three
4232        // kinds behind it, and two of them working in a format where the third
4233        // cannot is a worse surface than three that agree — especially as
4234        // `insert_image` is the kind anyone reaches for first.
4235        if self.refuse_unsupported("media", Gesture::InsertImage) {
4236            return;
4237        }
4238        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4239        let alt_text = self
4240            .selected_text()
4241            .map(str::to_string)
4242            .unwrap_or_else(|| alt.to_string());
4243        let tag = match kind {
4244            MediaKind::Audio => "audio",
4245            _ => "video",
4246        };
4247        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
4248        self.edit(start, end, &markup);
4249    }
4250
4251    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
4252    /// button. Spelling and placement are both twig's; leaf used to write `---`
4253    /// itself, which was the Markdown spelling in a djot document too.
4254    ///
4255    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
4256    /// mid-paragraph and lands it after the caret's whole block. To get a rule
4257    /// *at* the caret — the paragraph parted in two around it, which is what a
4258    /// rule button is understood to do — the paragraph is first divided with
4259    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
4260    /// the offset `split_block` returns puts the rule after the *second* half
4261    /// instead, which is a rule in the right document and the wrong place.
4262    ///
4263    /// Only a plain paragraph is split. Everywhere else the rule simply lands
4264    /// after the block, which is both twig's own answer and the better one:
4265    /// splitting a fenced code block would leave two fences with a rule between
4266    /// them, and splitting a list item would mint an item nobody asked for on the
4267    /// way to a rule that lands after the list regardless. A table and a setext
4268    /// heading refuse the split outright, so they take the same path by
4269    /// themselves.
4270    pub fn insert_thematic_break(&mut self) {
4271        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
4272        {
4273            return;
4274        }
4275        self.caret = self.skip_trailing_close_delims(self.caret);
4276        // A selection is replaced by the rule, so collapse it first and let the
4277        // split-and-rule below run from the caret it leaves behind.
4278        if let Some((s, e)) = self.selection() {
4279            self.splice(s, e, "", EditKind::Other);
4280        }
4281        self.anchor = None;
4282        self.record_caret();
4283        let at = self.caret;
4284        if self.caret_in_bare_paragraph() {
4285            // A failure here is not fatal: the rule still lands after the block,
4286            // which is exactly what this call was trying to improve on.
4287            let _ = self.editor.split_block(at);
4288        }
4289        match self.editor.insert_thematic_break(at) {
4290            Ok(change) => {
4291                self.last_edit_kind = None;
4292                self.refresh();
4293                self.anchor = None;
4294                self.caret = change.new.end;
4295                self.dirty = self.source != self.clean_source;
4296                self.status = None;
4297                self.clamp_caret();
4298                self.record_caret();
4299            }
4300            Err(e) => self.status = Some(format!("thematic break: {e}")),
4301        }
4302    }
4303
4304    /// Whether the caret sits in a paragraph and nothing else — no list item, no
4305    /// quote, no fence, no table. The one shape where parting the block around
4306    /// the caret is unambiguously what a rule button means; see
4307    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
4308    /// container is left to take the rule after itself.
4309    fn caret_in_bare_paragraph(&mut self) -> bool {
4310        let caret = self.caret.min(self.source.len());
4311        let Ok(chain) = self.editor.ancestors_at(caret) else {
4312            return false;
4313        };
4314        let mut in_para = false;
4315        for m in chain {
4316            match m.kind {
4317                Kind::Para => in_para = true,
4318                Kind::ListItem
4319                | Kind::TaskListItem
4320                | Kind::BlockQuote
4321                | Kind::CodeBlock
4322                | Kind::Table => return false,
4323                _ => {}
4324            }
4325        }
4326        in_para
4327    }
4328
4329    /// The destination of the link under the caret — what a Link prompt shows so
4330    /// ⌘K on an existing link edits its URL instead of asking for it again.
4331    /// `None` when the caret stands in no link.
4332    ///
4333    /// An autolink carries no separate destination: its text *is* the URL, so
4334    /// that's what comes back for one.
4335    pub fn link_destination_at_caret(&mut self) -> Option<String> {
4336        self.link_destination_at(self.caret)
4337    }
4338
4339    /// The destination of the link at `off`.
4340    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
4341    /// the caret isn't.
4342    ///
4343    /// The offset form exists for the same reason
4344    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
4345    /// the document somewhere else — a footnote's text in a popover, say — has
4346    /// rows and runs but no caret in them, and still needs to know which of those
4347    /// runs a reader can follow.
4348    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
4349        self.nodes()
4350            .into_iter()
4351            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
4352            .filter(|n| n.span.start <= off && off < n.span.end)
4353            .max_by_key(|n| n.span.start)
4354            .and_then(|n| n.destination.or(n.text))
4355    }
4356
4357    /// Where the locator `id` lands in this document — the `#v2` half of a
4358    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
4359    /// answers to it.
4360    ///
4361    /// The other end of a link, and the reason this exists: without it a
4362    /// destination has only file granularity, so following a citation into a
4363    /// chapter drops the reader at the top of it to hunt for the verse. Which is
4364    /// also why it is a *document* query rather than a caret one — the document
4365    /// being asked is usually not the one the reader is in.
4366    ///
4367    /// Three readings, tried in order, because the same `#some-heading` is
4368    /// written three ways across the formats leaf opens:
4369    ///
4370    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
4371    ///    the auto-ids djot mints for its headings. The only exact answer, so it
4372    ///    goes first — a document that says `{#v1}` has settled the question.
4373    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
4374    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
4375    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
4376    ///    authored anywhere land on a djot heading.
4377    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
4378    ///    none and `{#custom}` is literal text in a Markdown heading — so for
4379    ///    the format most vaults are written in, the heading's own words are the
4380    ///    only thing a fragment can name. This is the rule every Markdown
4381    ///    renderer already follows, which is what makes `#a-heading` mean in
4382    ///    diaryx what it means on the web.
4383    ///
4384    /// Ties go to the earliest match, then to the widest: a duplicated id is the
4385    /// document's mistake and the first one is the answer every anchor
4386    /// implementation gives, while preferring the wider span picks the section
4387    /// over the heading that opens it — more for a peek to show, same place to
4388    /// land.
4389    pub fn locate(&mut self, id: &str) -> Option<Landing> {
4390        let id = id.trim();
4391        if id.is_empty() {
4392            return None;
4393        }
4394        let nodes = self.nodes();
4395
4396        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
4397        // is picking, among nodes that start together, the one that ends last.
4398        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
4399            matches
4400                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
4401                .map(|n| Landing {
4402                    start: n.span.start,
4403                    end: n.span.end,
4404                })
4405        };
4406
4407        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
4408            return Some(landing);
4409        }
4410        let want = slug(id);
4411        if want.is_empty() {
4412            return None;
4413        }
4414        if let Some(landing) = pick(
4415            &mut nodes
4416                .iter()
4417                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
4418        ) {
4419            return Some(landing);
4420        }
4421
4422        // A heading by its words. Its span is one line, so the end comes from
4423        // where the *section* it opens gives out — the next heading that is not
4424        // under it, or the end of the document. A Markdown heading has no
4425        // section node to ask (twig only builds those for djot), and a peek that
4426        // showed the heading alone would answer "what does that say" with the
4427        // title of the thing it says.
4428        let heading = nodes
4429            .iter()
4430            .filter(|n| n.kind == Kind::Heading)
4431            .filter(|n| {
4432                n.content_span
4433                    .clone()
4434                    .and_then(|s| self.source.get(s))
4435                    .is_some_and(|text| slug(text) == want)
4436            })
4437            .min_by_key(|n| n.span.start)?;
4438        let level = heading.level.unwrap_or(u32::MAX);
4439        let end = nodes
4440            .iter()
4441            .filter(|n| n.kind == Kind::Heading)
4442            .filter(|n| n.span.start > heading.span.start)
4443            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
4444            .map(|n| n.span.start)
4445            .min()
4446            .unwrap_or(self.source.len());
4447        Some(Landing {
4448            start: heading.span.start,
4449            end,
4450        })
4451    }
4452
4453    /// Write a footnote at the caret — the toolbar's Footnote button, and the
4454    /// one gesture in the footnote story that *authors* rather than follows.
4455    ///
4456    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
4457    /// the `[^1]:` definition at the end of the document. Half a footnote is not
4458    /// a footnote — a bare reference with nothing defining it renders as literal
4459    /// brackets — so a single button that wrote only the reference would leave
4460    /// the author to hand-spell the other half in a document that had just
4461    /// stopped showing them what the first half meant. One edit also means one
4462    /// undo takes both back.
4463    ///
4464    /// The definition's body is left empty and **the caret lands in it**, which
4465    /// is the whole point of pressing the button: nobody wants a reference to a
4466    /// note they have not written yet. Getting back to where they were writing
4467    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
4468    /// the same return leg a reader following a reference already uses, so the
4469    /// author is left standing on the near end of a round trip that works.
4470    ///
4471    /// A selection collapses to its *end* rather than being replaced: a
4472    /// reference annotates the words before it, so "select the claim, add a
4473    /// footnote" should mark that claim, not consume it.
4474    pub fn insert_footnote(&mut self) {
4475        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
4476            return;
4477        }
4478        let at = self.selection().map_or(self.caret, |(_, end)| end);
4479        self.anchor = None;
4480        self.caret = at;
4481        self.record_caret();
4482        let label = self.next_footnote_label();
4483        match self.editor.insert_footnote(at, &label) {
4484            Ok(change) => {
4485                self.last_edit_kind = None;
4486                self.refresh();
4487                self.anchor = None;
4488                // `change.new` runs from the reference to the end of the
4489                // document, so its start is the `[^1]` just written and
4490                // `footnote_at` resolves it to the note the same way a reader's
4491                // tap does — and to the note's *body*, which is already a caret
4492                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
4493                // and has none), so this needs no snap on top. The fallback is
4494                // the reference's own offset: a format that spelled the pair some
4495                // way leaf can't read back should still leave the caret on the
4496                // edit rather than at the far end of a document it just grew.
4497                self.caret = self
4498                    .footnote_at(change.new.start)
4499                    .and_then(|note| note.offset)
4500                    .unwrap_or(change.new.start);
4501                self.dirty = self.source != self.clean_source;
4502                self.status = None;
4503                self.clamp_caret();
4504                self.record_caret();
4505            }
4506            Err(e) => self.status = Some(format!("footnote: {e}")),
4507        }
4508    }
4509
4510    /// The label to give a footnote the author has not named: the lowest counting
4511    /// number no footnote in the document is already wearing.
4512    ///
4513    /// twig takes the label rather than minting one, because it holds no opinion
4514    /// about what a document's footnotes should be called — and it is right not
4515    /// to. Numbering them is what every author of a numbered note expects, and
4516    /// re-using a taken number would silently point the new reference at somebody
4517    /// else's note (twig reuses an existing definition rather than appending a
4518    /// second one, which is the right rule for citing a note twice on purpose and
4519    /// exactly the wrong accident to have by default).
4520    ///
4521    /// *References* are counted alongside definitions, not just definitions: a
4522    /// document carrying a dangling `[^2]` has a 2 that means something to
4523    /// whoever wrote it, and minting a definition for it here would answer a
4524    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
4525    /// count entirely — they take no number, so they block none.
4526    fn next_footnote_label(&mut self) -> String {
4527        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
4528            .into_iter()
4529            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
4530            .filter_map(|label| label.parse().ok())
4531            .collect();
4532        taken.extend(
4533            self.nodes()
4534                .into_iter()
4535                .filter(|n| n.kind == Kind::FootnoteReference)
4536                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
4537                .filter_map(|label| label.parse::<u32>().ok()),
4538        );
4539        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
4540    }
4541
4542    /// The footnote reference under the caret, resolved to the note it names.
4543    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
4544    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
4545        self.footnote_at(self.caret)
4546    }
4547
4548    /// The footnote reference at `off`, resolved to the note it names — what a
4549    /// frontend shows when a reader activates a `[^1]`.
4550    ///
4551    /// A reference is not a link node, so
4552    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
4553    /// (and should not) answer for one: a link names a destination to leave for,
4554    /// a reference names a note that is already in this document. Following one
4555    /// is a move within the page, which is why this hands back an `offset`
4556    /// rather than something to open.
4557    ///
4558    /// Offset-based rather than caret-only because the gesture that wants this
4559    /// most is the one that must not move the caret: a pointer hovering a `[1]`
4560    /// asks what note it names without disturbing where the reader was typing.
4561    /// The caret is just the offset a click already placed —
4562    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
4563    ///
4564    /// `None` when `off` stands in no reference. A reference whose note the
4565    /// document never defines is *not* `None` — it answers with the label it
4566    /// looked for and no text, which is what lets a frontend say so instead of
4567    /// silently doing nothing.
4568    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
4569        // Innermost-wins by latest start, the rule its link sibling uses.
4570        let span = self
4571            .nodes()
4572            .into_iter()
4573            .filter(|n| n.kind == Kind::FootnoteReference)
4574            .filter(|n| n.span.start <= off && off < n.span.end)
4575            .max_by_key(|n| n.span.start)?
4576            .span;
4577        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
4578
4579        // The note itself. Definitions are roots beside `doc` rather than
4580        // children of it, so they're asked for directly — see
4581        // `wysiwyg::footnote_definitions`.
4582        let note = wysiwyg::footnote_definitions(&mut self.editor)
4583            .into_iter()
4584            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
4585        let Some(note) = note else {
4586            return Some(FootnoteRef {
4587                label,
4588                text: None,
4589                offset: None,
4590                end: None,
4591            });
4592        };
4593        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
4594        Some(FootnoteRef {
4595            label,
4596            text: body
4597                .clone()
4598                .and_then(|b| self.source.get(b))
4599                .map(str::to_string),
4600            // The body's start, not the definition's — see `FootnoteRef::offset`.
4601            offset: body.clone().map(|b| b.start),
4602            end: body.map(|b| b.end),
4603        })
4604    }
4605
4606    /// The footnote *definition* the caret stands in, and where the reference
4607    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
4608    /// at the caret's offset.
4609    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
4610        self.footnote_definition_at(self.caret)
4611    }
4612
4613    /// The footnote definition spanning `off`, and where the reference that
4614    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
4615    ///
4616    /// The mirror image, deliberately: the same gesture that takes a reader from
4617    /// `[1]` down to the note takes them from the note back up to `[1]`, so
4618    /// following a footnote is a round trip rather than a fall. It needs no
4619    /// memory of how the reader arrived — the document says where the reference
4620    /// is — which is what makes it work for a reader who scrolled to the notes
4621    /// themselves, and what keeps it right after an edit moves either end.
4622    ///
4623    /// `None` when `off` stands in no definition. A definition nothing cites is
4624    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
4625    /// its label and no offset, so a frontend can say "nothing refers to this"
4626    /// rather than offer a jump that goes nowhere.
4627    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
4628        // Definitions are roots beside `doc`, so `nodes()` — which walks the
4629        // document body — never reports one. They're asked for directly, the way
4630        // `footnote_at` asks for the note it resolves to.
4631        //
4632        // Closed at the end, unlike the half-open test its neighbours use. A
4633        // definition's span stops at its last content byte — the newline ending
4634        // the line is outside it — so `span.end` is the caret stop at the end of
4635        // the note's own row, not the first byte of anything after. Excluding it
4636        // meant the one caret an author is guaranteed to have, the one left
4637        // sitting at the end of the note they just typed, was in no definition at
4638        // all: writing a note and then asking to go back to its reference
4639        // answered nothing. Two definitions in a row still can't both match —
4640        // there is a blank line between them — and `max_by_key` decides anyway.
4641        let note = wysiwyg::footnote_definitions(&mut self.editor)
4642            .into_iter()
4643            .filter(|m| m.span.start <= off && off <= m.span.end)
4644            .max_by_key(|m| m.span.start)?;
4645        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
4646
4647        // The earliest reference carrying this label. `min` rather than a `find`,
4648        // because `nodes()` reports a flattened walk whose order is twig's
4649        // business, not document order. Bound first: the walk needs `&mut self`
4650        // and reading the labels back out needs `&self.source`.
4651        let nodes = self.nodes();
4652        let offset = nodes
4653            .into_iter()
4654            .filter(|n| n.kind == Kind::FootnoteReference)
4655            .filter(|n| {
4656                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
4657            })
4658            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
4659            .map(|n| n.span.start + 2)
4660            .min();
4661        Some(FootnoteDef { label, offset })
4662    }
4663
4664    /// The destination of the image under the caret — what an image prompt shows
4665    /// so editing an existing image starts from its current URL instead of blank,
4666    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
4667    /// `None` when the caret stands in no image. A caret resting just after a
4668    /// block image (its trailing stop) is still "in" it — the half-open span test
4669    /// excludes that offset, which is the intended precision: past the image is
4670    /// past it.
4671    pub fn image_destination_at_caret(&mut self) -> Option<String> {
4672        let off = self.caret;
4673        self.nodes()
4674            .into_iter()
4675            .filter(|n| n.kind == Kind::Image)
4676            .filter(|n| n.span.start <= off && off < n.span.end)
4677            .max_by_key(|n| n.span.start)
4678            .and_then(|n| n.destination)
4679    }
4680
4681    /// The language of the fenced code block the caret stands in — what a
4682    /// language prompt shows so editing it starts from the current value rather
4683    /// than blank. `None` when the caret is in no code block, or in one whose
4684    /// fence carries no language (or an indented block, which has no fence).
4685    pub fn code_language_at_caret(&mut self) -> Option<String> {
4686        let start = self.code_block_start_at_caret()?;
4687        wysiwyg::code_language(&self.source, start)
4688    }
4689
4690    /// Whether the caret stands in a fenced code block — the one a language
4691    /// prompt could edit. A frontend gates its "set language" affordance on this
4692    /// (an indented block, which can't carry a language, reports `false`).
4693    pub fn caret_in_fenced_code(&mut self) -> bool {
4694        self.code_block_start_at_caret()
4695            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
4696    }
4697
4698    /// Set (or clear, with `""`) the language of the fenced code block the caret
4699    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
4700    /// block, and a reported error for a language the format's fence cannot
4701    /// carry.
4702    ///
4703    /// twig rewrites the info string, so the fence's own width — measured
4704    /// against a body neither side touches — is kept, and a language holding a
4705    /// space, a line end or the fence character is refused rather than written
4706    /// out to reparse as something else. Leaf used to splice over the info span
4707    /// itself and `trim()` the input, which handled the one bad case it had
4708    /// thought of.
4709    pub fn set_code_language(&mut self, lang: &str) {
4710        // The read-only gate — this door reaches twig without the splice.
4711        if self.read_only {
4712            return;
4713        }
4714        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
4715            return;
4716        }
4717        if self.code_block_start_at_caret().is_none() {
4718            return;
4719        }
4720        let lang = lang.trim();
4721        // `None` clears the info string; `Some("")` asks for an empty one. Both
4722        // write a bare fence, and the prompt's empty value means "clear".
4723        let want = (!lang.is_empty()).then_some(lang);
4724        self.record_caret();
4725        match self.editor.set_code_language(self.caret, want) {
4726            Ok(_) => {
4727                self.last_edit_kind = None;
4728                self.refresh();
4729                self.anchor = None;
4730                self.dirty = self.source != self.clean_source;
4731                self.status = None;
4732                self.clamp_caret();
4733                self.record_caret();
4734            }
4735            Err(e) => self.status = Some(format!("code language: {e}")),
4736        }
4737    }
4738
4739    /// The `span.start` of the code block covering the caret — the anchor
4740    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
4741    /// in none.
4742    fn code_block_start_at_caret(&mut self) -> Option<usize> {
4743        let off = self.caret;
4744        self.nodes()
4745            .into_iter()
4746            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
4747            .max_by_key(|n| n.span.start)
4748            .map(|n| n.span.start)
4749    }
4750
4751    /// The source range of the text inside the link covering `off` — what sits
4752    /// between its `[` and `]`. `None` when twig reports no link there.
4753    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
4754        self.nodes()
4755            .into_iter()
4756            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
4757            // the other's `span.start`; the link that starts latest at or before
4758            // `off` is the one `off` is actually in.
4759            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
4760            .max_by_key(|n| n.span.start)
4761            .and_then(|n| n.content_span)
4762    }
4763
4764    // ── undo / redo ───────────────────────────────────────────────────────────
4765    // twig owns the history of *bytes* (it owns the buffer) and now carries the
4766    // caret through it too: `record_caret` stashes each state's caret in twig's
4767    // opaque per-step blob, and undo/redo hand it back with the source they
4768    // restore. So leaf keeps no history of its own — no parallel stacks to march
4769    // in lockstep and silently drift out of it.
4770
4771    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
4772    /// where they were when that step began.
4773    pub fn undo(&mut self) {
4774        if self.read_only {
4775            return;
4776        }
4777        let (undone, redoable) = (self.undo_steps, self.redo_steps);
4778        match self.editor.undo() {
4779            Ok(Some(change)) => {
4780                self.after_history(change);
4781                // `refresh` counted the restore as an edit; it was a step back.
4782                self.undo_steps = undone.saturating_sub(1);
4783                self.redo_steps = redoable + 1;
4784            }
4785            Ok(None) => {
4786                self.undo_steps = 0;
4787                self.status = Some("nothing to undo".into());
4788            }
4789            Err(e) => self.status = Some(format!("undo: {e}")),
4790        }
4791    }
4792
4793    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
4794    /// selection back where that step originally left them.
4795    pub fn redo(&mut self) {
4796        if self.read_only {
4797            return;
4798        }
4799        let (undone, redoable) = (self.undo_steps, self.redo_steps);
4800        match self.editor.redo() {
4801            Ok(Some(change)) => {
4802                self.after_history(change);
4803                // `refresh` counted the restore as an edit; it was a step forward.
4804                self.undo_steps = undone + 1;
4805                self.redo_steps = redoable.saturating_sub(1);
4806            }
4807            Ok(None) => {
4808                self.redo_steps = 0;
4809                self.status = Some("nothing to redo".into());
4810            }
4811            Err(e) => self.status = Some(format!("redo: {e}")),
4812        }
4813    }
4814
4815    /// Refresh the cached source and put the caret back where the step being
4816    /// undone/redone had it, clearing any active run.
4817    ///
4818    /// The caret comes from twig's blob for the restored state (what
4819    /// `record_caret` stored). `change` is only the fallback for a state with no
4820    /// blob — a caret at the end of the restored text, which is where this always
4821    /// landed before the blobs were kept. It is the edit site, not where the user
4822    /// was standing, so it's a floor and not the behaviour: undoing should hand
4823    /// back the document *and* the place you were working, which for an edit made
4824    /// anywhere but under the caret are two different places.
4825    fn after_history(&mut self, change: Change) {
4826        self.refresh();
4827        match self
4828            .editor
4829            .caret_blob()
4830            .ok()
4831            .and_then(|b| CaretState::from_blob(&b))
4832        {
4833            Some(state) => {
4834                self.caret = state.caret.min(self.source.len());
4835                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
4836            }
4837            None => {
4838                self.caret = change.new.end.min(self.source.len());
4839                self.anchor = None;
4840            }
4841        }
4842        self.goal_col = None;
4843        self.last_edit_kind = None;
4844        self.dirty = self.source != self.clean_source;
4845        self.status = None;
4846        self.clamp_caret();
4847    }
4848
4849    // ── the file ──────────────────────────────────────────────────────────────
4850
4851    #[cfg(feature = "fs")]
4852    pub fn save(&mut self) {
4853        if self.is_untitled() {
4854            // No path to write and no name to invent: ⌘S on an untitled document
4855            // is a Save As, and only a frontend has a picker to ask with. Say so
4856            // rather than failing at the filesystem with an empty path.
4857            self.status = Some("untitled — save as…".into());
4858            return;
4859        }
4860        let path = self.path.clone();
4861        if self.write(&path) {
4862            self.mark_saved();
4863        }
4864    }
4865
4866    /// Save As: write the document to `path` and *move* it there — `self.path`
4867    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
4868    /// what Save As means; a copy would leave the user editing a document whose
4869    /// name is no longer where their keystrokes go.
4870    ///
4871    /// The move only happens if the bytes actually landed. A failed write leaves
4872    /// the path, `dirty`, and the disk watermark exactly as they were, with the
4873    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
4874    /// must never come away believing it was saved.
4875    ///
4876    /// An existing `path` is overwritten, and the caller is the one that knows
4877    /// whether to ask first: a Save As picker has already run that prompt, and a
4878    /// second confirmation from down here would be the same question twice.
4879    ///
4880    /// `format` does **not** follow the new extension. The buffer is parsed as
4881    /// the format it was opened with, and re-reading it as another one is a
4882    /// conversion — a different, lossy operation that would throw away the undo
4883    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
4884    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
4885    /// until it's reopened.
4886    #[cfg(feature = "fs")]
4887    pub fn save_as(&mut self, path: PathBuf) {
4888        if !self.write(&path) {
4889            return;
4890        }
4891        self.path = path;
4892        self.mark_saved();
4893    }
4894
4895    /// Put `source` on disk at `path`, reporting whether it got there. The one
4896    /// place leaf writes a document, so a save and a Save As can't disagree
4897    /// about what a failure looks like.
4898    #[cfg(feature = "fs")]
4899    fn write(&mut self, path: &Path) -> bool {
4900        match std::fs::write(path, self.source.as_bytes()) {
4901            Ok(()) => true,
4902            Err(e) => {
4903                self.status = Some(format!("save failed: {e}"));
4904                false
4905            }
4906        }
4907    }
4908
4909    /// Re-base the document's saved watermark to the current bytes: clears
4910    /// `dirty`, records `source` as the new clean state (so undoing back to here
4911    /// clears the flag again), and re-stamps the on-disk hash.
4912    ///
4913    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
4914    /// the hook a **filesystem-free host** calls itself once it has persisted
4915    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
4916    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
4917    /// are already where that host wants them, and this just tells the model they
4918    /// are safe.
4919    pub fn mark_saved(&mut self) {
4920        self.clean_source = self.source.clone();
4921        self.dirty = false;
4922        // The bytes on disk are now ours, so this is the new watermark: without
4923        // re-stamping it, every save would report its own work as an external
4924        // change forever after.
4925        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
4926        self.status = Some(format!("saved {}", self.file_name()));
4927    }
4928
4929    /// What the file looks like now against the bytes leaf last read or wrote.
4930    ///
4931    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
4932    /// so this is a filesystem round-trip, not a per-frame question — ask it
4933    /// when a window regains focus, on a timer, or before a save.
4934    ///
4935    /// This *only* reports the file. Whether the document also has unsaved edits
4936    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
4937    /// [`DiskState::Changed`] means a save overwrites someone's work and a
4938    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
4939    /// it has no way to ask — so it hands a frontend both halves and lets it put
4940    /// the question to the person who can answer it.
4941    #[cfg(feature = "fs")]
4942    pub fn disk_state(&self) -> DiskState {
4943        let Some(want) = self.disk_hash else {
4944            return DiskState::Untitled;
4945        };
4946        match std::fs::read(&self.path) {
4947            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
4948            Ok(_) => DiskState::Changed,
4949            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
4950            Err(_) => DiskState::Unreadable,
4951        }
4952    }
4953
4954    /// Re-read the file and replace the document with what's there — the other
4955    /// answer to a [`DiskState::Changed`].
4956    ///
4957    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
4958    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
4959    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
4960    /// document shouldn't have to argue with a guard.
4961    ///
4962    /// **The undo history survives, and the reload is one step in it.** The
4963    /// whole buffer is spliced with the file's bytes through the same door every
4964    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
4965    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
4966    /// swapped the document out from under a reader gives them back what they
4967    /// were looking at, marked dirty, and ^Z again carries on into whatever they
4968    /// had done before it. This used to build a fresh parse and drop the stack,
4969    /// on the reasoning that twig's history belongs to the buffer and these are
4970    /// different bytes; that is true of *rebasing* a step onto them and not of
4971    /// recording the swap itself as one, which is all this is. A splice twig
4972    /// won't take falls back to the fresh parse, and only that path still costs
4973    /// the history.
4974    ///
4975    /// The caret keeps its byte offset, clamped to the new length; the selection
4976    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
4977    /// file changed, so it can't know where the caret "still" is. Clamping keeps
4978    /// it where the user left it in the common case (a change further down the
4979    /// file, or none in the text they're sitting in), and never puts it
4980    /// somewhere invalid. A selection has two such offsets and no such excuse —
4981    /// silently reinterpreting one over changed bytes would arm the *next*
4982    /// keystroke to delete something the user never selected.
4983    ///
4984    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
4985    /// document alone with a status.
4986    #[cfg(feature = "fs")]
4987    pub fn reload(&mut self) {
4988        if self.is_untitled() {
4989            self.status = Some("no file to reload".into());
4990            return;
4991        }
4992        let bytes = match std::fs::read(&self.path) {
4993            Ok(b) => b,
4994            Err(e) => {
4995                self.status = Some(format!("reload failed: {e}"));
4996                return;
4997            }
4998        };
4999        let Ok(source) = String::from_utf8(bytes) else {
5000            self.status = Some("reload failed: file is not UTF-8".into());
5001            return;
5002        };
5003        // Already these bytes — someone saved a file back unchanged, or leaf's
5004        // own write is being read back. Re-baseline against it and stop: a
5005        // splice of the text onto itself would put an undo step on the stack for
5006        // something nobody did.
5007        if source == self.source {
5008            self.disk_hash = Some(hash_bytes(source.as_bytes()));
5009            self.clean_source = source;
5010            self.dirty = false;
5011            self.status = Some(format!("reloaded {}", self.file_name()));
5012            return;
5013        }
5014        let caret = self.caret;
5015        // The pre-reload caret, so undoing the swap puts it back where the
5016        // reader was standing — the same bracketing `splice_exact` does.
5017        self.record_caret();
5018        if self
5019            .editor
5020            .edit_range(0, self.source.len(), &source)
5021            .is_ok()
5022        {
5023            self.refresh();
5024        } else {
5025            // twig wouldn't take the splice. Start over from the bytes, which is
5026            // what this always did, and is the one path that still costs the
5027            // history — `format` is the format this document *is*, not what the
5028            // (unchanged) name now says, see `save_as`.
5029            match new_editor(source.as_bytes(), self.format) {
5030                Ok(editor) => {
5031                    self.editor = editor;
5032                    self.source = source.clone();
5033                    // Not going through `refresh`, so the revision has to move
5034                    // here or every frontend keeps painting the old file from
5035                    // cache.
5036                    self.revision += 1;
5037                }
5038                Err(e) => {
5039                    self.status = Some(format!("reload failed: {e}"));
5040                    return;
5041                }
5042            }
5043        }
5044        self.disk_hash = Some(hash_bytes(source.as_bytes()));
5045        self.clean_source = self.source.clone();
5046        self.caret = caret.min(self.source.len());
5047        self.anchor = None;
5048        self.goal_col = None;
5049        self.last_edit_kind = None;
5050        self.dirty = false;
5051        self.status = Some(format!("reloaded {}", self.file_name()));
5052        self.clamp_caret();
5053        // And the post-reload caret, so a redo restores it.
5054        self.record_caret();
5055    }
5056
5057    /// Re-read the source from twig after it has changed the document. The one
5058    /// funnel every edit, undo, and redo comes through — so it's where the
5059    /// revision moves, and anything cached against the text dies here.
5060    fn refresh(&mut self) {
5061        if let Ok(s) = self.editor.source_str() {
5062            self.source = s;
5063        }
5064        self.revision += 1;
5065        // An edit is a step onto the history and the end of anything undone;
5066        // `undo`/`redo` come through here too and correct this after.
5067        self.undo_steps += 1;
5068        self.redo_steps = 0;
5069        self.clamp_caret();
5070    }
5071
5072    /// Whether [`undo`](Self::undo) has a step to take back — for a native
5073    /// Edit menu to enable its item by. See the note on `undo_steps` for what
5074    /// "has" means here.
5075    pub fn can_undo(&self) -> bool {
5076        !self.read_only && self.undo_steps > 0
5077    }
5078
5079    /// Whether [`redo`](Self::redo) has an undone step to restore.
5080    pub fn can_redo(&self) -> bool {
5081        !self.read_only && self.redo_steps > 0
5082    }
5083
5084    // ── caret movement ─────────────────────────────────────────────────────────
5085    // `extend` grows the selection (Shift+motion): it pins the anchor on the
5086    // first extended step and moves only the caret; an un-extended motion drops
5087    // the selection.
5088
5089    /// Place the caret at byte `offset` (clamped to a char boundary), extending
5090    /// the selection when `extend` is set. The public form of `move_to`, for a
5091    /// frontend that hit-tests pixels straight to a source offset.
5092    pub fn place_caret(&mut self, offset: usize, extend: bool) {
5093        self.goal_col = None;
5094        let before = self.caret;
5095        // A pixel hit-test can land between the visible caret stops — in the
5096        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
5097        // Snap to the nearest real stop so the caret can't come to rest where it
5098        // would draw in one place and type in another. The `(row, col)` click
5099        // path (`click`) already snaps this way through `offset_of_pos`; the
5100        // source view reaches every byte, so it snaps to nothing.
5101        let target = match self.view {
5102            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
5103            // The source view reaches every byte, so there is no stop to snap
5104            // to — but "every byte" still means every *character* boundary. A
5105            // caret resting inside a multi-byte character draws nowhere real
5106            // and panics the next time anything slices there.
5107            View::Source => self.char_boundary_at_or_before(offset),
5108        };
5109        self.move_to(target, extend);
5110        self.clamp_caret();
5111        self.debug_assert_on_a_stop(before);
5112    }
5113
5114    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
5115    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
5116    /// grab the metadata) while the source view still selects the literal whole.
5117    pub fn select_all(&mut self) {
5118        self.anchor = Some(self.caret_floor());
5119        self.caret = self.source.len();
5120        self.goal_col = None;
5121        self.last_edit_kind = None;
5122        self.status = None;
5123    }
5124
5125    /// Select the word (or whitespace / punctuation run) at `offset` — the
5126    /// double-click gesture. Anchors on the run's start with the caret at its
5127    /// end so a following Shift-motion extends from the far edge.
5128    pub fn select_word_at(&mut self, offset: usize) {
5129        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
5130        self.anchor = Some(s);
5131        self.caret = e;
5132        self.goal_col = None;
5133        self.last_edit_kind = None;
5134        self.status = None;
5135        self.clamp_caret();
5136    }
5137
5138    /// Select the whole enclosing text block (paragraph, heading, list item's
5139    /// text…) at `offset` — the triple-click gesture. Reads the range straight
5140    /// from the AST (twig's `content_span`), so it selects the entire *logical*
5141    /// paragraph even when that paragraph soft-wraps across several visual rows —
5142    /// where a visual-row-based select breaks down, because one source offset at
5143    /// a wrap boundary belongs to two rows at once.
5144    pub fn select_block_at(&mut self, offset: usize) {
5145        let off = offset.min(self.source.len());
5146        let range = self
5147            .editor
5148            .ancestors_at(off)
5149            .ok()
5150            .and_then(|chain| {
5151                // Ancestors run root → deepest; the deepest node that is neither
5152                // an inline span nor a multi-block container is the text block
5153                // the caret sits in (a paragraph, a heading, a code block…).
5154                chain
5155                    .into_iter()
5156                    .rev()
5157                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5158                    .map(|m| m.content_span.unwrap_or(m.span))
5159            })
5160            .unwrap_or_else(|| source_line_range(&self.source, off));
5161        self.anchor = Some(range.start.min(self.source.len()));
5162        self.caret = range.end.min(self.source.len());
5163        self.goal_col = None;
5164        self.last_edit_kind = None;
5165        self.status = None;
5166        self.clamp_caret();
5167    }
5168
5169    /// Select the exact source range `[start, end)` — anchor at `start`, caret
5170    /// at `end` — without snapping either end to a visible caret stop.
5171    ///
5172    /// The one caret verb that takes a range it was *handed* rather than one it
5173    /// worked out, for a host that already knows the bytes it means: a search
5174    /// hit, an annotation's footprint, a quote re-anchored through
5175    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
5176    /// wrong tool for that, and not by a little — it snaps to the nearest
5177    /// *visible* stop, and where a range butts up against a hidden delimiter
5178    /// the nearest stop is the one before it, so selecting the "needle" of
5179    /// `**needle**` comes back with "needl" and an edit against it strands the
5180    /// "e".
5181    ///
5182    /// What `place_caret` does that is bookkeeping rather than snapping still
5183    /// happens here, because a host handing in a range is not asking to opt out
5184    /// of the invariants:
5185    ///
5186    /// - both ends are clamped into the document and up to
5187    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
5188    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
5189    ///   types into the metadata;
5190    /// - both land on character boundaries, so nothing slices a `é` in half;
5191    /// - the sticky vertical goal column is dropped, and any armed inline mark
5192    ///   disarmed, since a range from outside inherits neither.
5193    ///
5194    /// An empty range is a caret rather than a selection —
5195    /// [`selection`](Self::selection) reports `None` for it, as it does for any
5196    /// anchor that has met the caret.
5197    pub fn select_range(&mut self, start: usize, end: usize) {
5198        let floor = self.caret_floor();
5199        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
5200        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
5201        self.anchor = Some(anchor);
5202        self.caret = caret;
5203        self.goal_col = None;
5204        self.status = None;
5205        self.last_edit_kind = None;
5206        self.clear_pending();
5207    }
5208
5209    /// `offset` itself if it is a character boundary, else the boundary before
5210    /// it. An offset that isn't one draws nowhere real and panics the next time
5211    /// anything slices there.
5212    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
5213        let mut o = offset.min(self.source.len());
5214        while o > 0 && !self.source.is_char_boundary(o) {
5215            o -= 1;
5216        }
5217        o
5218    }
5219
5220    /// The lowest source offset the caret may occupy in the active view. In
5221    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
5222    /// the first rendered offset; the source view reaches everything, so it's 0.
5223    fn caret_floor(&self) -> usize {
5224        match self.view {
5225            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
5226            View::Source => 0,
5227        }
5228    }
5229
5230    /// Land in a table cell with its whole content selected — the anchor at the
5231    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
5232    /// like tabbing into a form field: the text comes up selected, so typing
5233    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
5234    /// end`) collapses to a plain caret home (an empty selection is no selection).
5235    fn select_cell(&mut self, start: usize, end: usize) {
5236        self.select_range(start, end);
5237    }
5238
5239    fn move_to(&mut self, offset: usize, extend: bool) {
5240        if extend {
5241            if self.anchor.is_none() {
5242                self.anchor = Some(self.caret);
5243            }
5244        } else {
5245            self.anchor = None;
5246        }
5247        self.caret = offset.min(self.source.len()).max(self.caret_floor());
5248        self.status = None;
5249        // A caret move ends the current typing/deletion run, so the next edit
5250        // starts a fresh undo group rather than coalescing across the gap.
5251        self.last_edit_kind = None;
5252        // Moving away disarms any sticky mark — "start bold" applies only where
5253        // it was asked for, not wherever the caret next lands.
5254        self.clear_pending();
5255    }
5256
5257    // In the source view, motion walks source bytes / source lines. In the
5258    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
5259    // what steps the caret cleanly over hidden delimiters.
5260
5261    pub fn move_left(&mut self, extend: bool) {
5262        self.goal_col = None;
5263        if !extend && let Some((s, _e)) = self.selection() {
5264            self.move_to(s, false);
5265            return;
5266        }
5267        let target = match self.view {
5268            View::Source => {
5269                if self.caret > 0 {
5270                    prev_boundary(&self.source, self.caret)
5271                } else {
5272                    0
5273                }
5274            }
5275            // Walks caret *stops*, not columns: decoration (a table border, a
5276            // cell's padding) is stepped over in one press, and a hidden
5277            // delimiter never holds the caret up.
5278            View::Wysiwyg => self.vmap.stop_before(self.caret).unwrap_or(self.caret),
5279        };
5280        let before = self.caret;
5281        self.move_to(target, extend);
5282        self.debug_assert_on_a_stop(before);
5283    }
5284
5285    pub fn move_right(&mut self, extend: bool) {
5286        self.goal_col = None;
5287        if !extend && let Some((_s, e)) = self.selection() {
5288            self.move_to(e, false);
5289            return;
5290        }
5291        let target = match self.view {
5292            View::Source => {
5293                if self.caret < self.source.len() {
5294                    next_boundary(&self.source, self.caret)
5295                } else {
5296                    self.caret
5297                }
5298            }
5299            View::Wysiwyg => self.vmap.stop_after(self.caret).unwrap_or(self.caret),
5300        };
5301        let before = self.caret;
5302        self.move_to(target, extend);
5303        self.debug_assert_on_a_stop(before);
5304    }
5305
5306    /// Move to the start of the previous word (⌥← / Ctrl+←).
5307    pub fn move_word_left(&mut self, extend: bool) {
5308        self.goal_col = None;
5309        let before = self.caret;
5310        let target = self.word_left_from(self.caret);
5311        self.move_to(target, extend);
5312        self.debug_assert_on_a_stop(before);
5313    }
5314
5315    /// Move to the end of the next word (⌥→ / Ctrl+→).
5316    pub fn move_word_right(&mut self, extend: bool) {
5317        self.goal_col = None;
5318        let before = self.caret;
5319        let target = self.word_right_from(self.caret);
5320        self.move_to(target, extend);
5321        self.debug_assert_on_a_stop(before);
5322    }
5323
5324    // Word boundaries are found in the space the *view* is in. The source view
5325    // walks the source, because there the source is what's rendered. WYSIWYG
5326    // walks the rendered text instead: `**` is invisible to the user, so it has
5327    // to be invisible to word motion too — a caret parked inside one draws in
5328    // the column after `bold` and types two bytes earlier, and a word-delete
5329    // that stops there shreds the markup into `a ** c`.
5330
5331    /// The word boundary to the left of `off` in the active view's space.
5332    fn word_left_from(&self, off: usize) -> usize {
5333        match self.view {
5334            View::Source => prev_word(&self.source, off),
5335            View::Wysiwyg => self.glyph_word_left(off),
5336        }
5337    }
5338
5339    /// The word boundary to the right of `off` in the active view's space.
5340    fn word_right_from(&self, off: usize) -> usize {
5341        match self.view {
5342            View::Source => next_word(&self.source, off),
5343            View::Wysiwyg => self.glyph_word_right(off),
5344        }
5345    }
5346
5347    /// The character class of the glyph drawn at stop `off`.
5348    ///
5349    /// Read from the source, because a stop points at the source byte its glyph
5350    /// came from — the source *is* where the rendered character is written. What
5351    /// makes the walk glyph space rather than source space is that it only ever
5352    /// visits stops, and the hidden bytes between them have none.
5353    fn class_at(&self, off: usize) -> Class {
5354        self.source
5355            .get(off..)
5356            .and_then(|s| s.chars().next())
5357            .map_or(Class::Space, classify)
5358    }
5359
5360    /// [`next_word`] in glyph space: skip any leading separators, then consume
5361    /// the following word run, with the stop table standing in for the source's
5362    /// characters.
5363    fn glyph_word_right(&self, from: usize) -> usize {
5364        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
5365            return from;
5366        };
5367        let mut in_word = false;
5368        loop {
5369            match self.class_at(off) {
5370                Class::Word => in_word = true,
5371                _ if in_word => return off,
5372                _ => {}
5373            }
5374            match self.vmap.stop_after(off) {
5375                Some(next) => off = next,
5376                None => return off,
5377            }
5378        }
5379    }
5380
5381    /// [`prev_word`] in glyph space: skip separators walking left, then consume
5382    /// the preceding word run.
5383    fn glyph_word_left(&self, from: usize) -> usize {
5384        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
5385            return from;
5386        };
5387        let mut in_word = false;
5388        while let Some(prev) = self.vmap.stop_before(off) {
5389            match self.class_at(prev) {
5390                Class::Word => in_word = true,
5391                _ if in_word => return off,
5392                _ => {}
5393            }
5394            off = prev;
5395        }
5396        off
5397    }
5398
5399    /// After a motion that walks the visual map, the caret must be *on* the map.
5400    /// A stop is the only offset where the caret draws and edits in the same
5401    /// place, and it's the invariant both a caret parked inside an emoji and one
5402    /// parked inside a `**` were quietly breaking.
5403    ///
5404    /// Only when the caret actually moved: a walk with nowhere to go leaves it
5405    /// where it was, which is wherever the floor or a frontend put it rather
5406    /// than somewhere this motion chose.
5407    fn debug_assert_on_a_stop(&self, before: usize) {
5408        debug_assert!(
5409            self.view != View::Wysiwyg
5410                || self.vmap.num_rows() == 0
5411                || self.caret == before
5412                || self.vmap.is_stop(self.caret),
5413            "motion left the caret at {}, which is not a caret stop: it would draw in \
5414             one place and type in another",
5415            self.caret
5416        );
5417    }
5418
5419    // Up and Down run off the ends of the document rather than stopping dead at
5420    // them: Up from the first row lands at the document's start, Down from the
5421    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
5422    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
5423    // reaching the end of the text is what a reader means by it.
5424    //
5425    // The views used to disagree here by accident rather than by decision: the
5426    // source view fell into the edge behaviour through `row_col_to_offset`
5427    // clamping an out-of-range row to the end of the string, while WYSIWYG had
5428    // no row below to walk to and did nothing at all. They share the rule now,
5429    // each in its own space — the source view reaches every byte, WYSIWYG only
5430    // the offsets it draws.
5431
5432    pub fn move_up(&mut self, extend: bool) {
5433        let (row, col) = self.caret_pos();
5434        let goal = self.goal_col.unwrap_or(col);
5435        let target = match self.view {
5436            View::Source => match row.checked_sub(1) {
5437                Some(r) => row_col_to_offset(&self.source, r, goal),
5438                None => self.reachable_start(),
5439            },
5440            // A table's border rules are drawn but hold no caret, so Up steps
5441            // over them to the row that does.
5442            View::Wysiwyg => match self.vmap.navigable_above(row) {
5443                Some(r) => self.row_target(r, goal),
5444                None => self.reachable_start(),
5445            },
5446        };
5447        self.step_vertical(target, goal, extend);
5448    }
5449
5450    pub fn move_down(&mut self, extend: bool) {
5451        let (row, col) = self.caret_pos();
5452        let goal = self.goal_col.unwrap_or(col);
5453        let target = match self.view {
5454            View::Source => match self.source_row_below(row) {
5455                Some(r) => row_col_to_offset(&self.source, r, goal),
5456                None => self.reachable_end(),
5457            },
5458            View::Wysiwyg => match self.vmap.navigable_below(row) {
5459                Some(r) => self.row_target(r, goal),
5460                None => self.reachable_end(),
5461            },
5462        };
5463        self.step_vertical(target, goal, extend);
5464    }
5465
5466    /// Land a vertical motion at `target`, latching the `goal` column it aimed
5467    /// with so the rest of the run keeps aiming there.
5468    ///
5469    /// A motion with nowhere to go changes *nothing*, the goal column included:
5470    /// the latch used to run before the early return at the top of the document,
5471    /// so an Up that did nothing still armed a column, and the next Down aimed
5472    /// at one the caret had never been in.
5473    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
5474        let before = self.caret;
5475        if target == before {
5476            return;
5477        }
5478        self.goal_col = Some(goal);
5479        self.move_to(target, extend);
5480        self.debug_assert_on_a_stop(before);
5481    }
5482
5483    /// The source line below `row`, or `None` when `row` is the last one. Lines
5484    /// are counted by newline, so a trailing one leaves a real, empty last line
5485    /// for the caret to sit on — the document ends below it, not on it.
5486    fn source_row_below(&self, row: usize) -> Option<usize> {
5487        let last = self.source.bytes().filter(|&b| b == b'\n').count();
5488        (row < last).then_some(row + 1)
5489    }
5490
5491    /// Where a vertical motion aiming at the `goal` column lands on visual row
5492    /// `r`: the column clamped to the row, mapped to its offset, then held
5493    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
5494    /// column belongs to the row below, and a gutter's column 0 points at the
5495    /// block rather than at this row.
5496    fn row_target(&self, r: usize, goal: usize) -> usize {
5497        let (start, end) = self.row_bounds(r);
5498        self.vmap
5499            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
5500            .clamp(start, end)
5501    }
5502
5503    /// The first and last offsets the caret can reach in the active view.
5504    ///
5505    /// Not the same span in both: the source view shows every byte, so it can
5506    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
5507    /// sits below the first stop, and a document's trailing newline is drawn
5508    /// nowhere and so sits past the last.
5509    fn reachable_start(&self) -> usize {
5510        match self.view {
5511            View::Source => 0,
5512            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
5513        }
5514    }
5515
5516    fn reachable_end(&self) -> usize {
5517        match self.view {
5518            View::Source => self.source.len(),
5519            View::Wysiwyg => self
5520                .vmap
5521                .stop_at_or_before(self.source.len())
5522                .unwrap_or(self.caret),
5523        }
5524    }
5525
5526    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
5527    /// including the space a soft wrap ate off its end, which is drawn on this
5528    /// row however much the offset past it belongs to the next one.
5529    fn row_span(&self, r: usize) -> (usize, usize) {
5530        let start = self
5531            .vmap
5532            .row_start(r)
5533            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
5534        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
5535        (start.min(end), end)
5536    }
5537
5538    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
5539    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
5540    /// this row's last position is the one before it — the offset before the
5541    /// space the wrap ate, where the caret draws just past the row's last word
5542    /// and types there too.
5543    ///
5544    /// Aiming at the shared offset instead is what stalled End: it is the row's
5545    /// last *column*, so End pressed on the row reached it and then read back as
5546    /// the row below's start, where a second press ran on to that row's end and
5547    /// the next to the one after — End walking down the paragraph a row a press.
5548    fn row_bounds(&self, r: usize) -> (usize, usize) {
5549        let (start, end) = self.row_span(r);
5550        let wraps = self
5551            .vmap
5552            .navigable_below(r)
5553            .and_then(|b| self.vmap.row_start(b))
5554            .is_some_and(|off| off == end);
5555        match wraps {
5556            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
5557            false => (start, end),
5558        }
5559    }
5560
5561    /// The `[start, end]` of the line Home and End aim at: the visual row in
5562    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
5563    ///
5564    /// A soft-wrapped row is a line here, because it is one to the eye and the
5565    /// eye is what these keys are aimed by — a reader pressing End means the end
5566    /// of the line they can see. (`select_block_at` wants the opposite and reads
5567    /// the AST for it: a triple-click grabs the whole paragraph, however many
5568    /// rows it folds into.)
5569    fn line_bounds(&self) -> (usize, usize) {
5570        let (row, _) = self.caret_pos();
5571        match self.view {
5572            View::Source => {
5573                let start = line_start(&self.source, row);
5574                (start, line_end_from(&self.source, start))
5575            }
5576            View::Wysiwyg => self.row_bounds(row),
5577        }
5578    }
5579
5580    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
5581    /// *drawn* — what a kill takes.
5582    ///
5583    /// The two part only at a soft wrap, over the space the wrap ate: the caret
5584    /// can't stand after it (that offset opens the row below, and End stopping
5585    /// there would walk), but it is on this row, and a kill that spared it would
5586    /// leave a double space behind where the row's text had been. Deleting it
5587    /// joins nothing — a wrap is drawn, not written.
5588    fn line_span(&self) -> (usize, usize) {
5589        let (row, _) = self.caret_pos();
5590        match self.view {
5591            View::Source => self.line_bounds(),
5592            View::Wysiwyg => self.row_span(row),
5593        }
5594    }
5595
5596    /// The first offset in `[start, end]` holding something other than
5597    /// whitespace, or `end` when the line holds nothing else — where Home aims.
5598    ///
5599    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
5600    /// so a hidden delimiter is never taken for the line's first character (nor
5601    /// landed on), and the source view steps the source it is showing.
5602    fn first_non_space(&self, start: usize, end: usize) -> usize {
5603        let mut off = start;
5604        while off < end {
5605            if self.class_at(off) != Class::Space {
5606                return off;
5607            }
5608            off = match self.view {
5609                View::Source => next_boundary(&self.source, off),
5610                View::Wysiwyg => match self.vmap.stop_after(off) {
5611                    Some(next) => next,
5612                    None => return end,
5613                },
5614            };
5615        }
5616        end
5617    }
5618
5619    /// Home: to the first character on the line, or to column 0 when the caret
5620    /// is already on it — the two-press toggle every editor spells this way.
5621    /// The indentation is somewhere the caret has to be able to reach and almost
5622    /// never where a reader is headed, so it costs the second press.
5623    pub fn move_home(&mut self, extend: bool) {
5624        self.goal_col = None;
5625        let (start, end) = self.line_bounds();
5626        let text = self.first_non_space(start, end);
5627        let target = if self.caret == text { start } else { text };
5628        let before = self.caret;
5629        self.move_to(target, extend);
5630        self.debug_assert_on_a_stop(before);
5631    }
5632
5633    /// End: to the end of the line.
5634    pub fn move_end(&mut self, extend: bool) {
5635        self.goal_col = None;
5636        let (_, end) = self.line_bounds();
5637        let before = self.caret;
5638        self.move_to(end, extend);
5639        self.debug_assert_on_a_stop(before);
5640    }
5641
5642    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
5643    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
5644    /// when the caret isn't in a table, or is already in the last/first cell — the
5645    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
5646    /// meaning everywhere else.
5647    pub fn cell_hop(&mut self, forward: bool) -> bool {
5648        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5649            return false;
5650        };
5651        // Flatten to document (row-major) order and step one cell either way.
5652        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
5653        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
5654        let next = if forward {
5655            i.checked_add(1)
5656        } else {
5657            i.checked_sub(1)
5658        };
5659        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
5660            return false; // at the table's edge; leave Tab to the frontend
5661        };
5662        self.select_cell(start, end);
5663        true
5664    }
5665
5666    /// Move the caret to the cell directly above (`down == false`) or below in
5667    /// the same column, landing with the cell's whole content selected (see
5668    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
5669    /// when the caret isn't in a table), so the frontend can fall through — the
5670    /// vertical counterpart of [`Self::cell_hop`].
5671    ///
5672    /// A ragged row that is short a column clamps to its last cell, so Down never
5673    /// falls out of the table over a gap the row above happened to have.
5674    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
5675        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
5676            return false;
5677        };
5678        let target = match down {
5679            true => r + 1,
5680            false if r == 0 => return false,
5681            false => r - 1,
5682        };
5683        let Some(row) = grid.get(target) else {
5684            return false;
5685        };
5686        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
5687            return false;
5688        };
5689        self.select_cell(start, end);
5690        true
5691    }
5692
5693    /// The table containing `off` as a row-major grid of `(start, end)` cell
5694    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
5695    /// isn't in a table. Read straight off the visual map's laid-out grid, so
5696    /// every cell (an empty one included, whose derived home twig gives no
5697    /// `content_span` for) is present and in the order Tab walks them.
5698    // Grid, row, column — three returns that only ever travel together, and a
5699    // named type for the pair of them would be read at one call site.
5700    #[allow(clippy::type_complexity)]
5701    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
5702        for t in &self.vmap.tables {
5703            let mut pos = None;
5704            let grid: Vec<Vec<(usize, usize)>> = t
5705                .grid
5706                .iter()
5707                .enumerate()
5708                .map(|(r, row)| {
5709                    row.cells
5710                        .iter()
5711                        .enumerate()
5712                        .map(|(c, cell)| {
5713                            if pos.is_none() && off >= cell.start && off <= cell.end {
5714                                pos = Some((r, c));
5715                            }
5716                            (cell.start, cell.end)
5717                        })
5718                        .collect()
5719                })
5720                .collect();
5721            if let Some((r, c)) = pos {
5722                return Some((grid, r, c));
5723            }
5724        }
5725        None
5726    }
5727
5728    // ── table key policy ──────────────────────────────────────────────────────
5729    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
5730    // as one policy every frontend shares, rather than each re-deriving it. Each
5731    // reports whether it acted *as a table key*; a `false` hands the key back to
5732    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
5733    // everywhere else.
5734
5735    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
5736    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
5737    /// back and simply stays put at the very first cell. `false` when the caret
5738    /// isn't in a table.
5739    pub fn cell_tab(&mut self, forward: bool) -> bool {
5740        if !self.caret_in_table() {
5741            return false;
5742        }
5743        if self.cell_hop(forward) {
5744            return true;
5745        }
5746        // Off the last cell: grow the table by a row and step into its first
5747        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
5748        if forward {
5749            self.append_row_and_enter(0);
5750        }
5751        true
5752    }
5753
5754    /// Return inside a table: drop to the cell below in the same column,
5755    /// appending a new row when the caret is already in the last one. `false`
5756    /// when the caret isn't in a table, so the frontend inserts a newline.
5757    pub fn cell_return(&mut self) -> bool {
5758        if !self.caret_in_table() {
5759            return false;
5760        }
5761        if self.cell_move_vertical(true) {
5762            return true;
5763        }
5764        // Already on the last row: grow one below and drop into the same column.
5765        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
5766        self.append_row_and_enter(col);
5767        true
5768    }
5769
5770    /// Append a row below the caret's (last) row and land in `col` of it. The
5771    /// caret is in the last row, so twig's "insert below" makes the fresh row the
5772    /// table's new last — but twig re-spells the whole table, moving every byte,
5773    /// so the destination is read back from the rebuilt grid by the table's
5774    /// position (stable across a row insert), not from the pre-edit caret.
5775    fn append_row_and_enter(&mut self, col: usize) {
5776        let table = self.caret_table_index();
5777        self.table_insert_row(true);
5778        self.rebuild_map();
5779        let Some((start, end)) = table
5780            .and_then(|ti| self.vmap.tables.get(ti))
5781            .and_then(|t| t.grid.last())
5782            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
5783            .map(|cell| (cell.start, cell.end))
5784        else {
5785            return;
5786        };
5787        self.select_cell(start, end);
5788    }
5789
5790    /// The index, among the document's tables, of the one the caret sits in —
5791    /// `None` when it's in none. Used to re-find a table after an edit re-spells
5792    /// it (a row insert leaves the table order unchanged).
5793    fn caret_table_index(&self) -> Option<usize> {
5794        let off = self.caret;
5795        self.vmap.tables.iter().position(|t| {
5796            t.grid
5797                .iter()
5798                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
5799        })
5800    }
5801
5802    /// Shift+Return inside a table: insert a hard line break *within* the current
5803    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
5804    /// table, so the frontend inserts an ordinary line break.
5805    ///
5806    /// A table row is a single source line, so the newline-spelled hard break
5807    /// can't live in a cell. twig spells the in-cell break the format's way
5808    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
5809    /// break round-trips as structure the renderer reads back as a line — not the
5810    /// opaque raw HTML the old raw-splice left behind.
5811    ///
5812    /// Djot has no idiomatic in-cell break, so twig refuses it
5813    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
5814    /// would render as the literal text `<br>`. The gesture is still *consumed*
5815    /// there — returning `false` would let the frontend insert a real newline,
5816    /// which splits the one-line row — it just leaves the cell unchanged and says
5817    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
5818    ///
5819    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
5820    /// have to be read together: djot is not the only `false`, and naming it in
5821    /// the message was already a guess that HTML — which spells the break as its
5822    /// own `<br>` — would have made wrong.
5823    pub fn cell_line_break(&mut self) -> bool {
5824        if self.read_only || !self.caret_in_table() {
5825            return false;
5826        }
5827        self.record_caret();
5828        match self.editor.insert_line_break(self.caret) {
5829            Ok(change) => {
5830                self.last_edit_kind = None;
5831                self.refresh();
5832                self.caret = change.new.end;
5833                self.anchor = None;
5834                self.goal_col = None;
5835                self.clamp_caret();
5836                self.dirty = self.source != self.clean_source;
5837                self.status = None;
5838                self.record_caret();
5839            }
5840            Err(twig::Error::UnsupportedFormat) => {
5841                self.status = Some(format!(
5842                    "in-cell line breaks aren't supported in {}",
5843                    self.format_name()
5844                ));
5845            }
5846            Err(_) => {}
5847        }
5848        true
5849    }
5850
5851    /// Rebuild the visual map at the width the last build used. A structural edit
5852    /// bumps the revision and swaps the source in, but leaves the *map* stale;
5853    /// when a single gesture edits and then moves over the result (Tab appending
5854    /// a row, then stepping into it), the move needs the map to already show the
5855    /// edit rather than waiting for the frontend's next frame.
5856    fn rebuild_map(&mut self) {
5857        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
5858        self.build_map(wrap);
5859    }
5860
5861    /// Move the caret to the very start of the document (⌘↑ on macOS,
5862    /// Ctrl+Home on Windows/Linux).
5863    pub fn move_doc_start(&mut self, extend: bool) {
5864        self.goal_col = None;
5865        self.move_to(0, extend);
5866    }
5867
5868    /// Move the caret to the very end of the document (⌘↓ on macOS,
5869    /// Ctrl+End on Windows/Linux).
5870    pub fn move_doc_end(&mut self, extend: bool) {
5871        self.goal_col = None;
5872        let end = self.source.len();
5873        self.move_to(end, extend);
5874    }
5875
5876    /// Point the caret at the body cell `(row, col)` the mouse landed on —
5877    /// `col` being a cell of the terminal grid, which is what a display column
5878    /// is. A click on the far cell of a wide character lands at that
5879    /// character's start; the mapping's own doc-comments carry the rule.
5880    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
5881        self.goal_col = None;
5882        let target = match self.view {
5883            View::Source => row_col_to_offset(&self.source, row, col),
5884            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
5885        };
5886        let before = self.caret;
5887        self.move_to(target, extend);
5888        self.debug_assert_on_a_stop(before);
5889    }
5890
5891    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
5892    /// screen if it has moved since the last frame, and never scroll past the
5893    /// last of `rows`.
5894    ///
5895    /// Only if it has *moved* — that's the whole point. Revealing the caret on
5896    /// every frame ties the viewport to it, and a scroll wheel that fights the
5897    /// caret for the viewport loses: the view snaps back the instant it tries to
5898    /// pass the caret's row, so the document can't be scrolled beyond what's
5899    /// already on screen. A caret move is the frontend's cue to follow; a scroll
5900    /// with the caret sitting still is the reader's cue to leave it alone.
5901    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
5902        if self.drawn_caret != Some(self.caret) {
5903            if caret_row < self.scroll {
5904                self.scroll = caret_row;
5905            } else if height > 0 && caret_row >= self.scroll + height {
5906                self.scroll = caret_row + 1 - height;
5907            }
5908            self.drawn_caret = Some(self.caret);
5909        }
5910        self.scroll = self.scroll.min(rows.saturating_sub(1));
5911    }
5912
5913    /// The caret's screen position `(row, col)` in the active view's grid, with
5914    /// `col` a display column: the cell to draw the caret in, which on a line of
5915    /// `你好` or emoji is not the count of characters before it.
5916    pub fn caret_pos(&self) -> (usize, usize) {
5917        match self.view {
5918            View::Source => offset_to_row_col(&self.source, self.caret),
5919            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
5920        }
5921    }
5922
5923    fn clamp_caret(&mut self) {
5924        if self.caret > self.source.len() {
5925            self.caret = self.source.len();
5926        }
5927        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
5928        // any selection anchor) to the first rendered offset.
5929        let floor = self.caret_floor();
5930        if self.caret < floor {
5931            self.caret = floor;
5932        }
5933        if let Some(a) = self.anchor
5934            && a < floor
5935        {
5936            self.anchor = Some(floor);
5937        }
5938        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
5939            self.caret -= 1;
5940        }
5941    }
5942}
5943
5944// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
5945
5946// Left/right motion and backspace/delete step by *grapheme cluster*, not
5947// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
5948// marks moves and deletes as the single character a user sees. Grapheme
5949// boundaries are a superset of char boundaries, so the caret stays valid for twig.
5950
5951/// How an insert of `text` groups for undo: a single typed character folds into
5952/// the run of typing around it, while a newline or a multi-character insert is a
5953/// step of its own.
5954fn typed_edit_kind(text: &str) -> EditKind {
5955    if text.chars().take(2).count() == 1 && text != "\n" {
5956        EditKind::Insert
5957    } else {
5958        EditKind::Other
5959    }
5960}
5961
5962fn prev_boundary(s: &str, i: usize) -> usize {
5963    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5964    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
5965}
5966
5967fn next_boundary(s: &str, i: usize) -> usize {
5968    let mut cursor = GraphemeCursor::new(i, s.len(), true);
5969    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
5970}
5971
5972// ── word boundaries ──────────────────────────────────────────────────────────
5973// The shared primitive behind word-wise motion, word deletion, and
5974// double-click-to-select-a-word. A "word" is a maximal run of one character
5975// class; whitespace and punctuation are their own classes, so motion skips
5976// cleanly between them the way native text fields do.
5977
5978#[derive(PartialEq, Eq, Clone, Copy)]
5979enum Class {
5980    Word,
5981    Space,
5982    Other,
5983}
5984
5985/// The source range of an inline node's own visible text — the part of it a
5986/// WYSIWYG caret can reach, as against the delimiters that only spell it.
5987/// `None` for a node with no interior to empty (a `str`, a break).
5988///
5989/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
5990/// one delimiter in from the span — the same place the renderer maps it to. A
5991/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
5992/// against the source rather than trusted: a range guessed wrong here is text
5993/// deleted wrong.
5994fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
5995    if let Some(span) = n.content_span.clone() {
5996        return Some(span);
5997    }
5998    match n.kind.as_str() {
5999        "verbatim" | "inline_math" => {
6000            let text = n.text.as_ref()?;
6001            let start = n.span.start + 1;
6002            let range = start..start + text.len();
6003            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
6004        }
6005        _ => None,
6006    }
6007}
6008
6009/// The `id` a node declares, or `None` for one that declares none — the
6010/// attribute djot writes for a `{#v1}` and mints for a heading.
6011///
6012/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
6013/// names nothing, so it reads as absent rather than as the empty string.
6014fn declared_id(n: &FlatNode) -> Option<&str> {
6015    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
6016}
6017
6018/// A heading's words reduced to the form a link fragment spells them in:
6019/// lowercase, runs of anything else collapsed to a single `-`, with none left
6020/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
6021///
6022/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
6023/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
6024/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
6025/// any other language is still a heading someone will link to. Underscores
6026/// survive for the same reason they do on the web: they are word characters
6027/// wherever identifiers are written.
6028fn slug(text: &str) -> String {
6029    let mut out = String::new();
6030    let mut pending = false;
6031    for c in text.chars() {
6032        if c.is_alphanumeric() || c == '_' {
6033            if pending && !out.is_empty() {
6034                out.push('-');
6035            }
6036            pending = false;
6037            out.extend(c.to_lowercase());
6038        } else {
6039            pending = true;
6040        }
6041    }
6042    out
6043}
6044
6045fn is_block_container(kind: &Kind) -> bool {
6046    matches!(
6047        kind,
6048        Kind::Doc
6049            | Kind::Section
6050            | Kind::BlockQuote
6051            | Kind::BulletList
6052            | Kind::OrderedList
6053            | Kind::TaskList
6054            | Kind::ListItem
6055            | Kind::TaskListItem
6056            // Every `container` — a directive in any of its three forms, or a
6057            // promoted HTML element. A *text* directive is really inline, so
6058            // claiming it here is a small overreach, and the deliberate one this
6059            // function's kind-only peer `is_inline_kind` documents: the pair is
6060            // consulted together, and answering "block container" for something
6061            // inline is what keeps an ancestor walk from stopping short of the
6062            // paragraph that actually holds it.
6063            | Kind::Container
6064    )
6065}
6066
6067/// The `[start, end)` byte range of the source line containing `off` (newline
6068/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
6069/// line between paragraphs).
6070fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
6071    let off = off.min(s.len());
6072    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
6073    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
6074    start..end
6075}
6076
6077/// How many leading bytes an outdent takes off `line`: a whole indent level
6078/// where the line has one, and whatever it has where it has less.
6079///
6080/// A leading tab counts as a level on its own. It's indentation some other
6081/// editor wrote, and one tab is one level everywhere it came from — measuring it
6082/// in spaces it doesn't contain would leave it untouchable.
6083fn outdent_width(line: &str, unit: usize) -> usize {
6084    if line.starts_with('\t') {
6085        return 1;
6086    }
6087    line.bytes().take(unit).take_while(|b| *b == b' ').count()
6088}
6089
6090/// A list marker found at the head of a line, together with everything before it
6091/// that a sibling line has to repeat.
6092///
6093/// The three offsets differ only inside a block quote, where `>   - b` opens with
6094/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
6095/// `line_start == marker_start`, and `text` is the plain `"  - "`.
6096#[derive(Clone, Debug)]
6097struct ListMarker {
6098    /// The line's first byte.
6099    line_start: usize,
6100    /// Where the marker proper begins, past any quote prefix. The offset to hand
6101    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
6102    marker_start: usize,
6103    /// `line_start` through the marker's trailing space — quote prefix, indent
6104    /// and bullet together, which is what the next item's line opens with.
6105    text: String,
6106}
6107
6108impl ListMarker {
6109    /// Where the item's content starts — one past the marker's trailing space.
6110    fn content_start(&self) -> usize {
6111        self.line_start + self.text.len()
6112    }
6113}
6114
6115fn classify(c: char) -> Class {
6116    if c == '_' || c.is_alphanumeric() {
6117        Class::Word
6118    } else if c.is_whitespace() {
6119        Class::Space
6120    } else {
6121        Class::Other
6122    }
6123}
6124
6125/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
6126/// skip any leading separators, then consume the following word run.
6127fn next_word(s: &str, i: usize) -> usize {
6128    let mut off = i;
6129    let mut in_word = false;
6130    for c in s[i..].chars() {
6131        if classify(c) == Class::Word {
6132            in_word = true;
6133        } else if in_word {
6134            break;
6135        }
6136        off += c.len_utf8();
6137    }
6138    off
6139}
6140
6141/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
6142/// skip separators walking left, then consume the preceding word run.
6143fn prev_word(s: &str, i: usize) -> usize {
6144    let mut off = i;
6145    let mut in_word = false;
6146    for c in s[..i].chars().rev() {
6147        if classify(c) == Class::Word {
6148            in_word = true;
6149        } else if in_word {
6150            break;
6151        }
6152        off -= c.len_utf8();
6153    }
6154    off
6155}
6156
6157/// The `[start, end)` run of same-class characters surrounding `off` — the
6158/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
6159/// the run ending there is used.
6160fn word_range_at(s: &str, off: usize) -> (usize, usize) {
6161    if s.is_empty() {
6162        return (0, 0);
6163    }
6164    let off = off.min(s.len());
6165    let reference = if off < s.len() {
6166        s[off..].chars().next()
6167    } else {
6168        s[..off].chars().next_back()
6169    };
6170    let Some(rc) = reference else {
6171        return (off, off);
6172    };
6173    let class = classify(rc);
6174
6175    let mut start = off;
6176    for c in s[..start].chars().rev() {
6177        if classify(c) == class {
6178            start -= c.len_utf8();
6179        } else {
6180            break;
6181        }
6182    }
6183    let mut end = off;
6184    for c in s[end..].chars() {
6185        if classify(c) == class {
6186            end += c.len_utf8();
6187        } else {
6188            break;
6189        }
6190    }
6191    (start, end)
6192}
6193
6194/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
6195/// the line's start — terminal cells, not characters, so the column names the
6196/// cell the caret is drawn in even on a line of `你好` or emoji.
6197fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
6198    let off = off.min(s.len());
6199    let mut row = 0;
6200    let mut line_start = 0;
6201    for (i, &b) in s.as_bytes().iter().enumerate() {
6202        if i >= off {
6203            break;
6204        }
6205        if b == b'\n' {
6206            row += 1;
6207            line_start = i + 1;
6208        }
6209    }
6210    (row, wysiwyg::text_width(&s[line_start..off]))
6211}
6212
6213/// The byte offset at display column `col` of `row` (clamped to that line's
6214/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
6215///
6216/// A column landing *inside* a character — the second cell of `你`, or any cell
6217/// but the first of an emoji — resolves to that character's start, which is the
6218/// column the caret would have been drawn at to begin with. So both cells of a
6219/// wide character mean the character, and every offset survives the round trip
6220/// out to a column and back. The walk steps by grapheme cluster for the same
6221/// reason the caret does: a cluster is the character, and the cells belong to it
6222/// rather than to the codepoints spelling it.
6223fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
6224    let start = line_start(s, row);
6225    let end = line_end_from(s, start);
6226    let mut off = start;
6227    let mut at = 0; // the display column `off` sits at
6228    while off < end {
6229        let next = next_boundary(s, off).min(end);
6230        let cells = wysiwyg::text_width(&s[off..next]);
6231        if at + cells > col {
6232            break; // `col` is one of this cluster's own cells
6233        }
6234        at += cells;
6235        off = next;
6236    }
6237    off
6238}
6239
6240fn line_start(s: &str, row: usize) -> usize {
6241    if row == 0 {
6242        return 0;
6243    }
6244    let mut r = 0;
6245    for (i, &b) in s.as_bytes().iter().enumerate() {
6246        if b == b'\n' {
6247            r += 1;
6248            if r == row {
6249                return i + 1;
6250            }
6251        }
6252    }
6253    s.len()
6254}
6255
6256fn line_end_from(s: &str, start: usize) -> usize {
6257    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
6258}
6259
6260/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
6261/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
6262/// twig applies writing the mark out, so the toolbar can light the same button
6263/// that made the node.
6264///
6265/// `None` for every other kind, including the inline nodes that aren't marks at
6266/// all (`str`, `link`, `image`, the math and break kinds): they're things a
6267/// caret stands in, not formatting a button toggles.
6268fn inline_kind(kind: &Kind) -> Option<InlineKind> {
6269    Some(match kind {
6270        Kind::Strong => InlineKind::Strong,
6271        Kind::Emph => InlineKind::Emph,
6272        Kind::Verbatim => InlineKind::Verbatim,
6273        Kind::Mark => InlineKind::Mark,
6274        Kind::Superscript => InlineKind::Superscript,
6275        Kind::Subscript => InlineKind::Subscript,
6276        Kind::Insert => InlineKind::Insert,
6277        Kind::Delete => InlineKind::Delete,
6278        _ => return None,
6279    })
6280}
6281
6282/// A watermark for a file's contents (see `Doc::disk_hash`).
6283///
6284/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
6285/// watermark is compared only against one taken by the same process moments
6286/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
6287/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
6288fn hash_bytes(bytes: &[u8]) -> u64 {
6289    use std::hash::{Hash, Hasher};
6290    let mut h = std::collections::hash_map::DefaultHasher::new();
6291    bytes.hash(&mut h);
6292    h.finish()
6293}
6294
6295#[cfg(feature = "fs")]
6296fn detect_format(path: &Path) -> Result<Format> {
6297    let ext = path
6298        .extension()
6299        .and_then(|e| e.to_str())
6300        .unwrap_or("")
6301        .to_ascii_lowercase();
6302    Ok(match ext.as_str() {
6303        "dj" | "djot" => Format::Djot,
6304        "md" | "markdown" => Format::Markdown,
6305        "xml" => Format::Xml,
6306        "html" | "htm" => Format::Html,
6307        other => return Err(anyhow!("unknown document extension: .{other}")),
6308    })
6309}
6310
6311#[cfg(test)]
6312mod tests {
6313    use super::*;
6314
6315    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
6316    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
6317    /// without one is a view no user is ever in.
6318    fn doc_in(view: View, name: &str, body: &str) -> Doc {
6319        // The fixture name doubles as the temp file's, so two tests picking the
6320        // same one raced under the parallel runner and read each other's body —
6321        // a green suite proving the wrong thing. The counter makes that
6322        // unreachable rather than asking every future caller to notice.
6323        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6324        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6325        let mut p = std::env::temp_dir();
6326        p.push(format!("leaf_test_{name}_{seq}.md"));
6327        std::fs::write(&p, body).unwrap();
6328        let mut d = Doc::open(p).unwrap();
6329        d.view = view;
6330        if view == View::Wysiwyg {
6331            d.build_visual(80);
6332        }
6333        d
6334    }
6335
6336    // Source-view document for the source-behaviour tests. `Doc::open` now
6337    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
6338    // `wysiwyg_doc` builds the rich-text variant on top of this.
6339    fn doc_with(name: &str, body: &str) -> Doc {
6340        doc_in(View::Source, name, body)
6341    }
6342
6343    /// Every visual row's drawn text — what the reader actually sees, which is
6344    /// the only thing the reveal preference is supposed to change.
6345    fn drawn_rows(d: &Doc) -> Vec<String> {
6346        d.vmap
6347            .rows
6348            .iter()
6349            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6350            .collect()
6351    }
6352
6353    /// Put the caret at the first byte of `needle` and rebuild, so the row under
6354    /// it becomes the revealed line.
6355    fn caret_at(d: &mut Doc, needle: &str) {
6356        d.caret = d.source.find(needle).expect("needle in source");
6357        d.build_visual(80);
6358    }
6359
6360    #[test]
6361    fn blockquote_after_a_list_is_not_bulleted() {
6362        // twig nests a following top-level block quote under the `bullet_list`
6363        // (a direct child, not a `list_item`). The map must render it de-nested —
6364        // `│ quote`, never `• │ quote` — with a blank separator, like any block
6365        // that follows a list. Regression for the "combined list + blockquote" bug.
6366        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
6367        d.build_visual(80);
6368        let rows: Vec<String> = d
6369            .vmap
6370            .rows
6371            .iter()
6372            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
6373            .collect();
6374        assert!(
6375            rows.iter().any(|r| r == "│ quote"),
6376            "block quote should render on its own gutter, got rows: {rows:?}"
6377        );
6378        assert!(
6379            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
6380            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
6381        );
6382    }
6383
6384    // ── the map is built at most once per (revision, wrap) ───────────────────
6385    //
6386    // A frontend repaints for reasons that have nothing to do with the text — a
6387    // blinking caret, a scroll — and rebuilding the map is O(document). These
6388    // pin *that the cache fires*, which a passing suite can't tell you: a cache
6389    // that never hits is invisible to every other test in this file.
6390    //
6391    // The probe is to wreck the built map and ask for it again. A rebuild
6392    // repairs it; a cache hit hands the wreckage straight back. Nothing else
6393    // can distinguish the two from outside.
6394
6395    #[test]
6396    fn a_rebuild_with_nothing_changed_reuses_the_map() {
6397        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
6398        d.build_visual(80);
6399        assert!(!d.vmap.rows.is_empty());
6400        d.vmap.rows.clear(); // wreck it
6401        d.build_visual(80);
6402        assert!(
6403            d.vmap.rows.is_empty(),
6404            "the map was rebuilt though nothing changed — the cache never fired"
6405        );
6406    }
6407
6408    #[test]
6409    fn an_edit_rebuilds_the_map() {
6410        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
6411        d.build_visual(80);
6412        let before = d.revision();
6413        d.vmap.rows.clear();
6414        d.insert("x");
6415        d.build_visual(80);
6416        assert!(d.revision() > before, "an edit must move the revision");
6417        assert!(
6418            !d.vmap.rows.is_empty(),
6419            "an edited document must not paint from a stale map"
6420        );
6421    }
6422
6423    #[test]
6424    fn a_width_change_rebuilds_the_map() {
6425        // The map is a function of the wrap width too, so a resize is a miss
6426        // even though the text is untouched.
6427        let mut d = doc_in(
6428            View::Wysiwyg,
6429            "cache_width",
6430            "one two three four five six\n",
6431        );
6432        d.build_visual(80);
6433        d.vmap.rows.clear();
6434        d.build_visual(12);
6435        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
6436        // And the unwrapped map is its own key, not the same as any width.
6437        d.vmap.rows.clear();
6438        d.build_visual_unwrapped();
6439        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
6440    }
6441
6442    #[test]
6443    fn a_motion_does_not_rebuild_the_map() {
6444        // The whole point: moving the caret changes nothing the map is built
6445        // from. If a motion bumped the revision, every arrow key would cost a
6446        // full rebuild and the cache would be worthless.
6447        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
6448        d.build_visual(80);
6449        let rev = d.revision();
6450        d.move_right(false);
6451        d.move_right(true);
6452        d.move_down(false);
6453        assert_eq!(d.revision(), rev, "a motion must not move the revision");
6454        d.vmap.rows.clear();
6455        d.build_visual(80);
6456        assert!(
6457            d.vmap.rows.is_empty(),
6458            "a motion should not rebuild the map"
6459        );
6460    }
6461
6462    #[test]
6463    fn saving_does_not_rebuild_the_map() {
6464        // Saving changes `dirty`, not the text.
6465        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
6466        d.insert("x");
6467        d.build_visual(80);
6468        let rev = d.revision();
6469        d.save();
6470        assert_eq!(d.revision(), rev, "a save must not move the revision");
6471        assert!(!d.dirty, "the save should have cleaned the document");
6472    }
6473
6474    #[test]
6475    fn a_reload_rebuilds_the_map() {
6476        // Reload replaces the text without going through `refresh`, so it has to
6477        // move the revision itself — else the editor paints the old file.
6478        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
6479        d.build_visual(80);
6480        let rev = d.revision();
6481        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
6482        d.reload();
6483        assert!(d.revision() > rev, "a reload must move the revision");
6484        d.build_visual(80);
6485        let text: String = d
6486            .vmap
6487            .rows
6488            .iter()
6489            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
6490            .collect();
6491        assert!(
6492            text.contains("wholly new"),
6493            "the reloaded text should be on screen, got {text:?}"
6494        );
6495    }
6496
6497    // ── golden-case harness ──────────────────────────────────────────────────
6498    // The pattern the whole parity suite can reuse: write a fixture with the
6499    // caret marked by `|`, run one action, and compare the rendered result —
6500    // also caret-marked — against the expected string. One readable line per
6501    // behavior, and it exercises the exact `Doc` ops both frontends call.
6502
6503    /// Split a `|`-marked fixture into `(source, caret_offset)`.
6504    fn parse_caret(marked: &str) -> (String, usize) {
6505        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
6506        (marked.replacen('|', "", 1), caret)
6507    }
6508
6509    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
6510    /// selection) so a result reads like the fixtures.
6511    fn render_caret(d: &Doc) -> String {
6512        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
6513        // so the caret always renders inside its own selection.
6514        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
6515        if let Some((s, e)) = d.selection() {
6516            marks.push((s, 0, '['));
6517            marks.push((e, 2, ']'));
6518        }
6519        // Insert right-to-left: descending offset, then descending rank.
6520        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
6521        let mut out = d.source.clone();
6522        for (at, _, ch) in marks {
6523            out.insert(at, ch);
6524        }
6525        out
6526    }
6527
6528    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
6529    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6530        golden_in(View::Source, name, marked, action)
6531    }
6532
6533    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
6534    /// the same fixture has to read the same way in both.
6535    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
6536        let (src, caret) = parse_caret(marked);
6537        let mut d = doc_in(view, name, &src);
6538        d.caret = caret;
6539        action(&mut d);
6540        render_caret(&d)
6541    }
6542
6543    #[test]
6544    fn word_motion_walks_word_by_word() {
6545        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
6546        assert_eq!(
6547            g("hello wor|ld", |d| d.move_word_left(false)),
6548            "hello |world"
6549        );
6550        assert_eq!(
6551            g("hello| world", |d| d.move_word_left(false)),
6552            "|hello world"
6553        );
6554        assert_eq!(
6555            g("hel|lo world", |d| d.move_word_right(false)),
6556            "hello| world"
6557        );
6558        assert_eq!(
6559            g("hello| world", |d| d.move_word_right(false)),
6560            "hello world|"
6561        );
6562        // Punctuation is its own class, so motion stops at the boundary.
6563        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
6564    }
6565
6566    #[test]
6567    fn word_motion_extends_the_selection_when_asked() {
6568        assert_eq!(
6569            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
6570            "hello [world|]"
6571        );
6572    }
6573
6574    #[test]
6575    fn delete_word_removes_a_whole_word() {
6576        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
6577        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
6578        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
6579        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
6580    }
6581
6582    // ── Home / End ───────────────────────────────────────────────────────────
6583
6584    #[test]
6585    fn home_toggles_between_the_line_s_text_and_its_margin() {
6586        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
6587        // indent to the markup it spells everywhere it means one, so the fixture
6588        // with whitespace left to walk is a code block, which is verbatim.
6589        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
6590        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
6591        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
6592        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
6593        // A line with no indentation has one place to go, so the toggle is a
6594        // no-op rather than a trip to nowhere.
6595        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
6596        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
6597
6598        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
6599        let indent = d.source.find("    indented").unwrap();
6600        d.caret = indent + 6; // inside "indented"
6601        d.move_home(false);
6602        assert_eq!(
6603            d.caret,
6604            indent + 4,
6605            "wysiwyg: Home aims at the code line's text"
6606        );
6607        d.move_home(false);
6608        assert_eq!(
6609            d.caret, indent,
6610            "wysiwyg: the second press takes the indent"
6611        );
6612        d.move_home(false);
6613        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
6614    }
6615
6616    #[test]
6617    fn end_takes_the_line_the_view_is_showing() {
6618        // The line differs by view for the same document, and that is the point:
6619        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
6620        // as a space on one row and the source view as two lines.
6621        let mut d = doc_with("end_src", "one two\nthree\n");
6622        d.caret = 1;
6623        d.move_end(false);
6624        assert_eq!(d.caret, 7, "source: the end of the source line");
6625
6626        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
6627        d.caret = 1;
6628        d.move_end(false);
6629        assert_eq!(
6630            d.caret, 13,
6631            "wysiwyg: the end of the row, soft break and all"
6632        );
6633    }
6634
6635    #[test]
6636    fn home_and_end_extend_the_selection_when_asked() {
6637        for (view, tag) in VIEWS {
6638            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
6639            d.caret = 6;
6640            d.move_end(true);
6641            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
6642            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
6643            d.caret = 6;
6644            d.move_home(true);
6645            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
6646        }
6647    }
6648
6649    // ── kill to the line's start / end ───────────────────────────────────────
6650
6651    #[test]
6652    fn kill_to_the_line_start_and_end_in_both_views() {
6653        for (view, tag) in VIEWS {
6654            // The gap that reads as a paragraph break in each view: the source
6655            // view's lines are the renderer's rows only where the source says so.
6656            let gap = if view == View::Source { "\n" } else { "\n\n" };
6657            let mut d = doc_in(
6658                view,
6659                &format!("kill_end_{tag}"),
6660                &format!("one two{gap}three\n"),
6661            );
6662            d.caret = 3;
6663            d.delete_to_line_end();
6664            assert_eq!(
6665                d.source,
6666                format!("one{gap}three\n"),
6667                "{tag}: ^K to the line's end"
6668            );
6669            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
6670
6671            let mut d = doc_in(
6672                view,
6673                &format!("kill_start_{tag}"),
6674                &format!("one two{gap}three\n"),
6675            );
6676            d.caret = 7; // the end of the first line
6677            d.delete_to_line_start();
6678            assert_eq!(
6679                d.source,
6680                format!("{gap}three\n"),
6681                "{tag}: ⌘⌫ to the line's start"
6682            );
6683            assert_eq!(d.caret, 0, "{tag}");
6684        }
6685    }
6686
6687    #[test]
6688    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
6689        // The decision: at the boundary both kills do nothing, rather than
6690        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
6691        // at a soft wrap as often as at a newline, where there is nothing
6692        // written to delete — and a source newline is only half of the blank
6693        // line between two paragraphs, so taking it leaves a soft break rather
6694        // than the join it looks like. Backspace and Delete are the keys for it.
6695        for (view, tag) in VIEWS {
6696            let gap = if view == View::Source { "\n" } else { "\n\n" };
6697            let src = format!("one{gap}three\n");
6698            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
6699            d.caret = 3; // the end of "one"
6700            d.delete_to_line_end();
6701            assert_eq!(
6702                d.source, src,
6703                "{tag}: ^K at the line's end joined it to the next"
6704            );
6705
6706            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
6707            d.caret = 3 + gap.len(); // the start of "three"
6708            d.delete_to_line_start();
6709            assert_eq!(
6710                d.source, src,
6711                "{tag}: ⌘⌫ at the line's start joined it to the last"
6712            );
6713        }
6714    }
6715
6716    #[test]
6717    fn a_kill_takes_the_selection_when_there_is_one() {
6718        // What every other delete here does with one, so these two as well.
6719        for (view, tag) in VIEWS {
6720            for (name, kill) in [
6721                (
6722                    "end",
6723                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
6724                ),
6725                ("start", |d: &mut Doc| d.delete_to_line_start()),
6726            ] {
6727                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
6728                d.anchor = Some(4);
6729                d.caret = 7; // "two"
6730                kill(&mut d);
6731                assert_eq!(
6732                    d.source, "one  three\n",
6733                    "{tag}: {name} ignored the selection"
6734                );
6735                assert_eq!(d.selection(), None, "{tag}: {name}");
6736            }
6737        }
6738    }
6739
6740    #[test]
6741    fn a_kill_takes_the_markup_it_empties_with_it() {
6742        // The same hazard a word-delete has: a WYSIWYG range covers what the
6743        // user can see, which for `**bold**` is the word and never the
6744        // delimiters, so a kill that stopped at the text would leave `a ****` —
6745        // markup wrapped around nothing.
6746        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
6747        d.caret = d.source.find("bold").unwrap();
6748        d.delete_to_line_end();
6749        assert_eq!(d.source, "a \n");
6750    }
6751
6752    #[test]
6753    fn a_kill_is_undone_in_one_step() {
6754        for (view, tag) in VIEWS {
6755            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
6756            d.caret = 3;
6757            d.delete_to_line_end();
6758            assert_eq!(d.source, "one\n", "{tag}");
6759            d.undo();
6760            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
6761        }
6762    }
6763
6764    #[test]
6765    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
6766        // Regression: triple-click used move_home/move_end over visual rows, so
6767        // it only worked on a paragraph's first row (a wrap-boundary offset maps
6768        // to the earlier row). select_block_at reads the AST, so every offset in
6769        // the paragraph selects the whole thing.
6770        let body = "one two three four five six seven eight\n";
6771        let mut d = doc_with("sel_block", body);
6772        d.view = View::Wysiwyg;
6773        d.build_visual(12); // force the paragraph to wrap into several rows
6774        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
6775        let para = (0, "one two three four five six seven eight".len());
6776        for off in [0usize, 8, 19, 28, 38] {
6777            d.caret = 0;
6778            d.anchor = None;
6779            d.select_block_at(off);
6780            assert_eq!(
6781                d.selection(),
6782                Some(para),
6783                "offset {off} should select the paragraph"
6784            );
6785        }
6786    }
6787
6788    #[test]
6789    fn select_block_uses_content_span_for_a_heading() {
6790        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
6791        d.select_block_at(4); // inside "Title"
6792        // content_span excludes the "# " marker.
6793        assert_eq!(d.selected_text(), Some("Title"));
6794        d.select_block_at(10); // inside "body"
6795        assert_eq!(d.selected_text(), Some("body"));
6796    }
6797
6798    #[test]
6799    fn select_all_spans_the_document() {
6800        let mut d = doc_with("sel_all", "abc\n\ndef\n");
6801        d.select_all();
6802        assert_eq!(d.selection(), Some((0, d.source.len())));
6803    }
6804
6805    #[test]
6806    fn select_word_at_picks_the_surrounding_word() {
6807        let mut d = doc_with("sel_word", "hello world\n");
6808        d.select_word_at(8); // inside "world"
6809        assert_eq!(d.selection(), Some((6, 11)));
6810        // Double-clicking at end-of-word still grabs the word to its left.
6811        d.select_word_at(5); // the space between the words
6812        assert_eq!(d.selection(), Some((5, 6)));
6813    }
6814
6815    #[test]
6816    fn word_helpers_respect_utf8_boundaries() {
6817        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
6818        assert_eq!(
6819            golden("utf8", "|café ok", |d| d.move_word_right(false)),
6820            "café| ok"
6821        );
6822        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
6823    }
6824
6825    #[test]
6826    fn typing_inserts_at_the_caret_and_advances_it() {
6827        let mut d = doc_with("type", "hello\n");
6828        d.insert("Hi ");
6829        assert_eq!(d.source, "Hi hello\n");
6830        assert_eq!(d.caret, 3);
6831        assert!(d.dirty);
6832    }
6833
6834    #[test]
6835    fn backspace_deletes_the_char_before_the_caret() {
6836        let mut d = doc_with("bs", "hello\n");
6837        d.caret = 3; // after "hel"
6838        d.backspace();
6839        assert_eq!(d.source, "helo\n");
6840        assert_eq!(d.caret, 2);
6841    }
6842
6843    #[test]
6844    fn typing_replaces_the_selection() {
6845        let mut d = doc_with("replace", "a word b\n");
6846        d.anchor = Some(2);
6847        d.caret = 6; // "word" selected
6848        d.insert("X");
6849        assert_eq!(d.source, "a X b\n");
6850        assert_eq!(d.caret, 3);
6851        assert_eq!(d.anchor, None);
6852    }
6853
6854    #[test]
6855    fn toggle_bold_wraps_then_unwraps_the_selection() {
6856        let mut d = doc_with("bold", "a word b\n");
6857        d.anchor = Some(2);
6858        d.caret = 6;
6859        d.toggle(InlineKind::Strong);
6860        assert_eq!(d.source, "a **word** b\n");
6861        // The toggled region stays selected, so a second toggle reverses it.
6862        d.toggle(InlineKind::Strong);
6863        assert_eq!(d.source, "a word b\n");
6864        d.toggle(InlineKind::Strong);
6865        assert_eq!(d.source, "a **word** b\n");
6866    }
6867
6868    #[test]
6869    fn toggle_code_wraps_then_unwraps_the_selection() {
6870        let mut d = doc_with("code_rt", "a word b\n");
6871        d.anchor = Some(2);
6872        d.caret = 6;
6873        d.toggle(InlineKind::Verbatim);
6874        assert_eq!(d.source, "a `word` b\n");
6875        d.toggle(InlineKind::Verbatim);
6876        assert_eq!(d.source, "a word b\n");
6877    }
6878
6879    #[test]
6880    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
6881        // ⌘b at a bare caret, then type: the text comes out bold with no
6882        // selection ever made — the word-processor "start bold here" gesture.
6883        let mut d = doc_with("sticky_wrap", "xy\n");
6884        d.caret = 1; // between x and y
6885        d.toggle(InlineKind::Strong);
6886        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
6887        d.insert("A");
6888        assert_eq!(d.source, "x**A**y\n");
6889    }
6890
6891    #[test]
6892    fn sticky_bold_lights_the_toolbar_before_any_typing() {
6893        // The button must light the instant ⌘b is pressed, or the mode is
6894        // invisible until the first character lands.
6895        let mut d = doc_with("sticky_light", "xy\n");
6896        d.caret = 1;
6897        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6898        d.toggle(InlineKind::Strong);
6899        assert!(d.active_inline_marks().contains(InlineKind::Strong));
6900    }
6901
6902    #[test]
6903    fn sticky_bold_toggled_off_types_normally_again() {
6904        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
6905        // in the flow of typing, the exact sequence the user described.
6906        let mut d = doc_with("sticky_off", "\n");
6907        d.caret = 0;
6908        d.toggle(InlineKind::Strong);
6909        d.insert("a");
6910        d.insert("b"); // continues inside the run, no re-arming
6911        assert_eq!(d.source, "**ab**\n");
6912        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
6913        d.insert("c");
6914        assert_eq!(d.source, "**ab**c\n");
6915    }
6916
6917    #[test]
6918    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
6919        // Once a mark is realised the caret sits inside the run, so plain typing
6920        // extends it rather than starting a second, adjacent bold span.
6921        let mut d = doc_with("sticky_cont", "\n");
6922        d.caret = 0;
6923        d.toggle(InlineKind::Emph);
6924        d.insert("h");
6925        d.insert("i");
6926        assert_eq!(d.source, "*hi*\n");
6927    }
6928
6929    #[test]
6930    fn moving_the_caret_disarms_a_sticky_mark() {
6931        // Arming a mark and then moving away must not style text elsewhere.
6932        let mut d = doc_with("sticky_disarm", "xy\n");
6933        d.caret = 0;
6934        d.toggle(InlineKind::Strong);
6935        d.move_right(false); // caret 0 → 1, disarms
6936        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
6937        d.insert("A");
6938        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
6939    }
6940
6941    #[test]
6942    fn stacked_sticky_marks_apply_together() {
6943        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
6944        let mut d = doc_with("sticky_stack", "\n");
6945        d.caret = 0;
6946        d.toggle(InlineKind::Strong);
6947        d.toggle(InlineKind::Emph);
6948        d.insert("x");
6949        // Land the caret on the styled character and confirm both marks are live.
6950        d.anchor = Some(d.source.find('x').unwrap());
6951        d.caret = d.anchor.unwrap() + 1;
6952        let marks = d.active_inline_marks();
6953        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
6954        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
6955    }
6956
6957    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
6958
6959    #[test]
6960    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
6961        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
6962        // The space inside the run made `**bold **`, which is *not* bold — four
6963        // literal asterisks — so the rich view drew them, correctly and
6964        // uselessly, until the next character happened to close the run again.
6965        let mut d = wysiwyg_doc("edge_typing", "a \n");
6966        d.caret = 2;
6967        d.toggle(InlineKind::Strong);
6968        for c in "bold".chars() {
6969            d.insert(&c.to_string());
6970        }
6971        assert_eq!(d.source, "a **bold**\n");
6972        d.insert(" ");
6973        assert_eq!(
6974            d.source, "a **bold** \n",
6975            "the space belongs outside the run"
6976        );
6977        assert!(
6978            d.active_inline_marks().contains(InlineKind::Strong),
6979            "bold is still what's being typed, so the button stays lit"
6980        );
6981        // What the writer is looking at while all this happens: their words.
6982        d.build_visual(80);
6983        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
6984        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
6985        for c in "hey".chars() {
6986            d.insert(&c.to_string());
6987        }
6988        assert_eq!(
6989            d.source, "a **bold hey**\n",
6990            "one bold phrase, not two runs"
6991        );
6992    }
6993
6994    #[test]
6995    fn typing_past_a_space_can_still_leave_the_bold_behind() {
6996        // The other half: the marks stay armed across the space, so ⌘b turns
6997        // them off again there and the next word is plain — the run isn't
6998        // rejoined by a caret that was told not to.
6999        let mut d = wysiwyg_doc("edge_shed", "\n");
7000        d.caret = 0;
7001        d.toggle(InlineKind::Strong);
7002        for c in "bold ".chars() {
7003            d.insert(&c.to_string());
7004        }
7005        assert_eq!(d.source, "**bold** \n");
7006        d.toggle(InlineKind::Strong);
7007        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7008        d.insert("x");
7009        assert_eq!(d.source, "**bold** x\n");
7010    }
7011
7012    #[test]
7013    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
7014        // ⌘b and then a space before any word: the space is not marked (nothing
7015        // is), and the word after it is.
7016        let mut d = wysiwyg_doc("edge_space_first", "a\n");
7017        d.caret = 1;
7018        d.toggle(InlineKind::Strong);
7019        d.insert(" ");
7020        assert_eq!(d.source, "a \n");
7021        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7022        d.insert("b");
7023        assert_eq!(d.source, "a **b**\n");
7024    }
7025
7026    #[test]
7027    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
7028        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
7029        d.caret = 8; // the caret's home at the end of the run's text
7030        d.insert(" ");
7031        assert_eq!(
7032            d.source, "x **bold** \n",
7033            "the space lands past the delimiters"
7034        );
7035        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
7036
7037        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
7038        d.caret = 4; // in front of the "b"
7039        d.insert(" ");
7040        assert_eq!(d.source, "x  **bold** y\n");
7041        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
7042    }
7043
7044    #[test]
7045    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
7046        // Backspace over the last letter of a bold phrase.
7047        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
7048        d.caret = 10; // past the "h"
7049        d.backspace();
7050        assert_eq!(d.source, "a **bold** \n");
7051        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
7052        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7053        d.insert("x");
7054        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
7055    }
7056
7057    #[test]
7058    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
7059        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
7060        // mark, which is only text. The marks live on in the caret instead.
7061        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
7062        d.caret = 5;
7063        d.backspace();
7064        assert_eq!(d.source, "a  c\n");
7065        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7066        d.insert("x");
7067        assert_eq!(d.source, "a **x** c\n");
7068    }
7069
7070    #[test]
7071    fn typing_over_a_whole_bold_word_keeps_it_bold() {
7072        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
7073        d.anchor = Some(4);
7074        d.caret = 8; // the word, not its delimiters
7075        d.insert("x");
7076        assert_eq!(d.source, "a **x** c\n");
7077    }
7078
7079    #[test]
7080    fn a_code_span_keeps_the_space_it_is_given() {
7081        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
7082        // is still verbatim, so nothing is re-spelt. The repair asks the parser
7083        // rather than a table of kinds, and this is the answer it gets.
7084        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
7085        d.caret = 7;
7086        d.insert(" ");
7087        assert_eq!(d.source, "a `code ` c\n");
7088    }
7089
7090    #[test]
7091    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
7092        // A run's closing delimiter has a caret home on each side of it, one
7093        // column apart on screen — and a plain ← off the space after a bold word
7094        // lands on the outer one. The character drawn behind the caret there is
7095        // still the last letter of the phrase, so that is what Backspace takes;
7096        // the byte behind it is a `*` nobody can see.
7097        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
7098        d.caret = 9;
7099        d.move_left(false);
7100        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
7101        d.backspace();
7102        assert_eq!(
7103            d.source, "**bol** x\n",
7104            "a letter of the phrase, not its `*`"
7105        );
7106        assert_eq!(d.caret, 5);
7107
7108        // And the mirror in front of the opening delimiter, where Delete's
7109        // character is the first letter of the run.
7110        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
7111        d.caret = 1;
7112        d.delete_forward();
7113        assert_eq!(d.source, "x**old**\n");
7114        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
7115    }
7116
7117    #[test]
7118    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
7119        // The byte beside the caret at either edge of a bold word is a `*` the
7120        // rich view draws nothing for. Taking it is not the character delete the
7121        // key was pressed for — it unspells the run and puts a literal asterisk
7122        // on screen (`a *bold** c`). The visible character is the one that goes.
7123        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
7124        d.caret = 4; // in front of the "b"
7125        d.backspace();
7126        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
7127
7128        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
7129        d.caret = 8; // past the "d"
7130        d.delete_forward();
7131        assert_eq!(d.source, "a **bold**c\n");
7132        assert_eq!(d.caret, 8, "and the caret stays inside the run");
7133        d.insert("x");
7134        assert_eq!(d.source, "a **boldx**c\n");
7135
7136        // A code span's backticks are hidden the same way, so they are covered
7137        // by the same rule and not by a list of kinds.
7138        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
7139        d.caret = 3;
7140        d.backspace();
7141        assert_eq!(d.source, "a`code` c\n");
7142    }
7143
7144    #[test]
7145    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
7146        // The asterisks are on the screen there and the caret can stand between
7147        // them, so a delete takes exactly the byte it is aimed at.
7148        let mut d = doc_with("edge_open_src", "a **bold** c\n");
7149        d.caret = 4;
7150        d.backspace();
7151        assert_eq!(d.source, "a *bold** c\n");
7152
7153        let mut d = doc_with("edge_close_src", "a **bold** c\n");
7154        d.caret = 8;
7155        d.delete_forward();
7156        assert_eq!(d.source, "a **bold* c\n");
7157    }
7158
7159    #[test]
7160    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
7161        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
7162        // The space had stepped outside the run (the mark-edge rule), taking the
7163        // caret with it, so the delete put it back down on the far side of the
7164        // closing `**` — one place on screen, and the wrong side of it. Typing
7165        // came out plain and the toolbar went dark, with nothing to see.
7166        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
7167        d.caret = 0;
7168        d.toggle(InlineKind::Strong);
7169        for c in "bold".chars() {
7170            d.insert(&c.to_string());
7171        }
7172        d.insert(" ");
7173        assert_eq!(d.source, "**bold** \n");
7174        d.backspace();
7175        assert_eq!(
7176            d.source, "**bold**\n",
7177            "the space goes, the delimiters stay"
7178        );
7179        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
7180        assert!(
7181            d.active_inline_marks().contains(InlineKind::Strong),
7182            "so the button is still lit"
7183        );
7184        d.insert("x");
7185        assert_eq!(
7186            d.source, "**boldx**\n",
7187            "and the next character is still bold"
7188        );
7189    }
7190
7191    #[test]
7192    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
7193        // What the stranded caret did next: the byte behind it was the closing
7194        // `*`, so a second press took that instead of a letter — `**bold*`, the
7195        // styling gone and an asterisk on the screen where the word had been.
7196        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
7197        d.caret = 0;
7198        d.toggle(InlineKind::Strong);
7199        for c in "bold ".chars() {
7200            d.insert(&c.to_string());
7201        }
7202        assert_eq!(d.source, "**bold** \n");
7203        d.backspace();
7204        d.backspace();
7205        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
7206        assert_eq!(d.caret, 5);
7207    }
7208
7209    #[test]
7210    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
7211        // `***both***` closes two runs with one stack of asterisks: the caret has
7212        // to walk in through all of them, or it lands between the emph and the
7213        // strong and types half-marked.
7214        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
7215        d.caret = 11;
7216        d.backspace();
7217        assert_eq!(d.source, "***both***\n");
7218        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
7219        d.insert("x");
7220        assert_eq!(d.source, "***bothx***\n");
7221    }
7222
7223    #[test]
7224    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
7225        // The settle only moves a caret a run actually closed over. Ordinary
7226        // deletes — inside a run, or in plain prose — are untouched.
7227        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
7228        d.caret = 8;
7229        d.backspace();
7230        assert_eq!(d.source, "a **bol** c\n");
7231        assert_eq!(d.caret, 7);
7232
7233        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
7234        d.caret = 5;
7235        d.backspace();
7236        assert_eq!(d.source, "plai\n");
7237        assert_eq!(d.caret, 4);
7238    }
7239
7240    #[test]
7241    fn the_source_view_leaves_a_delete_where_it_landed() {
7242        // The delimiters are on the screen there, so the offset past them is a
7243        // place the caret can be seen to be — nothing to settle.
7244        let mut d = doc_with("edge_bksp_src", "**bold** \n");
7245        d.caret = 9;
7246        d.backspace();
7247        assert_eq!(d.source, "**bold**\n");
7248        assert_eq!(d.caret, 8);
7249    }
7250
7251    #[test]
7252    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
7253        // `***both***` closes two runs with one stack of asterisks; a space that
7254        // clears only the inner one lands against the outer's and breaks that
7255        // instead.
7256        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
7257        d.caret = 9;
7258        d.insert(" ");
7259        assert_eq!(d.source, "a ***both*** \n");
7260        assert_eq!(d.caret, 13);
7261        d.insert("x");
7262        assert_eq!(d.source, "a ***both x***\n");
7263    }
7264
7265    #[test]
7266    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
7267        // The delimiter shuffle is not an edit the writer made, so it is not a
7268        // step they have to undo past.
7269        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
7270        d.caret = 8;
7271        d.insert(" ");
7272        assert_eq!(d.source, "a **bold** \n");
7273        d.undo();
7274        assert_eq!(d.source, "a **bold**\n");
7275    }
7276
7277    #[test]
7278    fn the_source_view_types_the_space_where_it_was_asked_to() {
7279        // The rule is a rich-view courtesy. In the source view the delimiters are
7280        // on the screen and the user is editing the bytes they can see.
7281        let mut d = doc_with("edge_src", "a **bold** c\n");
7282        d.caret = 8;
7283        d.insert(" ");
7284        assert_eq!(d.source, "a **bold ** c\n");
7285    }
7286
7287    #[test]
7288    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
7289        // Double-clicking a word takes the space after it; bolding that must not
7290        // spell `**word **`, which is not bold at all.
7291        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
7292        d.anchor = Some(2);
7293        d.caret = 7; // "word "
7294        d.toggle(InlineKind::Strong);
7295        assert_eq!(d.source, "a **word** b\n");
7296        d.toggle(InlineKind::Strong);
7297        assert_eq!(d.source, "a word b\n");
7298        d.toggle(InlineKind::Strong);
7299        assert_eq!(
7300            d.source, "a **word** b\n",
7301            "reapplying the mark must not wrap stale delimiter offsets"
7302        );
7303        // And a selection of nothing but whitespace has no word to mark.
7304        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
7305        d.anchor = Some(6);
7306        d.caret = 7;
7307        d.toggle(InlineKind::Strong);
7308        assert_eq!(d.source, "a word b\n");
7309        assert!(d.status.is_some());
7310    }
7311
7312    #[test]
7313    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
7314        let mut d = doc_with("head_set", "hello\n");
7315        d.caret = 2; // caret inside the paragraph, no selection
7316        d.set_block(BlockKind::Heading(1));
7317        assert_eq!(d.source, "# hello\n");
7318    }
7319
7320    #[test]
7321    fn set_block_heading_works_in_wysiwyg_view() {
7322        // The app defaults to WYSIWYG; the caret is a source offset either way.
7323        let mut d = wysiwyg_doc("head_wys", "hello\n");
7324        d.caret = 2;
7325        d.set_block(BlockKind::Heading(1));
7326        assert_eq!(d.source, "# hello\n");
7327    }
7328
7329    #[test]
7330    fn toggle_heading_applies_switches_and_reverts() {
7331        let mut d = doc_with("head_toggle", "hello\n");
7332        d.caret = 2;
7333        d.toggle_heading(1);
7334        assert_eq!(d.source, "# hello\n"); // paragraph → H1
7335        d.toggle_heading(2);
7336        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
7337        d.toggle_heading(2);
7338        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
7339    }
7340
7341    #[test]
7342    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
7343        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
7344        // the blank line but the caret rendered on the *next* line, because the
7345        // separator was a non-navigable decoration row. In Preserve flow that
7346        // blank line is a real caret home — the caret must resolve onto it, and
7347        // typing there makes the soft break that continues the paragraph.
7348        let src = "line one:\nsecond line\n";
7349        let mut d = wysiwyg_doc("pre_enter_lineend", src);
7350        d.set_line_flow(LineFlow::Preserve);
7351        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
7352        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
7353        d.newline();
7354        d.build_visual_unwrapped();
7355        assert_eq!(d.source, "line one:\n\nsecond line\n");
7356        assert_eq!(
7357            d.caret, 10,
7358            "caret sits on the new blank line, not the next line"
7359        );
7360        // The blank line is row 1, and the caret resolves onto it — not row 2.
7361        assert_eq!(
7362            d.vmap.pos_of_offset(10),
7363            (1, 0),
7364            "caret renders on the blank row"
7365        );
7366        assert!(
7367            !d.vmap.rows[1].decoration,
7368            "the blank line is navigable in Preserve"
7369        );
7370        // Typing there makes a soft break: one paragraph, three lines.
7371        d.insert("new clause,");
7372        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
7373    }
7374
7375    #[test]
7376    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
7377        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
7378        // that keeps it one paragraph — where Fold would open a second paragraph.
7379        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
7380        d.set_line_flow(LineFlow::Preserve);
7381        d.caret = 3;
7382        d.newline();
7383        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
7384
7385        // End-of-paragraph: Enter then typing continues the same paragraph on a
7386        // new line (a soft break), not a fresh paragraph.
7387        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
7388        d.set_line_flow(LineFlow::Preserve);
7389        d.caret = 3;
7390        d.newline();
7391        d.insert("def");
7392        assert_eq!(
7393            d.source, "abc\ndef\n",
7394            "end-of-line Enter + typing is a soft break"
7395        );
7396    }
7397
7398    #[test]
7399    fn preserve_double_enter_still_makes_a_paragraph() {
7400        // Two Enters in a row promote to a real paragraph break: the second lands
7401        // on the blank line the first opened and takes the empty-line branch.
7402        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
7403        d.set_line_flow(LineFlow::Preserve);
7404        d.caret = 3;
7405        d.newline();
7406        d.newline();
7407        d.insert("def");
7408        assert_eq!(
7409            d.source, "abc\n\ndef\n",
7410            "double Enter is a paragraph break"
7411        );
7412    }
7413
7414    #[test]
7415    fn preserve_backspace_joins_across_a_soft_break() {
7416        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
7417        // soft break it deletes the single newline and joins the two lines.
7418        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
7419        d.set_line_flow(LineFlow::Preserve);
7420        d.build_visual(80);
7421        d.caret = 4; // start of "def", just past the soft break
7422        d.backspace();
7423        assert_eq!(
7424            d.source, "abcdef\n",
7425            "Backspace joins across the soft break"
7426        );
7427        assert_eq!(d.caret, 3, "caret lands where the lines meet");
7428    }
7429
7430    #[test]
7431    fn fold_enter_still_starts_a_new_paragraph() {
7432        // The default flow is unchanged: a lone `\n` would render as an invisible
7433        // space, so Enter keeps opening the paragraph break that actually shows.
7434        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
7435        d.caret = 3;
7436        d.newline();
7437        assert_eq!(
7438            d.source, "abc\n\ndef\n",
7439            "Fold mid-line Enter is a paragraph break"
7440        );
7441    }
7442
7443    #[test]
7444    fn wysiwyg_one_enter_starts_a_new_paragraph() {
7445        // Regression: one Enter left the caret between the two newlines, so typing
7446        // made a soft break (one paragraph) and you needed a second Enter.
7447        let mut d = wysiwyg_doc("wys_enter", "abc\n");
7448        d.caret = 3;
7449        d.newline();
7450        d.insert("def");
7451        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
7452    }
7453
7454    #[test]
7455    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
7456        // Regression: Enter at the caret's natural End-of-line resting place
7457        // after a bold run with nothing following it (on screen: right after
7458        // "bold", before the hidden closing "**") spliced the paragraph break
7459        // at that very byte offset — which sits *before* the closing "**" in
7460        // the source, since the delimiter is hidden and emits no glyph of its
7461        // own for `push_row`'s "end of row" fallback to count. That severed the
7462        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
7463        // "**" alone on the new line instead of leaving "**bold**" intact with
7464        // a fresh empty paragraph after it.
7465        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
7466        d.move_end(false); // the WYSIWYG End key, from caret 0
7467        assert_eq!(
7468            d.caret, 6,
7469            "caret rests right after \"bold\", before the hidden \"**\""
7470        );
7471        d.newline();
7472        assert!(
7473            d.source.starts_with("**bold**"),
7474            "the closing ** must stay attached to \"bold\": got {:?}",
7475            d.source
7476        );
7477        assert_eq!(
7478            d.source, "**bold**\n\n\n",
7479            "a fresh empty paragraph follows the still-intact bold run"
7480        );
7481    }
7482
7483    #[test]
7484    fn source_view_enter_is_a_single_newline() {
7485        let mut d = doc_with("src_enter", "abc\n");
7486        d.caret = 3;
7487        d.newline();
7488        assert_eq!(d.source, "abc\n\n");
7489    }
7490
7491    #[test]
7492    fn heading_applies_at_the_end_of_a_paragraph() {
7493        // The caret at a line end sits at the doc level; set_block must still find
7494        // the block on that line.
7495        let mut d = doc_with("head_end", "abc\n");
7496        d.caret = 3; // end of "abc"
7497        d.toggle_heading(1);
7498        assert_eq!(d.source, "# abc\n");
7499    }
7500
7501    #[test]
7502    fn heading_on_an_empty_new_paragraph_creates_one() {
7503        let mut d = wysiwyg_doc("head_empty", "abc\n");
7504        d.caret = 3;
7505        d.newline(); // caret now on a fresh, empty paragraph
7506        d.toggle_heading(1);
7507        d.insert("Title");
7508        assert!(d.source.contains("# Title"), "got {:?}", d.source);
7509    }
7510
7511    #[test]
7512    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
7513        // The reported bug, end to end: click a blank line with another one under
7514        // it, press H1, type. The text landed in the heading and the caret's
7515        // offset was right (the source view drew it there), but the rich view
7516        // drew it two rows lower, on the trailing blank line — the empty `# `
7517        // heading had left every row below it short by the marker's two bytes,
7518        // and the blank line ended up claiming the heading's own end offset.
7519        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
7520        d.build_visual_unwrapped();
7521        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
7522        d.toggle_heading(1);
7523        for c in "title".chars() {
7524            d.insert(&c.to_string());
7525            d.build_visual_unwrapped(); // as a frontend does, one frame per key
7526        }
7527        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
7528        assert_eq!(
7529            d.caret_pos(),
7530            (4, 5),
7531            "the caret draws at the end of the heading"
7532        );
7533    }
7534
7535    #[test]
7536    fn clicking_an_empty_heading_types_after_its_marker() {
7537        // The same anchor from the other side: the empty heading's row is its own
7538        // caret home, so a click on it must land past the hidden `# `. Landing in
7539        // front of the hashes made the first keystroke un-heading the line.
7540        let mut d = wysiwyg_doc("head_click", "# \n");
7541        d.build_visual_unwrapped();
7542        d.caret = d.vmap.offset_of_pos(0, 0);
7543        d.insert("x");
7544        assert_eq!(d.source, "# x\n");
7545    }
7546
7547    #[test]
7548    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
7549        let mut d = wysiwyg_doc("head_enter", "# Title\n");
7550        d.caret = 7; // end of the heading
7551        d.newline();
7552        d.insert("body");
7553        assert_eq!(d.source, "# Title\n\nbody\n");
7554    }
7555
7556    #[test]
7557    fn wysiwyg_enter_continues_a_bullet_list() {
7558        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
7559        d.caret = 6; // end of "item"
7560        d.newline();
7561        d.insert("two");
7562        assert_eq!(d.source, "- item\n- two\n");
7563    }
7564
7565    #[test]
7566    fn wysiwyg_enter_increments_an_ordered_list() {
7567        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
7568        d.caret = 6; // end of "one"
7569        d.newline();
7570        d.insert("two");
7571        assert_eq!(d.source, "1. one\n2. two\n");
7572    }
7573
7574    #[test]
7575    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
7576        // Regression for the "extra newline" left between a list and the paragraph
7577        // below it. Enter, Enter leaves the list on a fresh empty paragraph
7578        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
7579        // Backspace should then take the caret cleanly back to the end of the list
7580        // item, `- item\n\nnext`, not delete a single newline and strand it on the
7581        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
7582        // no caret can land on. The map is rebuilt between keystrokes exactly as a
7583        // frontend does, since Backspace reads the stop table to place the delete.
7584        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
7585        d.caret = 6; // end of "item"
7586        d.newline();
7587        d.build_visual(80);
7588        d.newline(); // leave the list onto a fresh empty paragraph
7589        d.build_visual(80);
7590        assert_eq!(
7591            d.source, "- item\n\n\n\nnext\n",
7592            "double-Enter opens the empty paragraph"
7593        );
7594        d.backspace();
7595        assert_eq!(
7596            d.source, "- item\n\nnext\n",
7597            "one Backspace collapses the whole gap"
7598        );
7599        assert_eq!(
7600            d.caret, 6,
7601            "and lands the caret back at the end of the list item"
7602        );
7603    }
7604
7605    #[test]
7606    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
7607        // The stop-wise delete must not over-reach when there is no block boundary
7608        // to cross: two blank lines in a row are one caret stop apart, so pressing
7609        // Enter on an empty line and then Backspace removes exactly the one newline
7610        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
7611        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
7612        d.caret = 5; // the empty paragraph the first Enter already opened
7613        d.build_visual(80);
7614        d.newline();
7615        d.build_visual(80);
7616        assert_eq!(
7617            d.source, "abc\n\n\n\n",
7618            "Enter on the blank line adds one newline"
7619        );
7620        d.backspace();
7621        assert_eq!(
7622            d.source, "abc\n\n\n",
7623            "Backspace takes back exactly that one newline"
7624        );
7625    }
7626
7627    #[test]
7628    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
7629        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
7630        d.caret = 6; // end of the empty "- " item
7631        d.newline();
7632        d.insert("p");
7633        assert_eq!(d.source, "- a\n\np\n");
7634    }
7635
7636    #[test]
7637    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
7638        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
7639        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
7640        // not take the list-exit path (which would splice the `- ` away as if
7641        // leaving an empty item); the AST guard sends it to a normal break and
7642        // leaves the underline intact.
7643        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
7644        assert!(
7645            d.nodes().iter().any(|n| n.kind == Kind::Heading),
7646            "precondition: twig parses this as a heading, not a list",
7647        );
7648        d.caret = 7; // on the `- ` underline line
7649        d.newline();
7650        assert!(
7651            d.source.contains("- "),
7652            "the setext underline survives, not spliced away as a list item: {:?}",
7653            d.source,
7654        );
7655    }
7656
7657    #[test]
7658    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
7659        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
7660        d.caret = 7; // end of "abc" inside the fence
7661        d.newline();
7662        d.insert("def");
7663        assert_eq!(d.source, "```\nabc\ndef\n```\n");
7664    }
7665
7666    #[test]
7667    fn wysiwyg_enter_continues_a_block_quote() {
7668        // Enter opens a new *paragraph* inside the quote, not a second line of
7669        // the same one. `> quote\n> more` is a soft break, which under
7670        // `LineFlow::Fold` renders as a space — the keystroke would look like it
7671        // did nothing. The quoted blank line is what makes the break visible, and
7672        // it's the same thing Enter does in running prose.
7673        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
7674        d.caret = 7; // end of "quote"
7675        d.newline();
7676        d.insert("more");
7677        assert_eq!(d.source, "> quote\n>\n> more\n");
7678        // Still one quote, now holding two paragraphs — not a quote and a stray
7679        // line that fell out of it.
7680        let quotes = d
7681            .nodes()
7682            .iter()
7683            .filter(|n| n.kind == Kind::BlockQuote)
7684            .count();
7685        assert_eq!(quotes, 1);
7686    }
7687
7688    #[test]
7689    fn set_block_makes_a_heading_at_the_caret() {
7690        let mut d = doc_with("head", "Title\n\nbody\n");
7691        d.caret = 0;
7692        d.set_block(BlockKind::Heading(2));
7693        assert_eq!(d.source, "## Title\n\nbody\n");
7694        d.set_block(BlockKind::Paragraph);
7695        assert_eq!(d.source, "Title\n\nbody\n");
7696    }
7697
7698    // ── block containers (quote / list) ──────────────────────────────────────
7699
7700    #[test]
7701    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
7702        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
7703        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
7704        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
7705        // A caret at a line end sits at the doc level; the block is still found.
7706        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
7707    }
7708
7709    #[test]
7710    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
7711        // Every source line of the paragraph gets its own `> `, so a caret left
7712        // on its old byte offset falls one prefix per line above it too far
7713        // back — inside the markup it just asked for rather than in its word.
7714        assert_eq!(
7715            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
7716            "> aaa\n> b|bb\n> ccc\n"
7717        );
7718    }
7719
7720    #[test]
7721    fn toggle_blockquote_works_in_wysiwyg_view() {
7722        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7723        assert_eq!(
7724            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
7725            "> hel|lo\n"
7726        );
7727        assert_eq!(
7728            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
7729            "hel|lo\n"
7730        );
7731    }
7732
7733    #[test]
7734    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
7735        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
7736        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7737        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7738        // The *other* kind converts in place instead of nesting, which is what
7739        // makes the two buttons one three-state control.
7740        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
7741        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
7742        // Its own kind, over the only item the list holds, takes it off.
7743        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
7744    }
7745
7746    #[test]
7747    fn toggle_list_works_in_wysiwyg_view() {
7748        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
7749        assert_eq!(
7750            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
7751            "1. hel|lo\n"
7752        );
7753        assert_eq!(
7754            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
7755            "- hel|lo\n"
7756        );
7757        assert_eq!(
7758            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
7759            "hel|lo\n"
7760        );
7761    }
7762
7763    #[test]
7764    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
7765        // The selection has to grow with the markup: twig takes a container off
7766        // only a range covering every block it holds, so the second press can
7767        // reverse the first only if the result is what's selected.
7768        let mut d = doc_with("list_sel", "abc\n\ndef\n");
7769        d.select_all();
7770        d.toggle_list(true);
7771        assert_eq!(d.source, "1. abc\n\n2. def\n");
7772        assert_eq!(d.selection(), Some((0, d.source.len())));
7773        d.toggle_list(true);
7774        assert_eq!(d.source, "abc\n\ndef\n");
7775    }
7776
7777    #[test]
7778    fn toggle_blockquote_nests_a_partly_covered_quote() {
7779        // twig's rule: covering only some of a container's blocks nests, because
7780        // taking the quote off would drag its uncovered siblings out with it.
7781        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
7782        d.caret = 2; // in the first quoted paragraph only
7783        d.toggle_blockquote();
7784        assert_eq!(d.source, "> > a\n>\n> b\n");
7785    }
7786
7787    #[test]
7788    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
7789        // A blank line used to be no block for twig to wrap —
7790        // `toggle_block_container` answered `NotFound` — so Quote and the list
7791        // buttons did nothing on the very line the H1 button works on, and leaf
7792        // lent twig a scratch paragraph to wrap and took it back out again.
7793        // twig 3.2.0 opens an empty container there itself, so what is left here
7794        // is where the caret lands: inside the marker that was just written.
7795        let mut d = doc_with("quote_blank", "\nabc\n");
7796        d.caret = 0;
7797        d.toggle_blockquote();
7798        assert_eq!(d.source, "> \nabc\n");
7799        assert_eq!(
7800            d.caret, 2,
7801            "the caret belongs inside the quote it just opened"
7802        );
7803        assert!(d.status.is_none(), "{:?}", d.status);
7804        assert!(d.dirty);
7805
7806        // And the paragraph below is still its own block: an empty container one
7807        // soft break from `abc` would take that paragraph into the quote with it.
7808        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
7809        d.caret = 0;
7810        d.toggle_blockquote();
7811        d.build_visual(80);
7812        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
7813
7814        // The same from the other side: a blank line directly under a paragraph
7815        // earns the blank line an empty block needs, rather than being read as a
7816        // soft break inside that paragraph.
7817        let mut d = doc_with("list_blank_below", "abc\n");
7818        d.caret = 4;
7819        d.toggle_list(false);
7820        assert_eq!(d.source, "abc\n\n- ");
7821        assert_eq!(d.caret, 7);
7822    }
7823
7824    #[test]
7825    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
7826        // The gesture the rendering fix is for. `newline` inside a quote already
7827        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
7828        // spelling — but the two marker lines it adds belonged to no node until
7829        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
7830        // just made drew as plain prose under the quote.
7831        let mut d = wysiwyg_doc("quote_enter", "> a\n");
7832        d.caret = 3; // past `a`, at the end of the quoted line
7833        d.newline();
7834        assert_eq!(d.source, "> a\n>\n> \n");
7835        d.build_visual(80);
7836        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
7837        // And the caret is on the new line, not stranded on the old one.
7838        assert_eq!(d.caret, 8);
7839    }
7840
7841    #[test]
7842    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
7843        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
7844        // now it is twig's single edit. Either way one ⌘z has to put the blank
7845        // line back rather than undoing into a half-built document.
7846        for open in [
7847            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
7848            &|d: &mut Doc| d.toggle_list(false),
7849            &|d: &mut Doc| d.toggle_list(true),
7850        ] {
7851            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
7852            d.caret = 3;
7853            open(&mut d);
7854            assert_ne!(d.source, "a\n\n\n\nb\n");
7855            d.undo();
7856            assert_eq!(d.source, "a\n\n\n\nb\n");
7857        }
7858    }
7859
7860    #[test]
7861    fn a_container_toggle_is_one_undo_step() {
7862        let mut d = doc_with("quote_undo", "hello\n");
7863        d.caret = 3;
7864        d.insert("X"); // a typing run the structural edit must not fold into
7865        d.toggle_blockquote();
7866        assert_eq!(d.source, "> helXlo\n");
7867        d.undo();
7868        assert_eq!(d.source, "helXlo\n");
7869    }
7870
7871    // ── links ────────────────────────────────────────────────────────────────
7872
7873    #[test]
7874    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
7875        let mut d = doc_with("link_sel", "word here\n");
7876        d.anchor = Some(0);
7877        d.caret = 4;
7878        d.insert_link("http://x.dev");
7879        assert_eq!(d.source, "[word](http://x.dev) here\n");
7880        // The text, not the destination — so a second press re-points the link
7881        // the first one made rather than nesting one inside it.
7882        assert_eq!(d.selected_text(), Some("word"));
7883        d.insert_link("http://y.dev");
7884        assert_eq!(d.source, "[word](http://y.dev) here\n");
7885        assert_eq!(d.selected_text(), Some("word"));
7886    }
7887
7888    #[test]
7889    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
7890        let mut d = doc_with("img_caret", "before after\n");
7891        d.caret = 7; // between "before " and "after"
7892        d.insert_image("cat.png", "a cat");
7893        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
7894        // The caret sits just past the inserted image, nothing selected.
7895        assert_eq!(d.selection(), None);
7896        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
7897    }
7898
7899    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
7900    /// destination at the first space, so the `format!` this used to be wrote
7901    /// something that was not an image at all — and the reader saw the markup as
7902    /// text. twig owns the spelling now, and moves it into the angle form.
7903    #[test]
7904    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
7905        let mut d = doc_with("img_space", "x\n");
7906        d.caret = 0;
7907        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
7908        assert_eq!(
7909            d.source,
7910            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
7911        );
7912        // And it reads back as an image pointing at the unescaped path — the angle
7913        // brackets are spelling, not part of the destination.
7914        d.caret = 2;
7915        assert_eq!(
7916            d.image_destination_at_caret(),
7917            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
7918        );
7919    }
7920
7921    /// A `)` in a caption or a filename must not close the image early.
7922    #[test]
7923    fn insert_image_escapes_a_paren_in_either_half() {
7924        let mut d = doc_with("img_paren", "x\n");
7925        d.caret = 0;
7926        d.insert_image("a)b.png", "");
7927        assert_eq!(d.source, "![](a\\)b.png)x\n");
7928        d.caret = 2;
7929        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
7930    }
7931
7932    #[test]
7933    fn insert_image_uses_the_selection_as_alt_text() {
7934        let mut d = doc_with("img_sel", "caption here\n");
7935        d.anchor = Some(0);
7936        d.caret = 7; // "caption"
7937        d.insert_image("p.png", "ignored fallback");
7938        assert_eq!(d.source, "![caption](p.png) here\n");
7939    }
7940
7941    #[test]
7942    fn insert_image_with_no_alt_leaves_empty_brackets() {
7943        let mut d = doc_with("img_noalt", "\n");
7944        d.caret = 0;
7945        d.insert_image("logo.svg", "");
7946        assert_eq!(d.source, "![](logo.svg)\n");
7947    }
7948
7949    #[test]
7950    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
7951        // The round trip is the point: it's no use writing markup the reader
7952        // can't pick up again. This is the pair that only holds from twig 2.5.1
7953        // on — before it, the one-line form went in fine and came back as a
7954        // paragraph of raw tags, publishing no media at all.
7955        let mut d = doc_with("vid_rt", "\n");
7956        d.caret = 0;
7957        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
7958        assert_eq!(
7959            d.source,
7960            "<video src=\"clip.mp4\" controls>a clip</video>\n"
7961        );
7962
7963        d.build_visual(80);
7964        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
7965        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
7966        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
7967        assert_eq!(d.vmap.media[0].alt, "a clip");
7968    }
7969
7970    #[test]
7971    fn insert_media_spells_audio_with_its_own_tag() {
7972        let mut d = doc_with("aud_rt", "\n");
7973        d.caret = 0;
7974        d.insert_media(MediaKind::Audio, "take.mp3", "");
7975        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
7976        d.build_visual(80);
7977        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
7978    }
7979
7980    #[test]
7981    fn insert_media_uses_the_selection_as_fallback_text() {
7982        // The same courtesy `insert_image` does with alt: select a caption,
7983        // insert, and the caption labels the thing rather than being replaced.
7984        let mut d = doc_with("vid_sel", "the talk here\n");
7985        d.anchor = Some(0);
7986        d.caret = 8; // "the talk"
7987        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
7988        assert_eq!(
7989            d.source,
7990            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
7991        );
7992    }
7993
7994    #[test]
7995    fn insert_media_with_an_image_kind_is_just_insert_image() {
7996        let mut d = doc_with("img_via_media", "\n");
7997        d.caret = 0;
7998        d.insert_media(MediaKind::Image, "logo.svg", "x");
7999        assert_eq!(d.source, "![x](logo.svg)\n");
8000    }
8001
8002    // ── thematic breaks ─────────────────────────────────────────────────────
8003
8004    /// The node the source parses as at `caret` — what confirms an inserted
8005    /// `---` actually reads back as a rule, not stray text or a setext heading.
8006    ///
8007    /// The *narrowest* node covering the offset. Every ancestor covers it too,
8008    /// and since twig 2.8 that includes the `doc` root, which now carries a real
8009    /// span (it reported none before, so taking the first match used to land on
8010    /// the block by luck and now always answers `"doc"`).
8011    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
8012        d.nodes()
8013            .into_iter()
8014            .filter(|n| n.span.start <= caret && caret < n.span.end)
8015            .min_by_key(|n| n.span.end - n.span.start)
8016            .map(|n| n.kind)
8017    }
8018
8019    #[test]
8020    fn a_task_box_toggles_at_the_caret_and_reads_back() {
8021        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
8022        d.caret = 8; // inside "todo"
8023        assert_eq!(d.task_checked_at_caret(), Some(false));
8024        d.toggle_task_checked();
8025        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
8026        assert_eq!(d.task_checked_at_caret(), Some(true));
8027        d.toggle_task_checked();
8028        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
8029    }
8030
8031    #[test]
8032    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
8033        // The whole reason `toggle_task_at` exists apart from the caret form:
8034        // ticking a box elsewhere must not move the cursor out of what's being
8035        // typed.
8036        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
8037        d.caret = 8; // inside "first"
8038        let second = d.source.find("second").unwrap();
8039        d.toggle_task_at(second);
8040        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
8041        assert_eq!(d.caret, 8, "the caret stayed in the first item");
8042    }
8043
8044    #[test]
8045    fn a_plain_item_gains_and_loses_a_box() {
8046        let mut d = doc_with("task_mint", "- plain\n");
8047        d.caret = 4;
8048        assert_eq!(d.task_checked_at_caret(), None);
8049        d.toggle_task_item();
8050        assert_eq!(d.source, "- [ ] plain\n");
8051        assert_eq!(
8052            d.task_checked_at_caret(),
8053            Some(false),
8054            "a new box arrives unticked"
8055        );
8056        d.toggle_task_item();
8057        assert_eq!(d.source, "- plain\n");
8058    }
8059
8060    #[test]
8061    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
8062        // `set checked` must not silently convert a bullet into a task — that is
8063        // `toggle_task_item`'s job, and twig refuses it here.
8064        let mut d = doc_with("task_none", "- plain\n");
8065        d.caret = 4;
8066        d.toggle_task_checked();
8067        assert_eq!(d.source, "- plain\n", "nothing written");
8068        assert!(
8069            d.status.is_some(),
8070            "the refusal should reach the status line"
8071        );
8072    }
8073
8074    #[test]
8075    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
8076        let mut d = doc_with("task_quote", "> - [ ] nested\n");
8077        d.caret = d.source.find("nested").unwrap();
8078        assert_eq!(d.task_checked_at_caret(), Some(false));
8079        d.toggle_task_checked();
8080        assert_eq!(d.source, "> - [x] nested\n");
8081    }
8082
8083    #[test]
8084    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
8085        // A rule is a block, so twig's `insert_thematic_break` alone lands it
8086        // after the whole paragraph. `split_block` parts the paragraph first and
8087        // the rule is aimed at the *first* half, which is what a rule button is
8088        // understood to do — and what leaf spelled by hand until twig grew both
8089        // halves of the gesture.
8090        let mut d = doc_with("hr_mid", "before after\n");
8091        d.caret = 7; // between "before " and "after"
8092        d.insert_thematic_break();
8093        assert_eq!(d.source, "before \n\n---\n\nafter\n");
8094        assert_eq!(d.selection(), None);
8095        assert_eq!(
8096            kind_at(&mut d, "before \n\n".len()),
8097            Some(Kind::ThematicBreak)
8098        );
8099    }
8100
8101    #[test]
8102    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
8103        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
8104        // and leaf wrote the first into both until twig started spelling it.
8105        let mut md = doc_with("hr_md", "para\n");
8106        md.caret = 2;
8107        md.insert_thematic_break();
8108        assert_eq!(md.source, "pa\n\n---\n\nra\n");
8109
8110        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
8111        dj.caret = 2;
8112        dj.insert_thematic_break();
8113        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
8114    }
8115
8116    #[test]
8117    fn clicking_below_a_final_thematic_break_can_type_after_it() {
8118        let mut d = wysiwyg_doc("hr_final_click", "---\n");
8119        d.build_visual(80);
8120        d.click(d.vmap.num_rows() + 2, 0, false);
8121        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
8122        d.insert("after");
8123        assert_eq!(d.source, "---\nafter");
8124    }
8125
8126    #[test]
8127    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
8128        // The same bytes are two documents. In Markdown `  - b` is a nested item
8129        // and the next one belongs beside it, at its indent. In Djot a list
8130        // marker can't interrupt a paragraph, so those bytes are literal text in
8131        // item `a` and there is only one item — writing `  - ` under it would add
8132        // no item at all, just more text, and the new sibling has to go to
8133        // column zero. Both spellings come out of the *enclosing item's* line.
8134        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
8135        md.caret = "- a\n  - b".len();
8136        md.newline();
8137        assert_eq!(md.source, "- a\n  - b\n  - \n");
8138        assert_eq!(list_items(&mut md), 3);
8139
8140        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
8141        dj.view = View::Wysiwyg;
8142        dj.build_visual(80);
8143        dj.caret = "- a\n  - b".len();
8144        dj.newline();
8145        assert_eq!(dj.source, "- a\n  - b\n- \n");
8146        assert_eq!(list_items(&mut dj), 2);
8147
8148        // Where Djot's nesting is real — opened by a blank line — the indent is
8149        // reproduced there too, and the two formats agree again.
8150        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
8151        dj.view = View::Wysiwyg;
8152        dj.build_visual(80);
8153        dj.caret = "- a\n\n  - b".len();
8154        dj.newline();
8155        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
8156        assert_eq!(list_items(&mut dj), 3);
8157    }
8158
8159    #[test]
8160    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
8161        // Tab replaces the line's whole prefix with the one twig spells, so the
8162        // quote markers, the parent's indent and an ordered marker's extra
8163        // column are all its answer rather than leaf's arithmetic.
8164        for (name, body, caret, want) in [
8165            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
8166            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
8167            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
8168            // A checkbox is markup the item's own text wraps past, but a nested
8169            // list may only open at the *list* marker's column — four in from
8170            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
8171            // parses as one item, not two.
8172            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
8173            (
8174                "quoted task",
8175                "> - [ ] a\n> - [ ] b\n",
8176                18,
8177                "> - [ ] a\n>   - [ ] b\n",
8178            ),
8179        ] {
8180            let mut doc = wysiwyg_doc(name, body);
8181            doc.caret = caret;
8182            doc.indent();
8183            assert_eq!(doc.source, want, "{name}");
8184            // The nesting is real, not just indented text.
8185            assert_eq!(list_items(&mut doc), 2, "{name}");
8186        }
8187    }
8188
8189    #[test]
8190    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
8191        // The same bytes, the two formats disagreeing, and a gesture that used
8192        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
8193        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
8194        // so those bytes are literal text inside item `a` — there is nothing to
8195        // outdent, and treating them as a marker turned one item into two, a
8196        // structural edit from a keystroke that should delete one character.
8197        //
8198        // twig's `line_prefix` is what tells them apart: it reports the marker
8199        // on the Markdown line and nothing on the Djot one, which is a
8200        // continuation. No byte scan can reach that answer.
8201        let src = "- a\n  - b\n";
8202        let at = "- a\n  - ".len();
8203
8204        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
8205        md.view = View::Wysiwyg;
8206        md.build_visual(80);
8207        md.caret = at;
8208        md.backspace();
8209        assert_eq!(md.source, "- a\n- b\n");
8210        assert_eq!(list_items(&mut md), 2);
8211
8212        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
8213        dj.view = View::Wysiwyg;
8214        dj.build_visual(80);
8215        dj.caret = at;
8216        dj.backspace();
8217        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
8218        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
8219    }
8220
8221    #[test]
8222    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
8223        // Leaf used to spell the next item from the marker bytes it scanned, and
8224        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
8225        // and dropped out of the checklist. twig reproduces the whole
8226        // continuation, and a fresh item is always unticked however the one above
8227        // it stands.
8228        for (name, body, want) in [
8229            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
8230            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
8231        ] {
8232            let mut doc = wysiwyg_doc(name, body);
8233            doc.caret = body.trim_end_matches('\n').len();
8234            doc.newline();
8235            assert_eq!(doc.source, want, "{name}");
8236            // Both items are checklist items — the new one is a box, not the
8237            // plain bullet the old marker scan left behind — and it is unticked
8238            // whichever way the one above it faces.
8239            let boxes: Vec<Option<bool>> = doc
8240                .nodes()
8241                .iter()
8242                .filter(|n| n.kind == Kind::TaskListItem)
8243                .map(|n| n.checked)
8244                .collect();
8245            assert_eq!(boxes.len(), 2, "{name}");
8246            assert_eq!(boxes[1], Some(false), "{name}");
8247        }
8248    }
8249
8250    #[test]
8251    fn a_split_takes_the_space_the_caret_was_in_front_of() {
8252        // Splicing a break at the caret strands the space the words were parted
8253        // at on the head of the second block, where it reads as an indent nobody
8254        // typed. twig's split consumes it.
8255        for (name, body, caret, want) in [
8256            ("para", "one two\n", 3, "one\n\ntwo\n"),
8257            ("item", "- one two\n", 5, "- one\n- two\n"),
8258            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
8259            // A heading takes leaf's own path, which has to match.
8260            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
8261        ] {
8262            let mut doc = wysiwyg_doc(name, body);
8263            doc.caret = caret;
8264            doc.newline();
8265            assert_eq!(doc.source, want, "{name}");
8266        }
8267    }
8268
8269    #[test]
8270    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
8271        // The one place leaf keeps its own break: `split_block` repeats the `#`,
8272        // and Enter after a title is how the body under it is asked for.
8273        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
8274        doc.caret = "# Title".len();
8275        doc.newline();
8276        doc.insert("body");
8277        assert_eq!(doc.source, "# Title\n\nbody\n");
8278        assert_eq!(
8279            doc.nodes()
8280                .iter()
8281                .filter(|n| n.kind == Kind::Heading)
8282                .count(),
8283            1
8284        );
8285    }
8286
8287    #[test]
8288    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
8289        // A quoted item's marker doesn't open its line, so a scan that starts at
8290        // column zero finds a `>` where it wanted a bullet, calls the line "not a
8291        // list" and hands Enter to the plain-quote branch — which writes `> ` and
8292        // drops the list. The next item has to carry the whole prefix.
8293        for (name, body, want) in [
8294            ("flat", "> - a\n", "> - a\n> - \n"),
8295            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
8296            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
8297            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
8298            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
8299        ] {
8300            let mut doc = wysiwyg_doc(name, body);
8301            doc.caret = body.trim_end_matches('\n').len();
8302            doc.newline();
8303            assert_eq!(doc.source, want, "{name}");
8304            // The marker isn't just spelled right, it parses as an item.
8305            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
8306        }
8307    }
8308
8309    #[test]
8310    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
8311        // Double-Enter exits the list. Unquoted that means a blank line, but a
8312        // *bare* blank line would end the quote too and drop the caret out of it,
8313        // so the separator keeps its `>` and the caret's line keeps its `> `.
8314        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
8315        doc.caret = "> - a\n> - ".len();
8316        doc.newline();
8317        assert_eq!(doc.source, "> - a\n>\n> \n");
8318        assert_eq!(list_items(&mut doc), 1);
8319        // What "still in the quote" means for the next keystroke: the caret sits
8320        // behind the prefix, and what's typed there lands inside the quote as a
8321        // paragraph of its own — not as more of item `a`.
8322        doc.insert("x");
8323        assert_eq!(doc.source, "> - a\n>\n> x\n");
8324        assert!(
8325            doc.editor
8326                .ancestors_at(doc.caret - 1)
8327                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
8328        );
8329    }
8330
8331    #[test]
8332    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
8333        // The marker is hidden block markup, so Backspace over it is structural —
8334        // but only the marker is the list's. Splicing from the line start would
8335        // take the `>` with it and silently unquote the line.
8336        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
8337        doc.caret = "> - ".len();
8338        doc.backspace();
8339        assert_eq!(doc.source, "> a\n");
8340        assert_eq!(list_items(&mut doc), 0);
8341
8342        // A nested one outdents instead, moving the bullet within the quote
8343        // rather than moving the quote.
8344        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
8345        doc.caret = "> - a\n>   - ".len();
8346        doc.backspace();
8347        assert_eq!(doc.source, "> - a\n> - b\n");
8348        assert_eq!(list_items(&mut doc), 2);
8349    }
8350
8351    #[test]
8352    fn only_a_bare_paragraph_is_parted_around_the_caret() {
8353        // The split is deliberately narrow. Parting a fenced block would leave
8354        // two fences with a rule between them, and parting a list item would
8355        // mint an item nobody asked for on the way to a rule that lands after
8356        // the list either way — so both keep the whole block intact and take the
8357        // rule after it. A caret in a quote is likewise left alone.
8358        for (name, body, caret, want) in [
8359            (
8360                "code",
8361                "```\nfn x() {}\n```\n",
8362                8,
8363                "```\nfn x() {}\n```\n\n---\n",
8364            ),
8365            ("list", "- one two\n", 6, "- one two\n\n---\n"),
8366            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
8367        ] {
8368            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
8369            d.caret = caret;
8370            d.insert_thematic_break();
8371            assert_eq!(d.source, want, "{name}: the block should stay whole");
8372        }
8373    }
8374
8375    #[test]
8376    fn insert_thematic_break_replaces_the_selection() {
8377        // Now that the rule lands *at* the caret again, replacing the selection
8378        // is coherent once more: the text goes, and the rule takes its place.
8379        // The space the deletion left leading the second half is consumed by the
8380        // split rather than opening the new paragraph with it.
8381        let mut d = doc_with("hr_sel", "one two three\n");
8382        d.anchor = Some(4);
8383        d.caret = 7; // "two"
8384        d.insert_thematic_break();
8385        assert_eq!(d.source, "one \n\n---\n\nthree\n");
8386        assert_eq!(d.selection(), None);
8387    }
8388
8389    #[test]
8390    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
8391        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
8392        // because writing `---` into one is code, not a rule — twig now walks out
8393        // to the block that owns the caret's line, so there is nothing to refuse.
8394        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
8395        code.caret = 5; // inside the fenced code
8396        code.insert_thematic_break();
8397        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
8398        assert_eq!(code.status, None, "no refusal to report any more");
8399
8400        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
8401        table.caret = 3; // in the header row
8402        table.insert_thematic_break();
8403        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
8404    }
8405
8406    #[test]
8407    fn insert_thematic_break_in_a_list_item_ends_the_list() {
8408        // The un-indented rule cannot continue the list, so it closes the list
8409        // and lands at the top level rather than nested inside it.
8410        let mut d = doc_with("hr_list", "- one\n- two\n");
8411        d.caret = "- one\n- tw".len(); // mid "two"
8412        d.insert_thematic_break();
8413        d.build_visual(80);
8414        let rule_at = d.source.find("---").unwrap();
8415        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8416        assert!(
8417            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
8418                && n.span.start <= rule_at
8419                && rule_at < n.span.end),
8420            "the rule must not be nested inside the list"
8421        );
8422    }
8423
8424    #[test]
8425    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
8426        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
8427        // which is the document the gesture was actually asked for.
8428        let mut d = doc_with("hr_quote", "> hello\n");
8429        d.caret = 4; // inside the quoted text
8430        d.insert_thematic_break();
8431        assert_eq!(d.source, "> hello\n>\n> ---\n");
8432        d.build_visual(80);
8433        let rule_at = d.source.find("---").unwrap();
8434        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
8435        assert!(
8436            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
8437                && n.span.start <= rule_at
8438                && rule_at < n.span.end),
8439            "the rule belongs to the quote it was asked for"
8440        );
8441    }
8442
8443    // ── typing against a block picture ────────────────────────────────────────
8444
8445    /// A rendered-view document with the caret parked on one of the picture's two
8446    /// stops, and the map already built — the state a frontend is in between
8447    /// drawing a frame and the next keystroke.
8448    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
8449        let mut d = doc_in(View::Wysiwyg, name, src);
8450        d.build_visual_unwrapped();
8451        let start = src.find("![").unwrap();
8452        d.caret = match side {
8453            MediaStop::Before => start,
8454            MediaStop::After => start + "![](p.png)".len(),
8455        };
8456        d
8457    }
8458
8459    /// The block media the map publishes, after rebuilding it — "is this still a
8460    /// picture, or has it become a line of text with an image in it?"
8461    fn media_count(d: &mut Doc) -> usize {
8462        d.build_visual_unwrapped();
8463        d.vmap.media.len()
8464    }
8465
8466    #[test]
8467    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
8468        // The accident this prevents: tap the blank page under a photo (which
8469        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
8470        // paragraph with an *inline* image — the photo stops being drawn.
8471        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
8472        d.insert("xy");
8473        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
8474        assert_eq!(media_count(&mut d), 1, "still a picture");
8475    }
8476
8477    #[test]
8478    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
8479        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8480        d.insert("xy");
8481        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
8482        assert_eq!(media_count(&mut d), 1);
8483    }
8484
8485    #[test]
8486    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
8487        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
8488        d.insert("x");
8489        assert_eq!(d.source, "x\n\n![](p.png)\n");
8490        assert_eq!(media_count(&mut d), 1);
8491    }
8492
8493    #[test]
8494    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
8495        // The opened paragraph is part of the keystroke, not an edit the writer
8496        // made — so it undoes with the character, not a step later.
8497        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
8498        d.insert("x");
8499        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
8500        d.undo();
8501        assert_eq!(d.source, "hi\n\n![](p.png)\n");
8502    }
8503
8504    #[test]
8505    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
8506        // ⌘V dissolves the picture exactly as a keystroke does.
8507        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
8508        d.paste("pasted");
8509        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
8510        assert_eq!(media_count(&mut d), 1);
8511    }
8512
8513    #[test]
8514    fn typing_beside_an_inline_image_is_ordinary_editing() {
8515        // An inline image has no placeholder row and no stops of its own. Opening
8516        // a paragraph mid-sentence would be the bug, not the fix.
8517        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
8518        d.build_visual_unwrapped();
8519        d.caret = "see ![](p.png)".len();
8520        d.insert("!");
8521        assert_eq!(d.source, "see ![](p.png)! here\n");
8522    }
8523
8524    #[test]
8525    fn source_view_types_raw_markup_against_an_image_untouched() {
8526        // Source view is for writing the markup itself; a break inserted behind
8527        // the writer's back there would be the editor arguing with them.
8528        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
8529        d.caret = "![](p.png)".len();
8530        d.insert("x");
8531        assert_eq!(d.source, "![](p.png)x\n");
8532    }
8533
8534    #[test]
8535    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
8536        // A selection is replaced, not joined into, so there is nothing to
8537        // protect: the range takes the picture with it.
8538        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
8539        d.anchor = Some(d.caret);
8540        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
8541        d.insert("x");
8542        assert_eq!(d.source, "hi\n\nx\n");
8543    }
8544
8545    #[test]
8546    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
8547        // What this actually cost: a real vault's photo, to one stray Backspace.
8548        // The caret past `![](p.png)` was deleting the closing paren — invisible
8549        // in the rendered view — and the photo became the text `![](p.png`.
8550        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
8551        d.backspace();
8552        assert_eq!(d.source, "hi\n");
8553        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
8554        d.undo();
8555        assert_eq!(
8556            d.source, "hi\n\n![](p.png)\n",
8557            "and comes back in one piece"
8558        );
8559    }
8560
8561    #[test]
8562    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
8563        // Deleting the break here would join the picture to the paragraph above,
8564        // where it is an *inline* image and stops being drawn. Step over the
8565        // boundary; the next press deletes in the paragraph the caret reached.
8566        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
8567        d.backspace();
8568        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
8569        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
8570        d.backspace();
8571        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
8572        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
8573    }
8574
8575    #[test]
8576    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
8577        // The mirror. A byte-step here eats the `!` and leaves a link.
8578        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
8579        d.delete_forward();
8580        assert_eq!(d.source, "hi\n\nbye\n");
8581        assert_eq!(media_count(&mut d), 0);
8582    }
8583
8584    #[test]
8585    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
8586        let mut d = doc_at_picture(
8587            "pic_del_after",
8588            "hi\n\n![](p.png)\n\nbye\n",
8589            MediaStop::After,
8590        );
8591        d.delete_forward();
8592        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
8593        assert_eq!(
8594            d.caret,
8595            d.source.find("bye").unwrap(),
8596            "the caret stepped down to `bye`"
8597        );
8598    }
8599
8600    #[test]
8601    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
8602        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
8603        d.backspace();
8604        assert_eq!(d.source, "\n");
8605        assert_eq!(media_count(&mut d), 0);
8606    }
8607
8608    #[test]
8609    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
8610        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
8611        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
8612        d.delete_word_back();
8613        assert_eq!(d.source, "hi there\n");
8614
8615        // And in front of one it runs *through* the paragraph break into the
8616        // prose above, which merges the picture inline — so it steps out first,
8617        // and the second press deletes the word it was aimed at.
8618        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
8619        d.delete_word_back();
8620        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
8621        d.delete_word_back();
8622        assert_eq!(
8623            d.source, "hi \n\n![](p.png)\n",
8624            "the word above went, the picture stayed"
8625        );
8626        assert_eq!(media_count(&mut d), 1);
8627    }
8628
8629    #[test]
8630    fn source_view_deletes_raw_markup_against_an_image_untouched() {
8631        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
8632        d.caret = "![](p.png)".len();
8633        d.backspace();
8634        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
8635    }
8636
8637    #[test]
8638    fn image_destination_at_caret_reads_the_image_under_the_caret() {
8639        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
8640        d.caret = 3; // inside the image markup
8641        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
8642        // Past the image, the caret is in no image.
8643        d.caret = "![a cat](cat.png)".len();
8644        assert_eq!(d.image_destination_at_caret(), None);
8645    }
8646
8647    #[test]
8648    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
8649        // The image is one placeholder row by default, and `set_media_rows` grows
8650        // it to the height the frontend measured: the label row plus blank
8651        // `decoration` fillers that hold the vertical space a raster is drawn into.
8652        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
8653        assert_eq!(d.vmap.media.len(), 1);
8654        let img_row = d.vmap.media[0].rows_span.start;
8655        assert_eq!(
8656            d.vmap.media[0].rows_span,
8657            img_row..img_row + 1,
8658            "default is one row"
8659        );
8660
8661        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
8662        d.build_visual(80);
8663        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
8664        let span = d.vmap.media[0].rows_span.clone();
8665        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
8666        // The label row carries the mark and its glyphs; the three below are blank
8667        // decoration — drawn, but no caret and no text.
8668        assert!(
8669            d.vmap.rows[span.start].media.is_some(),
8670            "mark rides the first row"
8671        );
8672        for r in (span.start + 1)..span.end {
8673            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
8674            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
8675            assert!(
8676                d.vmap.rows[r].media.is_none(),
8677                "only the first row is marked"
8678            );
8679        }
8680    }
8681
8682    #[test]
8683    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
8684        // The extra rows are pure spacers: the caret's only homes stay the stop in
8685        // front of the image and the one just past it, so walking the document top
8686        // to bottom visits the same offsets whether the image is 1 row or 5.
8687        let body = "ab\n\n![x](p.png)\n\ncd\n";
8688        let stops_at = |rows: usize| -> Vec<usize> {
8689            let mut d = wysiwyg_doc("img_stops", body);
8690            if rows > 1 {
8691                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
8692                d.build_visual(80);
8693            }
8694            d.caret = 0;
8695            let mut seen = vec![d.caret];
8696            loop {
8697                d.move_right(false);
8698                if *seen.last().unwrap() == d.caret {
8699                    break;
8700                }
8701                seen.push(d.caret);
8702            }
8703            seen
8704        };
8705        assert_eq!(
8706            stops_at(1),
8707            stops_at(5),
8708            "reserving rows must not add stops"
8709        );
8710    }
8711
8712    #[test]
8713    fn insert_link_repoints_the_link_at_a_bare_caret() {
8714        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
8715        d.caret = 3; // in the link's text, nothing selected
8716        d.insert_link("http://y.dev");
8717        assert_eq!(d.source, "[word](http://y.dev)\n");
8718        assert_eq!(d.selected_text(), Some("word"));
8719    }
8720
8721    #[test]
8722    fn insert_link_on_an_empty_range_autolinks_a_url() {
8723        // A link with no text of its own is an autolink, and twig spells it —
8724        // `<…>` is the canonical form and needs no text typed into it, so the
8725        // caret lands after it rather than selecting a finished link.
8726        let mut d = doc_with("link_empty", "\n");
8727        d.caret = 0;
8728        d.insert_link("http://x.dev");
8729        assert_eq!(d.source, "<http://x.dev>\n");
8730        assert_eq!(d.selection(), None);
8731        assert_eq!(d.caret, 14);
8732    }
8733
8734    #[test]
8735    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
8736        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
8737        // in Markdown, so a destination that can't autolink doubles as the text
8738        // instead — which is then selected, ready to be typed over.
8739        let mut d = doc_with("link_rel", "\n");
8740        d.caret = 0;
8741        d.insert_link("./notes.md");
8742        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
8743        assert_eq!(d.selection(), Some((1, 11)));
8744        d.insert("Notes");
8745        assert_eq!(d.source, "[Notes](./notes.md)\n");
8746    }
8747
8748    #[test]
8749    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
8750        // The autolink's text is its URL, so re-pointing replaces the whole
8751        // node — the caret must not splice a second link inside the first.
8752        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
8753        d.caret = 10;
8754        d.insert_link("https://y.dev");
8755        assert_eq!(d.source, "see <https://y.dev> ok\n");
8756    }
8757
8758    #[test]
8759    fn code_language_reads_and_edits_through_the_fence() {
8760        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
8761        d.caret = 10; // inside the code body
8762        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8763        assert!(d.caret_in_fenced_code());
8764
8765        d.set_code_language("python");
8766        assert!(
8767            d.source.starts_with("```python\n"),
8768            "source: {:?}",
8769            d.source
8770        );
8771        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
8772
8773        // Clearing it leaves a bare fence and no label.
8774        d.set_code_language("");
8775        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
8776        assert_eq!(d.code_language_at_caret(), None);
8777
8778        // A caret outside any code block edits nothing.
8779        let mut p = doc_with("code_lang_none", "just prose\n");
8780        assert!(!p.caret_in_fenced_code());
8781        p.set_code_language("rust");
8782        assert_eq!(p.source, "just prose\n");
8783    }
8784
8785    #[test]
8786    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
8787        // Markdown's info string ends at whitespace, so `two words` would write
8788        // a fence that reads back with a different language than the one asked
8789        // for. twig refuses it; leaf reports that and leaves the source alone.
8790        // The old splice trimmed the ends and wrote whatever was left.
8791        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
8792        d.caret = 10;
8793        d.set_code_language("two words");
8794        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
8795        assert!(d.status.is_some(), "the refusal should be reported");
8796        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
8797    }
8798
8799    #[test]
8800    fn link_destination_at_caret_reads_both_spellings() {
8801        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
8802        d.caret = 5;
8803        assert_eq!(
8804            d.link_destination_at_caret().as_deref(),
8805            Some("https://x.dev")
8806        );
8807        d.caret = 0;
8808        assert_eq!(d.link_destination_at_caret(), None);
8809
8810        // An autolink has no `destination`; its text is the URL.
8811        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
8812        a.caret = 10;
8813        assert_eq!(
8814            a.link_destination_at_caret().as_deref(),
8815            Some("https://x.dev")
8816        );
8817        a.caret = 21;
8818        assert_eq!(a.link_destination_at_caret(), None);
8819    }
8820
8821    #[test]
8822    fn locate_finds_the_block_a_declared_id_names() {
8823        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
8824        // verse. The locator has to land on the *verse*, which is the whole
8825        // reason a link carries one.
8826        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
8827                   {#v2}\nYea, I make a record in the language of my father.\n";
8828        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8829        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
8830        assert_eq!(
8831            d.source[v2.start..v2.end].trim_end(),
8832            "Yea, I make a record in the language of my father."
8833        );
8834        // The attribute line is not part of it: `start` is a place to put a
8835        // caret, and `{#v2}` is markup the caret has no business landing in.
8836        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
8837        assert_eq!(d.locate("v99"), None);
8838    }
8839
8840    #[test]
8841    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
8842        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
8843        // Markdown heading is literal text. So `#the-second-part` can only be
8844        // the heading's own words, which is the rule every Markdown renderer
8845        // already follows and therefore the one a link was authored against.
8846        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
8847        let mut d = doc_with("locate_md", src);
8848        let hit = d.locate("the-second-part").expect("the heading's slug");
8849        assert!(d.source[hit.start..].starts_with("## The Second Part"));
8850        // Bounded by the next heading that isn't under it, so a peek shows the
8851        // section rather than only its title.
8852        assert_eq!(
8853            &d.source[hit.start..hit.end],
8854            "## The Second Part\n\nbody\n\n"
8855        );
8856
8857        // A subsection does not end its parent: `# Title` runs to `## Third`'s
8858        // sibling only because there is no other `#`, so it covers the lot.
8859        let title = d.locate("title").expect("the top heading");
8860        assert_eq!(title.end, d.source.len());
8861    }
8862
8863    #[test]
8864    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
8865        // djot mints `Some-Heading-Here`; a link to it is written
8866        // `#some-heading-here` by nearly everything that writes links. Both
8867        // spellings are one question.
8868        let src = "## Some Heading Here\n\nbody\n";
8869        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8870        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
8871        let slugged = d.locate("some-heading-here").expect("the link's spelling");
8872        assert_eq!(exact, slugged);
8873        // The section, not the heading line — there is more to show than a title.
8874        assert_eq!(&d.source[exact.start..exact.end], src);
8875    }
8876
8877    #[test]
8878    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
8879        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
8880        assert_eq!(d.locate(""), None);
8881        assert_eq!(d.locate("   "), None);
8882        // All punctuation: it names nothing, and must not be read as "match the
8883        // first heading whose slug is also empty".
8884        assert_eq!(d.locate("!!!"), None);
8885    }
8886
8887    #[test]
8888    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
8889        // The document's mistake, and the answer every other anchor
8890        // implementation gives — the alternative is for a link to mean whichever
8891        // of the two a walk happened to reach first.
8892        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
8893        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
8894        let hit = d.locate("dup").expect("the first `{#dup}`");
8895        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
8896    }
8897
8898    #[test]
8899    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
8900        // The button's whole job: a reference where the caret was, a definition
8901        // to give it meaning, and the caret waiting in the empty note so the
8902        // next keystroke is the note's first word.
8903        let mut d = doc_with("fn_insert", "A claim and more.\n");
8904        d.caret = 7; // just past "A claim"
8905        d.insert_footnote();
8906        assert!(
8907            d.source.starts_with("A claim[^1] and more."),
8908            "{:?}",
8909            d.source
8910        );
8911        assert!(
8912            d.source.contains("[^1]:"),
8913            "the definition too: {:?}",
8914            d.source
8915        );
8916        assert_eq!(d.status, None);
8917
8918        let reference = d.source.find("[^1]").unwrap();
8919        let note = d
8920            .footnote_at(reference + 2)
8921            .expect("the reference just written");
8922        assert_eq!(note.label, "1");
8923        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
8924        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
8925        // …and typing there is typing into the note, not near it.
8926        d.insert("the note");
8927        assert_eq!(
8928            d.footnote_at(reference + 2).and_then(|f| f.text),
8929            Some("the note".to_string())
8930        );
8931    }
8932
8933    #[test]
8934    fn insert_footnote_numbers_past_the_notes_already_written() {
8935        // A second press must not hand back a label somebody else is using: twig
8936        // reuses a defined label rather than appending a rival definition, so a
8937        // repeat of `1` would quietly point the new reference at the old note.
8938        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
8939        d.caret = 7; // past `[^1]`, before " two."
8940        d.insert_footnote();
8941        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
8942        assert_eq!(d.source.matches("[^2]:").count(), 1);
8943    }
8944
8945    #[test]
8946    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
8947        // `[^2]` with no definition is still a 2 that means something to whoever
8948        // wrote it — stepping over it would mint a note for their reference. A
8949        // word label takes no number, so it blocks none.
8950        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
8951        d.caret = d.source.find(" c").unwrap();
8952        d.insert_footnote();
8953        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
8954        assert!(
8955            d.source.starts_with("a[^2] b[^why][^1] c"),
8956            "{:?}",
8957            d.source
8958        );
8959    }
8960
8961    #[test]
8962    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
8963        // A reference annotates the words before it. Consuming the selection —
8964        // which is what an insert normally does — would delete the very claim
8965        // the author selected in order to footnote.
8966        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
8967        d.anchor = Some(2);
8968        d.caret = 7; // "claim" selected
8969        d.insert_footnote();
8970        assert!(
8971            d.source.starts_with("A claim[^1] and more."),
8972            "{:?}",
8973            d.source
8974        );
8975    }
8976
8977    #[test]
8978    fn a_note_just_written_still_knows_where_its_reference_is() {
8979        // The authoring loop in one test: press the button, type the note, ask to
8980        // go back. The caret ends at the note's last byte — which is the *end* of
8981        // the definition's span, the one offset the query used to exclude — so
8982        // this is where the round trip either works or doesn't.
8983        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
8984        d.caret = 7;
8985        d.insert_footnote();
8986        d.insert("the note");
8987        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
8988        let back = d
8989            .footnote_definition_at_caret()
8990            .expect("still in the note we just typed");
8991        assert_eq!(back.label, "1");
8992        // …and following it lands on the reference's label, where a reader's
8993        // return leg lands.
8994        assert_eq!(back.offset, Some(9));
8995        assert_eq!(&d.source[9..10], "1");
8996    }
8997
8998    #[test]
8999    fn insert_footnote_takes_one_undo_for_both_halves() {
9000        // twig writes the pair as a single edit; the point of that is here.
9001        let before = "A claim and more.\n";
9002        let mut d = doc_with("fn_insert_undo", before);
9003        d.caret = 7;
9004        d.insert_footnote();
9005        assert_ne!(d.source, before);
9006        d.undo();
9007        assert_eq!(d.source, before, "one undo takes back both halves");
9008    }
9009
9010    #[test]
9011    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
9012        // HTML is authorable — it spells the inline marks — and has no footnote.
9013        // The refusal says so rather than writing brackets that would render as
9014        // brackets.
9015        let src = "<p>A claim.</p>\n";
9016        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
9017        assert!(!Capabilities::of(Format::Html).footnote);
9018        d.caret = 5;
9019        d.insert_footnote();
9020        assert_eq!(d.source, src, "nothing written");
9021        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
9022    }
9023
9024    #[test]
9025    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
9026        // The empty body is the one place this could go wrong: the definition
9027        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
9028        // byte early would draw up in the paragraph above the note it belongs to.
9029        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
9030        d.place_caret(7, false);
9031        d.insert_footnote();
9032        d.build_visual(80); // the frame a frontend draws after the edit
9033        assert_eq!(
9034            d.vmap.snap_to_stop(d.caret),
9035            d.caret,
9036            "the caret sits on a stop"
9037        );
9038        let (row, _) = d.caret_pos();
9039        assert!(
9040            drawn_rows(&d)[row].contains("[1]"),
9041            "the caret is on the note's row, not above it: {:?}",
9042            drawn_rows(&d)
9043        );
9044    }
9045
9046    #[test]
9047    fn footnote_at_caret_resolves_a_reference_to_its_note() {
9048        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
9049        // blank line, as one has to.
9050        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
9051        d.caret = 9;
9052        let f = d
9053            .footnote_at_caret()
9054            .expect("the caret stands in a reference");
9055        assert_eq!(f.label, "1");
9056        assert_eq!(f.text.as_deref(), Some("the note"));
9057        // The offset points at the note's first word, not at the definition's
9058        // `[` — the marker is decoration with no caret stop on it.
9059        assert_eq!(f.offset, Some(29));
9060        assert_eq!(&d.source[29..37], "the note");
9061        // …and `end` closes the range, so a frontend can ask which rendered rows
9062        // the note occupies rather than re-deriving them from the text.
9063        assert_eq!(f.end, Some(37));
9064        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
9065    }
9066
9067    /// Two definitions in a row: each is its own note, and neither reaches into
9068    /// the other.
9069    ///
9070    /// A djot definition's span used to run past the blank line into the first
9071    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
9072    /// offsets named the *next* note's rows too, showing a reader two footnotes
9073    /// when they had asked about one. twig 3.1 ends the span after the block's
9074    /// own last line; the test outlives the workaround leaf carried for it.
9075    #[test]
9076    fn footnote_at_stops_a_note_at_the_definition_after_it() {
9077        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
9078        for format in [Format::Markdown, Format::Djot] {
9079            let mut d = Doc::from_source(src.to_string(), format).unwrap();
9080            d.caret = 7;
9081            let f = d.footnote_at_caret().expect("a reference");
9082            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
9083            assert_eq!(
9084                &src[f.offset.unwrap()..f.end.unwrap()],
9085                "first note.",
9086                "in {format:?}"
9087            );
9088        }
9089    }
9090
9091    /// The other side of that boundary: a blank line *inside* a definition is
9092    /// interior to it, and the note keeps its second paragraph.
9093    ///
9094    /// This is what the old body scan cost. It stopped at the first line not
9095    /// indented under the note — a blank line is not — so a two-paragraph note
9096    /// came back as its first paragraph, and "go to note" framed half of it.
9097    /// Reading the span twig gives is both simpler and right.
9098    #[test]
9099    fn footnote_at_keeps_a_notes_second_paragraph() {
9100        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
9101        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
9102        d.caret = 7;
9103        let f = d.footnote_at_caret().expect("a reference");
9104        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
9105        // And it stops there — `After.` is the next block, not more note.
9106        assert_eq!(
9107            &src[f.offset.unwrap()..f.end.unwrap()],
9108            f.text.as_deref().unwrap()
9109        );
9110        assert!(!f.text.as_deref().unwrap().contains("After"));
9111    }
9112
9113    #[test]
9114    fn footnote_at_bounds_a_note_whose_body_is_empty() {
9115        // `[^1]:` with nothing after it. The range is empty rather than
9116        // inverted, and still points inside the definition — which is what keeps
9117        // a frontend's row lookup from walking off into the block above.
9118        let src = "A claim[^1].\n\n[^1]:\n";
9119        let mut d = doc_with("fn_empty_body", src);
9120        d.caret = 9;
9121        let f = d.footnote_at_caret().expect("a reference");
9122        assert_eq!(f.text.as_deref(), Some(""));
9123        assert_eq!(f.offset, f.end, "an empty note is an empty range");
9124        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
9125    }
9126
9127    #[test]
9128    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
9129        let mut d = doc_with(
9130            "fn_at_caret_none",
9131            "A claim[^1] and more.\n\n[^1]: the note\n",
9132        );
9133        d.caret = 2; // in the prose
9134        assert_eq!(d.footnote_at_caret(), None);
9135    }
9136
9137    #[test]
9138    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
9139        // The two are deliberately separate: a reference names a note in this
9140        // document, a link names somewhere to leave for, and answering one with
9141        // the other is what made a reference click do nothing at all.
9142        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
9143        d.caret = 3; // the `1` of `[^1]`
9144        assert!(d.footnote_at_caret().is_some());
9145        assert_eq!(
9146            d.link_destination_at_caret(),
9147            None,
9148            "a reference is not a link"
9149        );
9150
9151        d.caret = 10; // inside the link's label
9152        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
9153        assert_eq!(
9154            d.link_destination_at_caret().as_deref(),
9155            Some("https://x.dev")
9156        );
9157    }
9158
9159    #[test]
9160    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
9161        // A `[^99]` the document never defines is a real state — a note deleted
9162        // out from under its reference — and the label is what lets a frontend
9163        // say so. `None` here would be indistinguishable from "not on a
9164        // reference", which is the wrong thing to tell a reader.
9165        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
9166        d.caret = 9;
9167        let f = d
9168            .footnote_at_caret()
9169            .expect("the reference is still a reference");
9170        assert_eq!(f.label, "99");
9171        assert_eq!(f.text, None);
9172        assert_eq!(f.offset, None);
9173    }
9174
9175    #[test]
9176    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
9177        // Labels are not always numbers, and a note's body runs past its first
9178        // line — the indented continuation belongs to the note, so it comes back
9179        // with it (source bytes, verbatim, as documented).
9180        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
9181        let mut d = doc_with("fn_word_label", src);
9182        d.caret = 6;
9183        let f = d
9184            .footnote_at_caret()
9185            .expect("the caret stands in a reference");
9186        assert_eq!(f.label, "note");
9187        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
9188    }
9189
9190    #[test]
9191    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
9192        // The point of the offset form: a pointer hovering a reference asks what
9193        // note it names, and must not drag the caret along to ask.
9194        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
9195        d.caret = 0;
9196        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
9197        assert_eq!(f.label, "1");
9198        assert_eq!(f.text.as_deref(), Some("the note"));
9199        assert_eq!(d.caret, 0, "asking must not move the caret");
9200        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
9201    }
9202
9203    #[test]
9204    fn footnote_definition_at_caret_points_back_at_the_reference() {
9205        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
9206        // the caret can rest on — is at 9.
9207        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
9208        d.caret = 30; // inside the note's body
9209        let f = d
9210            .footnote_definition_at_caret()
9211            .expect("the caret stands in a definition");
9212        assert_eq!(f.label, "1");
9213        assert_eq!(f.offset, Some(9));
9214        assert_eq!(&d.source[7..11], "[^1]");
9215    }
9216
9217    #[test]
9218    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
9219        // The two legs have to meet: wherever `footnote_at` sends the caret, the
9220        // definition query must answer for — otherwise arriving at a note leaves
9221        // the reader somewhere the way back isn't offered.
9222        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
9223        let mut d = doc_with("fn_def_marker", src);
9224        let landed = d.footnote_at(9).unwrap().offset.unwrap();
9225        assert_eq!(
9226            d.footnote_definition_at(landed).and_then(|f| f.offset),
9227            Some(9),
9228            "the note a reference sends you to offers the way back"
9229        );
9230    }
9231
9232    #[test]
9233    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
9234        // The two queries answer for disjoint places, which is what lets one
9235        // gesture mean "down to the note" in one and "back up" in the other
9236        // without either having to remember which way the reader is going.
9237        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
9238        d.caret = 2; // prose
9239        assert_eq!(d.footnote_definition_at_caret(), None);
9240        d.caret = 9; // the reference
9241        assert_eq!(d.footnote_definition_at_caret(), None);
9242        assert!(
9243            d.footnote_at_caret().is_some(),
9244            "which is the reference's own query"
9245        );
9246    }
9247
9248    #[test]
9249    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
9250        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
9251        // note", which is false and leaves a frontend unable to explain why the
9252        // way back is missing.
9253        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
9254        let mut d = doc_with("fn_def_orphan", src);
9255        d.caret = src.find("orphan").unwrap();
9256        let f = d
9257            .footnote_definition_at_caret()
9258            .expect("an orphan is still a definition");
9259        assert_eq!(f.label, "2");
9260        assert_eq!(f.offset, None);
9261    }
9262
9263    #[test]
9264    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
9265        // One label, cited twice. The first is where the reader most likely came
9266        // from, and the only answer that doesn't depend on how they got here.
9267        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
9268        let mut d = doc_with("fn_def_repeat", src);
9269        d.caret = src.find("the note").unwrap();
9270        let f = d.footnote_definition_at_caret().expect("a definition");
9271        assert_eq!(
9272            f.offset,
9273            Some(5),
9274            "the first `[^a]`'s label, not the second's"
9275        );
9276        assert_eq!(&src[3..7], "[^a]");
9277    }
9278
9279    #[test]
9280    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
9281        // Down and back up, each leg found from the document rather than from a
9282        // memory of the other — so it still works for a reader who scrolled to
9283        // the notes instead of jumping there.
9284        //
9285        // `place_caret` rather than assigning `caret`, because that is what a
9286        // frontend calls: it snaps to a real caret stop, and a jump that lands
9287        // on a byte the caret can't rest on would arrive somewhere the return
9288        // leg no longer answers for. `build_map` first, since snapping is a
9289        // no-op until the map exists — which is exactly how this went unnoticed
9290        // when the offsets pointed at the `[^` markers.
9291        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
9292        d.build_map(None);
9293        d.place_caret(9, false);
9294        let down = d
9295            .footnote_at_caret()
9296            .expect("a reference")
9297            .offset
9298            .expect("a note");
9299        d.place_caret(down, false);
9300        let up = d
9301            .footnote_definition_at_caret()
9302            .expect("a definition")
9303            .offset
9304            .expect("a reference");
9305        d.place_caret(up, false);
9306        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
9307        assert_eq!(
9308            d.footnote_at_caret().expect("back on the reference").label,
9309            "1"
9310        );
9311    }
9312
9313    #[test]
9314    fn insert_link_hands_the_destination_to_twig_raw() {
9315        // Escaping is twig's, and format-specific: Markdown ends a destination
9316        // at the first space and needs the `<…>` form, where djot would read
9317        // those angle brackets as part of the URL.
9318        let mut d = doc_with("link_space", "word\n");
9319        d.anchor = Some(0);
9320        d.caret = 4;
9321        d.insert_link("a b");
9322        assert_eq!(d.source, "[word](<a b>)\n");
9323    }
9324
9325    #[test]
9326    fn insert_link_reports_a_destination_no_format_can_carry() {
9327        let mut d = doc_with("link_bad", "word\n");
9328        d.anchor = Some(0);
9329        d.caret = 4;
9330        d.insert_link("a\nb");
9331        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
9332        assert!(
9333            d.status.is_some(),
9334            "InvalidArgument should reach the status line"
9335        );
9336        assert!(!d.dirty);
9337    }
9338
9339    #[test]
9340    fn insert_link_works_in_wysiwyg_view() {
9341        let mut d = wysiwyg_doc("link_wys", "word here\n");
9342        d.anchor = Some(0);
9343        d.caret = 4;
9344        d.insert_link("http://x.dev");
9345        assert_eq!(d.source, "[word](http://x.dev) here\n");
9346        assert_eq!(d.selected_text(), Some("word"));
9347        // The map the caret has to keep riding is rebuilt each frame; motion
9348        // over the fresh one must still land on a real stop (the debug_assert).
9349        d.build_visual(80);
9350        d.move_right(false);
9351        d.move_left(false);
9352    }
9353
9354    #[test]
9355    fn click_maps_a_row_col_to_a_byte_offset() {
9356        let mut d = doc_with("click", "ab\ncd\n");
9357        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
9358        assert_eq!(d.caret, 4);
9359    }
9360
9361    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
9362    // stop just as the `(row, col)` click path does, so the caret can never come
9363    // to rest in the blank gap between two paragraphs — where it would draw in one
9364    // place and type in another.
9365    #[test]
9366    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
9367        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
9368        // caret stop (stops are 0,1,3,4).
9369        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
9370        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
9371        d.place_caret(2, false);
9372        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9373        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
9374    }
9375
9376    #[test]
9377    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
9378        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
9379        d.place_caret(0, false); // anchor at the start of "A"
9380        d.place_caret(2, true); // drag into the gap
9381        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
9382        let (s, e) = d.selection().expect("a selection");
9383        assert!(
9384            d.vmap.is_stop(s) && d.vmap.is_stop(e),
9385            "selection {s}..{e} off a stop"
9386        );
9387    }
9388
9389    #[test]
9390    fn place_caret_on_a_real_stop_is_left_untouched() {
9391        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
9392        d.place_caret(3, false); // the start of "B" — a genuine stop
9393        assert_eq!(d.caret, 3);
9394    }
9395
9396    // An *empty paragraph* (two blank lines, an intentional blank line the user
9397    // opened) is a real caret stop, unlike the gap — a click into it must stay.
9398    #[test]
9399    fn place_caret_rests_in_an_empty_paragraph() {
9400        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
9401        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
9402        assert!(d.vmap.is_stop(empty));
9403        d.place_caret(empty, false);
9404        assert_eq!(d.caret, empty);
9405    }
9406
9407    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
9408        doc_in(View::Wysiwyg, name, body)
9409    }
9410
9411    /// How many list items the source actually parses into — the check that a
9412    /// marker Leaf wrote is a marker the format agrees is one.
9413    fn list_items(doc: &mut Doc) -> usize {
9414        doc.editor
9415            .nodes()
9416            .unwrap()
9417            .iter()
9418            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
9419            .count()
9420    }
9421
9422    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
9423    /// incremental (`build_spliced` / `build_cached`) path must always match.
9424    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
9425        reference_map_revealing(source, None)
9426    }
9427
9428    /// [`reference_map`] with a reveal line — the ground truth for the
9429    /// `MarkupMode::Full` builds, where the map is a function of the caret's
9430    /// line as well as the text.
9431    fn reference_map_revealing(
9432        source: &str,
9433        reveal: Option<Range<usize>>,
9434    ) -> crate::wysiwyg::VisualMap {
9435        // The same parse `Doc` uses. With twig's plain defaults instead, the two
9436        // sides disagree on what the *document* is before the renderer is even
9437        // reached — a bare `:word` is a text directive to one and prose to the
9438        // other — and the mismatch reads as a splice bug that isn't one.
9439        let mut ed =
9440            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
9441        let nodes = ed.nodes().unwrap();
9442        crate::wysiwyg::build(
9443            &nodes,
9444            source,
9445            None,
9446            false,
9447            &std::collections::HashMap::new(),
9448            reveal,
9449        )
9450    }
9451
9452    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
9453        if a.rows.len() != b.rows.len() {
9454            return true;
9455        }
9456        for (ra, rb) in a.rows.iter().zip(&b.rows) {
9457            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
9458                return true;
9459            }
9460            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
9461                if ga.ch != gb.ch || ga.src != gb.src {
9462                    return true;
9463                }
9464            }
9465        }
9466        false
9467    }
9468
9469    #[test]
9470    fn incremental_build_matches_a_fresh_build_across_edits() {
9471        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
9472        // fast path, gated on twig's `dirty_range`) or falls back to
9473        // `build_cached`. After each edit the map must be byte-identical to a
9474        // from-scratch build — this is the correctness net under the splice.
9475        let docs = [
9476            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
9477            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
9478            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
9479            // A footnote definition is a root beside `doc`, merged back into the
9480            // top-level list by `wysiwyg::top_blocks`. The random edits below
9481            // make and unmake definitions as they go (a deleted `:` turns one
9482            // back into a paragraph, and vice versa), which is exactly the
9483            // structural churn the splice path has to notice and bail out of.
9484            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
9485        ];
9486        // A deterministic mix: mostly single characters (which stay inside one
9487        // block → splice), plus edits that reshape structure (a paragraph break,
9488        // a heading marker, a code fence → fallback), so both paths are exercised.
9489        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
9490        for src in docs {
9491            let mut d = wysiwyg_doc("diff", src);
9492            d.build_visual_unwrapped();
9493            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
9494
9495            for step in 0..60usize {
9496                let len = d.source.len();
9497                let raw = (step * 13 + 5) % (len + 1);
9498                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9499                let pre = d.source.clone();
9500                let action;
9501                if step % 3 == 0 && pos < len {
9502                    let end = (pos + 1..=len)
9503                        .find(|&i| d.source.is_char_boundary(i))
9504                        .unwrap();
9505                    action = format!("delete [{pos},{end})");
9506                    d.edit(pos, end, "");
9507                } else {
9508                    let ins = inserts[step % inserts.len()];
9509                    action = format!("insert {ins:?} @ {pos}");
9510                    d.edit(pos, pos, ins);
9511                }
9512                d.build_visual_unwrapped();
9513                if maps_differ(&d.vmap, &reference_map(&d.source)) {
9514                    panic!(
9515                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
9516                        d.source
9517                    );
9518                }
9519            }
9520        }
9521    }
9522
9523    /// A frontend is handed [`Doc::vmap`] and may present it differently:
9524    /// leaf-ratatui splices blank filler rows under an oversized heading so the
9525    /// raster it paints there has somewhere to stand, and leaves them in the map
9526    /// because the caret and the mouse both read it between frames. The splice
9527    /// path addresses that map by *row index*, against the block layout the last
9528    /// build recorded — so handed a map with rows in it that no block owns, it
9529    /// laid the re-rendered block over one of the fillers and carried the rows
9530    /// the block really occupied into the suffix. One stranded copy of the
9531    /// edited line, and everything below it a row further down, per keystroke.
9532    ///
9533    /// A map that isn't the one the layout describes is a map this path can't
9534    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
9535    #[test]
9536    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
9537        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
9538        d.build_visual_unwrapped();
9539
9540        // Stand in for the heading filler rows: two blank rows past the heading
9541        // that no block accounts for. Cloning a real row keeps every field
9542        // plausible — it is the row *count* the splice can't survive.
9543        let filler = d.vmap.rows[0].clone();
9544        d.vmap.rows.insert(1, filler.clone());
9545        d.vmap.rows.insert(1, filler);
9546
9547        // An edit inside the last block: the single-block case the splice path
9548        // is for, and the one the frontend hits on every keystroke.
9549        let at = d.source.len() - 1;
9550        d.edit(at, at, "!");
9551        d.build_visual_unwrapped();
9552
9553        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
9554    }
9555
9556    #[test]
9557    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
9558        // The same correctness net as `incremental_build_matches_a_fresh_build_
9559        // across_edits`, under `MarkupMode::Full` — where the map depends on
9560        // the caret's *line* as well as the text, so the two caches have a new
9561        // way to be wrong. Both are exercised: the block cache can hand back
9562        // rows built for a line that is no longer the revealed one, and the
9563        // splice path can reuse a suffix that still has yesterday's line raw.
9564        //
9565        // Caret motion is interleaved with the edits deliberately, because a
9566        // caret that only ever moved with the edit would never cross a line
9567        // without also dirtying it — the case where a stale reveal survives.
9568        let docs = [
9569            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
9570            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
9571        ];
9572        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
9573        for src in docs {
9574            let mut d = wysiwyg_doc("reveal_diff", src);
9575            d.set_markup_mode(MarkupMode::Full);
9576
9577            for step in 0..60usize {
9578                let len = d.source.len();
9579                let raw = (step * 13 + 5) % (len + 1);
9580                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
9581                let pre = d.source.clone();
9582                let action;
9583                if step % 3 == 0 && pos < len {
9584                    let end = (pos + 1..=len)
9585                        .find(|&i| d.source.is_char_boundary(i))
9586                        .unwrap();
9587                    action = format!("delete [{pos},{end})");
9588                    d.edit(pos, end, "");
9589                } else {
9590                    let ins = inserts[step % inserts.len()];
9591                    action = format!("insert {ins:?} @ {pos}");
9592                    d.edit(pos, pos, ins);
9593                }
9594                // Walk the caret somewhere else in the document, independently
9595                // of where the edit landed.
9596                let want = (step * 29 + 11) % (d.source.len() + 1);
9597                d.caret = (want..=d.source.len())
9598                    .find(|&i| d.source.is_char_boundary(i))
9599                    .unwrap();
9600                d.build_visual_unwrapped();
9601
9602                let want = reference_map_revealing(&d.source, d.reveal_line());
9603                if maps_differ(&d.vmap, &want) {
9604                    panic!(
9605                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
9606                        d.caret, d.source
9607                    );
9608                }
9609            }
9610        }
9611    }
9612
9613    #[test]
9614    fn caret_motion_across_lines_rebuilds_only_under_full() {
9615        // The cache-key change has to earn its keep in both directions: `Full`
9616        // must rebuild when the caret changes line (or the reveal would never
9617        // move), and the hidden modes must *not* (or every arrow key would pay
9618        // for a feature they don't use). The existing `cache_motion` test pins
9619        // the second for the default mode; this pins the pair against a mode
9620        // change alone.
9621        let body = "*one* here\n\n*two* there\n";
9622
9623        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
9624        full.set_markup_mode(MarkupMode::Full);
9625        caret_at(&mut full, "one");
9626        let before = full.revision();
9627        caret_at(&mut full, "two");
9628        assert_eq!(full.revision(), before, "motion is not an edit");
9629        assert!(
9630            drawn_rows(&full).iter().any(|r| r == "*two* there"),
9631            "the map followed the caret: {:?}",
9632            drawn_rows(&full)
9633        );
9634
9635        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
9636        caret_at(&mut hidden, "one");
9637        let key = hidden.vmap_key.clone();
9638        caret_at(&mut hidden, "two");
9639        assert_eq!(
9640            hidden.vmap_key, key,
9641            "a hidden mode rebuilds nothing on motion"
9642        );
9643    }
9644
9645    #[test]
9646    fn wysiwyg_down_crosses_a_paragraph_boundary() {
9647        // Regression: the blank separator row used to share the previous
9648        // paragraph's end offset, so Down got pinned at the boundary (while Up
9649        // still crossed). Both directions must step through it symmetrically.
9650        //
9651        // It's now stepped *over* rather than onto: the blank line between two
9652        // paragraphs is the boundary being drawn, not a line of the document, so
9653        // one press of Down crosses it. The goal column survives the crossing —
9654        // col 3 at the end of "abc" is col 3 at the end of "def".
9655        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
9656        d.caret = 3; // end of "abc" (row 0)
9657        d.move_down(false);
9658        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
9659        assert_eq!(d.caret, 8); // end of "def", col 3 kept
9660        d.move_up(false);
9661        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
9662        assert_eq!(d.caret, 3);
9663    }
9664
9665    #[test]
9666    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
9667        // The second Up and the second Down here run off the ends of the
9668        // document, which is no longer a place a press is swallowed: they carry
9669        // the caret to the start and the end of the text. The claim in the
9670        // middle — that a Down retraces the Up that crossed the paragraph gap —
9671        // is the one this test is for, and it is asserted where it is made.
9672        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
9673        d.caret = 5; // start of "def"
9674        let start = d.caret_pos();
9675        d.move_up(false);
9676        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
9677        d.move_up(false);
9678        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
9679        d.move_down(false);
9680        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
9681        d.move_down(false);
9682        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
9683    }
9684
9685    #[test]
9686    fn wysiwyg_new_paragraph_shows_before_typing() {
9687        // Regression: two Enters at the end of a paragraph produced trailing
9688        // newlines with no AST node, so the caret appeared stuck on the old line
9689        // until a character was typed. It must ride down onto the new line now.
9690        let mut d = doc_with("wys_newpara", "abc\n");
9691        d.view = View::Wysiwyg;
9692        d.caret = 3;
9693        d.insert("\n");
9694        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
9695        assert_eq!(d.source, "abc\n\n\n");
9696        d.build_visual(80);
9697        let (row, _) = d.caret_pos();
9698        assert!(
9699            row >= 2,
9700            "caret should have moved down to the new line, got row {row}"
9701        );
9702        assert!(
9703            d.vmap.num_rows() >= 3,
9704            "the blank lines should render as rows"
9705        );
9706    }
9707
9708    #[test]
9709    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
9710        // The reported bug: Enter at the end of a paragraph that has another
9711        // paragraph below put the caret at the *start of the next paragraph* —
9712        // the empty paragraph it opened had no row, so the caret snapped onto
9713        // "World". It must now sit on its own empty line, with a blank spacer
9714        // above it (the paragraph gap).
9715        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
9716        d.caret = 5; // end of "Hello"
9717        d.newline();
9718        d.build_visual(80);
9719        let (row, col) = d.caret_pos();
9720        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
9721        assert_eq!(
9722            d.vmap.row_width(row),
9723            0,
9724            "caret's row must be empty, not 'World'"
9725        );
9726        assert!(
9727            row >= 2,
9728            "a blank spacer row should sit above the caret, got row {row}"
9729        );
9730        // The row above the caret is a real (empty) gap, and "Hello" stays put.
9731        assert_eq!(
9732            d.vmap.row_width(row - 1),
9733            0,
9734            "the row above the caret is a gap"
9735        );
9736        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9737        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
9738    }
9739
9740    #[test]
9741    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
9742        // At the document end a single Enter must also show the paragraph gap —
9743        // a blank spacer row above the caret — so the layout already matches how
9744        // it will look once the new paragraph has text.
9745        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
9746        d.caret = 5; // end of "Hello", no trailing newline
9747        d.newline(); // source becomes "Hello\n\n"
9748        d.build_visual(80);
9749        let (row, col) = d.caret_pos();
9750        assert_eq!(col, 0);
9751        assert!(
9752            row >= 2,
9753            "caret should sit below a blank spacer, got row {row}"
9754        );
9755        assert_eq!(
9756            d.vmap.row_width(row - 1),
9757            0,
9758            "the row above the caret is a gap"
9759        );
9760    }
9761
9762    #[test]
9763    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
9764        // The spacer is view-only: typing the new paragraph must not reflow the
9765        // caret onto a different row — the transient view already matched the
9766        // settled one.
9767        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
9768        d.caret = 5;
9769        d.newline();
9770        d.build_visual(80);
9771        let before = d.caret_pos();
9772        d.insert("New");
9773        d.build_visual(80);
9774        let after = d.caret_pos();
9775        assert_eq!(
9776            after.0, before.0,
9777            "typing must not move the caret to another row ({before:?} -> {after:?})"
9778        );
9779    }
9780
9781    #[test]
9782    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
9783        let fm = "---\ntitle: hi\n---\n";
9784        let body = format!("{fm}# leaf\n\nbody\n");
9785        let mut d = wysiwyg_doc("wys_fm", &body);
9786        // Opening lifts the caret out of the now-hidden frontmatter.
9787        assert_eq!(
9788            d.caret,
9789            fm.len(),
9790            "caret should start at the first real block"
9791        );
9792        // Left at the content start can't step back into frontmatter.
9793        d.move_left(false);
9794        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
9795        // Doc-start lands on the content floor, not offset 0.
9796        d.move_doc_start(false);
9797        assert_eq!(d.caret, fm.len());
9798        // Select-all + copy never include the frontmatter bytes.
9799        d.select_all();
9800        let sel = d.selected_text().unwrap().to_string();
9801        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
9802        assert!(
9803            sel.starts_with("# leaf"),
9804            "selection should begin at content: {sel:?}"
9805        );
9806    }
9807
9808    #[test]
9809    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
9810        // A fresh note is frontmatter and nothing else. With no rendered block
9811        // to floor the caret it opened at offset 0 — before the opening `---` —
9812        // so the first keystroke wrote itself in front of the metadata and the
9813        // file came out as `This---\ntitle: …`.
9814        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
9815        let mut d = wysiwyg_doc("wys_fm_only", fm);
9816        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
9817        // Nothing is rendered, so the caret draws at the origin of an empty view
9818        // — the same place an empty document puts it.
9819        assert_eq!(d.caret_pos(), (0, 0));
9820        d.insert("This");
9821        assert_eq!(d.source, format!("{fm}This"));
9822    }
9823
9824    /// `select_range` is the verb for a range a host already knows the bytes of,
9825    /// so it must not snap — and must still hold every invariant `place_caret`
9826    /// holds, the frontmatter floor above all.
9827    #[test]
9828    fn select_range_takes_the_range_as_given_but_still_floors_it() {
9829        let fm = "---\ntitle: foo\n---\n\n";
9830        let body = format!("{fm}body foo here\n");
9831        let mut d = wysiwyg_doc("wys_select_range", &body);
9832
9833        // The `foo` in the body: taken exactly, not snapped to a caret stop.
9834        let at = body.rfind("foo").unwrap();
9835        d.select_range(at, at + 3);
9836        assert_eq!(d.selection(), Some((at, at + 3)));
9837        assert_eq!(d.selected_text(), Some("foo"));
9838
9839        // The `foo` in the hidden frontmatter: below the floor, so both ends
9840        // come up to it rather than parking the caret in the metadata, where a
9841        // later keystroke would rewrite `title:`.
9842        let hidden = body.find("foo").unwrap();
9843        assert!(hidden < d.vmap.content_start);
9844        d.select_range(hidden, hidden + 3);
9845        assert!(
9846            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
9847            "a range under the floor must not leave the caret in the frontmatter"
9848        );
9849
9850        // Past the end, and mid-character, are both brought back to something
9851        // sliceable rather than panicking the next reader of the range.
9852        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
9853        let mut d = multi;
9854        d.select_range(2, 9_999);
9855        assert_eq!(d.caret, d.source.len());
9856        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
9857        assert!(d.source.is_char_boundary(d.caret));
9858    }
9859
9860    /// The bug `select_range` exists for: a match butting up against a hidden
9861    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
9862    /// the one before the `**`.
9863    #[test]
9864    fn select_range_does_not_snap_off_a_hidden_delimiter() {
9865        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
9866        let at = d.source.find("needle").unwrap();
9867        d.select_range(at, at + 6);
9868        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
9869    }
9870
9871    #[test]
9872    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
9873        // Backspace deletes `prev_boundary..caret` directly; at the first real
9874        // block that boundary is inside the hidden frontmatter, so it must be a
9875        // no-op rather than eating the closing `---`.
9876        let fm = "---\ntitle: hi\n---\n";
9877        let body = format!("{fm}leaf\n");
9878        let mut d = wysiwyg_doc("wys_fm_bs", &body);
9879        assert_eq!(d.caret, fm.len());
9880        d.backspace();
9881        assert_eq!(d.source, body, "backspace must not touch frontmatter");
9882        d.delete_word_back();
9883        assert_eq!(
9884            d.source, body,
9885            "word-delete must not touch frontmatter either"
9886        );
9887    }
9888
9889    #[test]
9890    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
9891        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
9892        // fenced div, really, since core parses these on for every document
9893        // now (`parse_extensions`). The container is a `directive` node, an
9894        // `is_block_container` kind like `block_quote`, so the caret works
9895        // inside its child paragraph exactly as it would inside a quote: typing
9896        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
9897        // untouched.
9898        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
9899        let mut d = wysiwyg_doc("wys_vis", body);
9900        d.caret = body.find("hello").unwrap() + "hello".len();
9901        d.insert("!");
9902        assert_eq!(
9903            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
9904            "typing inside the block edits its content in place"
9905        );
9906        assert!(
9907            d.source.contains(":::vis{.public .family}"),
9908            "opening fence survives"
9909        );
9910        assert!(d.source.contains(":::\nafter"), "closing fence survives");
9911    }
9912
9913    #[test]
9914    fn source_view_still_reaches_frontmatter() {
9915        // The metadata is only *hidden*, never lost: the source view edits and
9916        // selects it in full, and it's always preserved on save.
9917        let fm = "---\ntitle: hi\n---\n";
9918        let body = format!("{fm}# leaf\n");
9919        let mut d = doc_with("src_fm", &body);
9920        d.select_all();
9921        let sel = d.selected_text().unwrap();
9922        assert!(
9923            sel.contains("title"),
9924            "source view should select everything"
9925        );
9926        d.move_doc_start(false);
9927        assert_eq!(d.caret, 0, "source view can reach offset 0");
9928    }
9929
9930    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
9931
9932    #[test]
9933    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
9934        // The border and padding between two cells all share one source offset,
9935        // so a column-stepping caret would sit on `│` and then stall there
9936        // forever. Right must step: end of "Name" -> start of "Qty".
9937        let mut d = wysiwyg_doc("tbl_right", TABLE);
9938        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
9939        d.move_right(false);
9940        assert_eq!(
9941            d.caret,
9942            TABLE.find("Qty").unwrap(),
9943            "should land in the next cell"
9944        );
9945        let (r, c) = d.caret_pos();
9946        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
9947    }
9948
9949    #[test]
9950    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
9951        let mut d = wysiwyg_doc("tbl_left", TABLE);
9952        d.caret = TABLE.find("Qty").unwrap();
9953        d.move_left(false);
9954        assert_eq!(
9955            d.caret,
9956            TABLE.find("Name").unwrap() + 4,
9957            "end of the previous cell"
9958        );
9959    }
9960
9961    #[test]
9962    fn wysiwyg_down_steps_over_a_table_rule() {
9963        // Between the header and the first body row sits a `├───┼───┤` rule.
9964        // It's drawn but holds no caret, so one Down must reach "Pear".
9965        let mut d = wysiwyg_doc("tbl_down", TABLE);
9966        d.caret = TABLE.find("Name").unwrap();
9967        d.move_down(false);
9968        assert_eq!(
9969            d.caret,
9970            TABLE.find("Pear").unwrap(),
9971            "one Down reaches the body row"
9972        );
9973        d.move_down(false);
9974        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
9975    }
9976
9977    #[test]
9978    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
9979        let mut d = wysiwyg_doc("tbl_tab", TABLE);
9980        d.caret = TABLE.find("Name").unwrap();
9981        // A hop lands with the destination cell's whole content selected, the
9982        // caret at its end — so typing replaces the cell like a form field.
9983        assert!(d.cell_hop(true));
9984        assert_eq!(
9985            d.selected_text(),
9986            Some("Qty"),
9987            "the target cell comes up selected"
9988        );
9989        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
9990        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
9991        assert_eq!(d.selected_text(), Some("Pear"));
9992        assert!(d.cell_hop(false));
9993        assert_eq!(d.selected_text(), Some("Qty"));
9994    }
9995
9996    #[test]
9997    fn tab_outside_a_table_is_not_a_cell_hop() {
9998        // `cell_hop` reports false so the frontend can indent as usual.
9999        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
10000        d.caret = 4;
10001        assert!(!d.cell_hop(true));
10002        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
10003    }
10004
10005    #[test]
10006    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
10007        let mut d = wysiwyg_doc("tbl_edge", TABLE);
10008        d.caret = TABLE.rfind("12").unwrap(); // the final cell
10009        assert!(!d.cell_hop(true), "no cell after the last one");
10010        d.caret = TABLE.find("Name").unwrap();
10011        assert!(!d.cell_hop(false), "no cell before the first one");
10012    }
10013
10014    #[test]
10015    fn wysiwyg_vertical_cell_motion_holds_the_column() {
10016        // Down/Up step to the cell above/below in the *same column*, not back to
10017        // the top-left the way a naive row/col motion over the picture would.
10018        let mut d = wysiwyg_doc("tbl_vert", TABLE);
10019        d.caret = TABLE.find("Qty").unwrap();
10020        // Each vertical hop selects the destination cell, holding the column.
10021        assert!(d.cell_move_vertical(true));
10022        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
10023        assert!(d.cell_move_vertical(true));
10024        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
10025        assert!(!d.cell_move_vertical(true), "no row below the last");
10026        assert!(d.cell_move_vertical(false));
10027        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
10028        assert!(d.cell_move_vertical(false));
10029        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
10030        assert!(!d.cell_move_vertical(false), "no row above the header");
10031    }
10032
10033    #[test]
10034    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
10035        let mut d = wysiwyg_doc("tbl_grow", TABLE);
10036        d.caret = TABLE.rfind("12").unwrap();
10037        let rows_before = d.source.matches('\n').count();
10038        assert!(d.cell_tab(true), "acts as a table key");
10039        assert_eq!(
10040            d.source.matches('\n').count(),
10041            rows_before + 1,
10042            "a fresh row was appended"
10043        );
10044        assert!(d.caret_in_table(), "the caret entered the new row");
10045        // The caret sits in the new row's first cell — past the old last cell.
10046        assert!(d.caret > TABLE.rfind("12").unwrap());
10047    }
10048
10049    #[test]
10050    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
10051        let mut d = wysiwyg_doc("tbl_ret", TABLE);
10052        d.caret = TABLE.find("Name").unwrap();
10053        assert!(d.cell_return(), "acts as a table key");
10054        assert_eq!(
10055            d.selected_text(),
10056            Some("Pear"),
10057            "Return drops one cell, selecting it"
10058        );
10059        // From the last row, Return appends a row and enters it.
10060        d.caret = TABLE.rfind("Fig").unwrap();
10061        let rows_before = d.source.matches('\n').count();
10062        assert!(d.cell_return());
10063        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
10064        assert!(d.caret_in_table());
10065    }
10066
10067    #[test]
10068    fn return_and_tab_outside_a_table_decline() {
10069        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
10070        d.caret = 4;
10071        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
10072        assert!(!d.cell_tab(true), "no table: the frontend indents");
10073        assert!(
10074            !d.cell_line_break(),
10075            "no table: the frontend breaks the line"
10076        );
10077    }
10078
10079    #[test]
10080    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
10081        let mut d = wysiwyg_doc("tbl_break", TABLE);
10082        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
10083        assert!(d.cell_line_break(), "acts as a table key");
10084        assert!(
10085            d.source.contains("Pear<br>"),
10086            "spelled as an inline <br>: {}",
10087            d.source
10088        );
10089        assert!(d.caret_in_table(), "still in the cell, past the break");
10090        // The break renders as a real line: the "Pear" cell now draws two lines,
10091        // so the table's picture is one row taller than a single-line table.
10092        d.build_visual(80);
10093        let table = &d.vmap.tables[0];
10094        let cell = &table.grid[1].cells[0]; // first body row, first column
10095        assert!(
10096            cell.glyphs.iter().any(|g| g.ch == '\n'),
10097            "the cell carries the break as a newline glyph for the frontend to split"
10098        );
10099    }
10100
10101    #[test]
10102    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
10103        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
10104        // back as structure — the whole point of routing through insert_line_break
10105        // instead of splicing raw `<br>` bytes.
10106        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
10107        d.caret = TABLE.find("Pear").unwrap() + 4;
10108        assert!(d.cell_line_break());
10109        let kinds: Vec<Kind> = d
10110            .editor
10111            .nodes()
10112            .unwrap()
10113            .iter()
10114            .map(|n| n.kind.clone())
10115            .collect();
10116        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
10117        assert!(
10118            !kinds.contains(&Kind::RawInline),
10119            "still raw HTML: {kinds:?}"
10120        );
10121    }
10122
10123    #[test]
10124    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
10125        // The `<br>` draws as one newline glyph, so Backspace over it must take
10126        // all four bytes — a one-byte delete would strand a visible `<br` in the
10127        // cell (the reported bug).
10128        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
10129        d.caret = TABLE.find("Pear").unwrap() + 4;
10130        assert!(d.cell_line_break());
10131        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
10132        d.backspace(); // caret sits just past the break
10133        assert!(
10134            !d.source.contains("<br"),
10135            "no half-deleted <br left: {}",
10136            d.source
10137        );
10138        assert!(
10139            d.source.contains("| Pear |"),
10140            "the cell is back to one line: {}",
10141            d.source
10142        );
10143    }
10144
10145    #[test]
10146    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
10147        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
10148        d.caret = TABLE.find("Pear").unwrap() + 4;
10149        assert!(d.cell_line_break());
10150        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
10151        d.delete_forward();
10152        assert!(
10153            !d.source.contains("<br"),
10154            "no half-deleted <br: {}",
10155            d.source
10156        );
10157        assert!(
10158            d.source.contains("| Pear |"),
10159            "cell back to one line: {}",
10160            d.source
10161        );
10162    }
10163
10164    #[test]
10165    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
10166        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
10167        // still consumed (a real newline would split the one-line row), but the
10168        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
10169        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
10170        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10171        d.caret = src.find("Pear").unwrap() + 4;
10172        assert!(d.caret_in_table(), "caret should be inside the djot table");
10173        assert!(
10174            d.cell_line_break(),
10175            "the key is consumed, not passed to the frontend"
10176        );
10177        assert_eq!(d.source, src, "the djot cell is left untouched");
10178        assert!(
10179            !d.source.contains("<br>"),
10180            "no non-idiomatic <br> spliced into djot"
10181        );
10182        assert!(
10183            d.status.is_some(),
10184            "the refusal is surfaced on the status line"
10185        );
10186    }
10187
10188    #[test]
10189    fn typing_in_a_cell_edits_that_cell() {
10190        // Editing comes free once offsets map correctly: the caret is a source
10191        // offset, so a normal splice lands inside the pipe table.
10192        let mut d = wysiwyg_doc("tbl_type", TABLE);
10193        d.caret = TABLE.find("Pear").unwrap() + 4;
10194        d.insert("s");
10195        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
10196    }
10197
10198    #[test]
10199    fn motion_and_delete_treat_an_emoji_as_one_character() {
10200        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
10201        // bytes, several codepoints. Right-arrow must clear it in one step, and
10202        // backspace must remove the whole cluster, not a stray joiner.
10203        let family = "👨‍👩‍👧";
10204        let mut d = doc_with("emoji", &format!("a{family}b\n"));
10205        d.caret = 1; // just after 'a', before the emoji
10206        d.move_right(false);
10207        assert_eq!(
10208            d.caret,
10209            1 + family.len(),
10210            "one step clears the whole cluster"
10211        );
10212        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
10213
10214        d.backspace(); // delete the emoji as a unit
10215        assert_eq!(d.source, "ab\n");
10216        assert_eq!(d.caret, 1);
10217    }
10218
10219    #[test]
10220    fn motion_handles_a_combining_accent_as_one_character() {
10221        // "e" + U+0301 (combining acute) renders as one é.
10222        let mut d = doc_with("combining", "e\u{0301}x\n");
10223        d.caret = 0;
10224        d.move_right(false);
10225        assert_eq!(
10226            d.caret,
10227            "e\u{0301}".len(),
10228            "steps past base + combining mark"
10229        );
10230    }
10231
10232    #[test]
10233    fn undo_then_redo_round_trips_an_edit() {
10234        let mut d = doc_with("undo", "hello\n");
10235        d.caret = 5;
10236        d.insert("!");
10237        assert_eq!(d.source, "hello!\n");
10238        d.undo();
10239        assert_eq!(d.source, "hello\n");
10240        assert_eq!(d.caret, 5, "undo restores the caret");
10241        d.redo();
10242        assert_eq!(d.source, "hello!\n");
10243    }
10244
10245    #[test]
10246    fn a_run_of_typing_undoes_as_one_step() {
10247        let mut d = doc_with("coalesce", "\n");
10248        d.caret = 0;
10249        d.insert("a");
10250        d.insert("b");
10251        d.insert("c");
10252        assert_eq!(d.source, "abc\n");
10253        d.undo(); // the whole typed run, not just "c"
10254        assert_eq!(d.source, "\n");
10255        d.undo(); // nothing left — the run was one step
10256        assert_eq!(d.source, "\n");
10257        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10258    }
10259
10260    // ── IME composition ──────────────────────────────────────────────────────
10261
10262    #[test]
10263    fn a_composition_run_undoes_as_one_step() {
10264        let mut d = doc_with("compose", "\n");
10265        d.caret = 0;
10266        // What an IME does: each step replaces the last one's provisional bytes.
10267        d.edit_composing(0, 0, "k");
10268        d.edit_composing(0, 1, "か");
10269        d.edit_composing(0, 3, "かん");
10270        d.edit_composing(0, 6, "感"); // the commit
10271        d.end_composition();
10272        assert_eq!(d.source, "感\n");
10273        d.undo(); // the whole composition, not its last keystroke
10274        assert_eq!(d.source, "\n");
10275        assert_eq!(d.status.as_deref(), None, "the run was a single step");
10276    }
10277
10278    #[test]
10279    fn two_compositions_are_two_undo_steps() {
10280        let mut d = doc_with("compose_two", "\n");
10281        d.caret = 0;
10282        d.edit_composing(0, 0, "か");
10283        d.edit_composing(0, 3, "蚊");
10284        d.end_composition();
10285        d.edit_composing(3, 3, "き");
10286        d.edit_composing(3, 6, "木");
10287        d.end_composition();
10288        assert_eq!(d.source, "蚊木\n");
10289        d.undo();
10290        assert_eq!(d.source, "蚊\n", "only the second composition");
10291        d.undo();
10292        assert_eq!(d.source, "\n");
10293    }
10294
10295    #[test]
10296    fn a_composition_does_not_fold_into_the_typing_around_it() {
10297        let mut d = doc_with("compose_typing", "\n");
10298        d.caret = 0;
10299        d.insert("a");
10300        d.insert("b");
10301        d.edit_composing(2, 2, "か");
10302        d.edit_composing(2, 5, "蚊");
10303        d.end_composition();
10304        d.insert("c");
10305        assert_eq!(d.source, "ab蚊c\n");
10306        d.undo();
10307        assert_eq!(d.source, "ab蚊\n");
10308        d.undo();
10309        assert_eq!(d.source, "ab\n");
10310        d.undo();
10311        assert_eq!(d.source, "\n");
10312    }
10313
10314    #[test]
10315    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
10316        let mut d = doc_with("compose_spurious", "\n");
10317        d.caret = 0;
10318        d.insert("a");
10319        d.end_composition(); // an IME unmarking unprompted
10320        d.insert("b");
10321        assert_eq!(d.source, "ab\n");
10322        d.undo();
10323        assert_eq!(d.source, "\n", "still one typed run");
10324    }
10325
10326    // ── the clipboard's rich flavor ──────────────────────────────────────────
10327
10328    #[test]
10329    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
10330        let mut d = doc_with("sel_inline", "a **bold** c\n");
10331        d.anchor = Some(2);
10332        d.caret = 10; // `**bold**`, inside the paragraph
10333        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
10334    }
10335
10336    #[test]
10337    fn a_whole_block_selection_keeps_its_paragraph() {
10338        let mut d = doc_with("sel_block", "a **bold** c\n");
10339        d.anchor = Some(0);
10340        d.caret = 12; // the entire paragraph
10341        assert_eq!(
10342            d.selection_html().as_deref(),
10343            Some("<p>a <strong>bold</strong> c</p>")
10344        );
10345    }
10346
10347    #[test]
10348    fn a_multi_block_selection_keeps_its_structure() {
10349        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
10350        d.select_all();
10351        let html = d.selection_html().expect("renders");
10352        assert!(html.contains("<p>para</p>"), "{html:?}");
10353        assert!(html.contains("<li>one</li>"), "{html:?}");
10354    }
10355
10356    #[test]
10357    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
10358        // The fragment `Head` is a paragraph standalone; the *document* says it
10359        // sits inside one block, so the wrapper is an artifact either way.
10360        let mut d = doc_with("sel_heading", "# Head line\n");
10361        d.anchor = Some(2);
10362        d.caret = 6;
10363        assert_eq!(d.selection_html().as_deref(), Some("Head"));
10364    }
10365
10366    #[test]
10367    fn no_selection_publishes_no_html() {
10368        let mut d = doc_with("sel_none", "a b\n");
10369        d.caret = 1;
10370        assert_eq!(d.selection_html(), None);
10371    }
10372
10373    #[test]
10374    fn pasting_html_converts_it_and_is_one_undo_step() {
10375        let mut d = doc_with("paste_html", "x\n");
10376        d.caret = 1;
10377        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
10378        assert_eq!(d.source, "xa **b** c\n");
10379        d.undo();
10380        assert_eq!(d.source, "x\n", "the whole paste, in one step");
10381    }
10382
10383    #[test]
10384    fn pasting_html_replaces_the_selection() {
10385        let mut d = doc_with("paste_html_sel", "keep drop\n");
10386        d.anchor = Some(5);
10387        d.caret = 9;
10388        assert!(d.paste_html("<em>new</em>"));
10389        assert_eq!(d.source, "keep *new*\n");
10390    }
10391
10392    #[test]
10393    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
10394        let mut d = doc_with("paste_html_bad", "x\n");
10395        d.caret = 1;
10396        // twig builds no table from HTML; raw `<table>` in prose is worse than
10397        // the plain flavor the caller still holds.
10398        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
10399        assert_eq!(d.source, "x\n", "declined edits nothing");
10400    }
10401
10402    #[test]
10403    fn copy_then_paste_round_trips_through_the_html_flavor() {
10404        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
10405        d.select_all();
10406        let html = d.selection_html().expect("renders");
10407        let mut into = doc_with("clip_round_dst", "\n");
10408        into.caret = 0;
10409        assert!(into.paste_html(&html));
10410        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
10411    }
10412
10413    #[test]
10414    fn moving_the_caret_starts_a_new_undo_group() {
10415        let mut d = doc_with("break", "\n");
10416        d.caret = 0;
10417        d.insert("a");
10418        d.insert("b"); // "ab\n", caret at 2
10419        d.move_left(false); // breaks the run
10420        d.insert("X"); // "aXb\n"
10421        assert_eq!(d.source, "aXb\n");
10422        d.undo();
10423        assert_eq!(
10424            d.source, "ab\n",
10425            "first undo removes only the post-move insert"
10426        );
10427        d.undo();
10428        assert_eq!(d.source, "\n", "second undo removes the earlier run");
10429    }
10430
10431    #[test]
10432    fn undo_reverses_a_format_toggle() {
10433        let mut d = doc_with("fmt_undo", "a word b\n");
10434        d.anchor = Some(2);
10435        d.caret = 6;
10436        d.toggle(InlineKind::Strong);
10437        assert_eq!(d.source, "a **word** b\n");
10438        d.undo();
10439        assert_eq!(d.source, "a word b\n");
10440    }
10441
10442    #[test]
10443    fn undo_back_to_the_saved_state_clears_dirty() {
10444        let mut d = doc_with("dirty_undo", "hello\n");
10445        assert!(!d.dirty);
10446        d.caret = 5;
10447        d.insert("!");
10448        assert!(d.dirty);
10449        d.undo();
10450        assert!(
10451            !d.dirty,
10452            "undoing to the saved source is not a modification"
10453        );
10454    }
10455
10456    #[test]
10457    fn a_new_edit_invalidates_redo() {
10458        let mut d = doc_with("redo_inv", "\n");
10459        d.caret = 0;
10460        d.insert("a");
10461        d.undo();
10462        d.insert("b"); // diverges — the redo of "a" is now gone
10463        d.redo();
10464        assert_eq!(d.source, "b\n");
10465    }
10466
10467    #[test]
10468    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
10469        let mut d = doc_with("can_undo", "hello\n");
10470        assert!(
10471            !d.can_undo() && !d.can_redo(),
10472            "a fresh document has no history"
10473        );
10474        d.caret = 5;
10475        d.insert("!");
10476        assert!(
10477            d.can_undo() && !d.can_redo(),
10478            "an edit is a step to take back"
10479        );
10480        d.undo();
10481        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
10482        d.redo();
10483        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
10484        d.undo();
10485        d.insert("?");
10486        assert!(
10487            d.can_undo() && !d.can_redo(),
10488            "a fresh edit ends the redo chain"
10489        );
10490        // A coalesced run over-counts steps — the bound is what a menu needs,
10491        // and it reconciles the moment twig reports the history empty.
10492        d.insert("a");
10493        d.insert("b");
10494        while d.can_undo() {
10495            d.undo();
10496        }
10497        assert_eq!(d.source, "hello\n");
10498        assert!(!d.can_undo());
10499        // A reading surface has nothing to undo, whatever the history holds.
10500        d.redo();
10501        d.set_read_only(true);
10502        assert!(!d.can_undo() && !d.can_redo());
10503    }
10504
10505    #[test]
10506    fn undo_on_empty_history_is_a_no_op() {
10507        let mut d = doc_with("undo_empty", "hi\n");
10508        d.undo();
10509        assert_eq!(d.source, "hi\n");
10510        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
10511    }
10512
10513    #[test]
10514    fn a_one_character_paste_is_its_own_undo_step() {
10515        for view in [View::Source, View::Wysiwyg] {
10516            let mut d = doc_in(view, "paste_step", "ab\n");
10517            d.caret = 0;
10518            d.insert("x");
10519            d.insert("y"); // a run of typing
10520            d.paste("z"); // one character, but pasted — not part of that run
10521            assert_eq!(d.source, "xyzab\n");
10522            d.undo();
10523            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
10524            assert_eq!(d.caret, 2, "and hands back the caret it found");
10525            d.undo();
10526            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
10527        }
10528    }
10529
10530    #[test]
10531    fn the_same_character_typed_still_joins_the_run() {
10532        // The other half of the pair: `z` is a keystroke here and a paste above,
10533        // and the two undo differently. Nothing about the *string* says which —
10534        // which is why provenance has to come from the door the caller uses.
10535        for view in [View::Source, View::Wysiwyg] {
10536            let mut d = doc_in(view, "typed_run", "ab\n");
10537            d.caret = 0;
10538            d.insert("x");
10539            d.insert("y");
10540            d.insert("z");
10541            d.undo();
10542            assert_eq!(d.source, "ab\n", "one run, one step");
10543        }
10544    }
10545
10546    #[test]
10547    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
10548        for view in [View::Source, View::Wysiwyg] {
10549            let mut d = doc_in(view, "undo_caret", "hello world\n");
10550            d.caret = 11; // standing at the end of "world", away from the edit
10551            d.edit(0, 5, "goodbye");
10552            assert_eq!(d.source, "goodbye world\n");
10553            d.undo();
10554            assert_eq!(d.source, "hello world\n");
10555            // The undone edit ends at offset 5; the user was at 11.
10556            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
10557        }
10558    }
10559
10560    #[test]
10561    fn undo_restores_the_selection_the_edit_replaced() {
10562        for view in [View::Source, View::Wysiwyg] {
10563            let mut d = doc_in(view, "undo_sel", "a word b\n");
10564            d.anchor = Some(2);
10565            d.caret = 6; // "word" selected
10566            d.insert("X");
10567            assert_eq!(d.source, "a X b\n");
10568            d.undo();
10569            assert_eq!(d.source, "a word b\n");
10570            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
10571        }
10572    }
10573
10574    #[test]
10575    fn redo_restores_the_caret_the_edit_left_behind() {
10576        for view in [View::Source, View::Wysiwyg] {
10577            let mut d = doc_in(view, "redo_caret", "hello world\n");
10578            d.caret = 11;
10579            d.edit(0, 5, "goodbye");
10580            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
10581            d.undo();
10582            d.redo();
10583            assert_eq!(d.source, "goodbye world\n");
10584            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
10585        }
10586    }
10587
10588    #[test]
10589    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
10590        for view in [View::Source, View::Wysiwyg] {
10591            let mut d = doc_in(view, "run_caret", "hi\n");
10592            d.caret = 2;
10593            d.insert("a");
10594            d.insert("b");
10595            d.insert("c");
10596            assert_eq!(d.source, "hiabc\n");
10597            d.undo();
10598            assert_eq!(d.source, "hi\n");
10599            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
10600            d.redo();
10601            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
10602        }
10603    }
10604
10605    #[test]
10606    fn undo_restores_the_caret_across_a_format_toggle() {
10607        // A toggle reaches twig without going through `splice`, so it has to
10608        // record its own step — miss it and every stack depth below it is off by
10609        // one, and undo starts handing back another edit's caret.
10610        for view in [View::Source, View::Wysiwyg] {
10611            let mut d = doc_in(view, "fmt_caret", "a word b\n");
10612            d.caret = 8;
10613            d.anchor = Some(2);
10614            d.caret = 6;
10615            d.toggle(InlineKind::Strong);
10616            assert_eq!(d.source, "a **word** b\n");
10617            d.undo();
10618            assert_eq!(d.source, "a word b\n");
10619            assert_eq!(
10620                d.selection(),
10621                Some((2, 6)),
10622                "the toggled selection comes back"
10623            );
10624        }
10625    }
10626
10627    #[test]
10628    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
10629        // The drift that would never announce itself: twig drops its redo stack
10630        // on any fresh edit, so a leaf redo entry that outlives it would restore
10631        // a caret from the timeline that edit abandoned.
10632        for view in [View::Source, View::Wysiwyg] {
10633            let mut d = doc_in(view, "redo_trunc", "hello world\n");
10634            d.caret = 11;
10635            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
10636            d.undo();
10637            assert_eq!(d.caret, 11);
10638            d.caret = 0;
10639            d.insert("X"); // diverges: A's redo is gone from twig
10640            assert_eq!(d.source, "Xhello world\n");
10641
10642            d.redo();
10643            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
10644            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
10645            d.undo();
10646            assert_eq!(d.source, "hello world\n");
10647            assert_eq!(
10648                d.caret, 0,
10649                "the surviving step's caret, not the dropped one"
10650            );
10651        }
10652    }
10653
10654    #[test]
10655    fn indent_and_outdent_move_the_caret_line_with_its_text() {
10656        for view in [View::Source, View::Wysiwyg] {
10657            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
10658            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
10659            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
10660            // Indentation the caret is standing *in* collapses to the line start
10661            // rather than dragging the caret into the text.
10662            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
10663            // A line with none to give back is left exactly as it was.
10664            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
10665            // Less than a full level gives back what it has.
10666            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
10667            // A tab is one level however many spaces it isn't.
10668            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
10669        }
10670    }
10671
10672    #[test]
10673    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
10674        // Why the level is two spaces and not the four both frontends type
10675        // today. Four is markdown's indented-code-block marker, so a Tab on a
10676        // paragraph would silently restyle it as code — a width that changes
10677        // what the document *means* isn't an indent. Pinned because the number
10678        // is the kind of thing a later list-aware pass would reach for.
10679        let mut d = doc_with("indent_kind", "hello\n");
10680        d.caret = 2;
10681        d.indent();
10682        assert_eq!(d.source, "  hello\n");
10683        assert!(
10684            d.nodes().iter().any(|n| n.kind == Kind::Para),
10685            "still prose after a Tab"
10686        );
10687        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
10688
10689        // The four-space level this replaces, for contrast: same text, and twig
10690        // reparses the paragraph into a code block.
10691        let mut wide = doc_with("indent_kind_4", "    hello\n");
10692        wide.build_visual(80);
10693        assert!(
10694            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
10695            "four spaces is a code block, not an indented paragraph"
10696        );
10697    }
10698
10699    #[test]
10700    fn indent_nests_a_list_item_under_its_parent() {
10701        // Tab indents a list item by its own marker width, landing its marker at
10702        // the parent's content column so twig reparses it as a nested list.
10703        for view in [View::Source, View::Wysiwyg] {
10704            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
10705            d.caret = 6; // on the second item
10706            d.indent();
10707            assert_eq!(d.source, "- a\n  - b\n");
10708            let lists = d
10709                .nodes()
10710                .iter()
10711                .filter(|n| n.kind == Kind::BulletList)
10712                .count();
10713            assert_eq!(lists, 2, "the indented item is a nested list");
10714        }
10715    }
10716
10717    #[test]
10718    fn indent_nests_an_ordered_item_at_its_marker_width() {
10719        // An ordered marker `1. ` is three columns wide, so a two-space step
10720        // (which nests a bullet) leaves it flat. Regression: Tab must use the
10721        // marker width, three, so the item actually nests — and the source
10722        // renumbers so the sub-list restarts at 1 and the outer list resumes.
10723        for view in [View::Source, View::Wysiwyg] {
10724            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
10725            d.caret = d.source.find('b').unwrap();
10726            d.indent();
10727            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
10728            let lists = d
10729                .nodes()
10730                .iter()
10731                .filter(|n| n.kind == Kind::OrderedList)
10732                .count();
10733            assert_eq!(lists, 2, "the indented item is a nested ordered list");
10734        }
10735    }
10736
10737    #[test]
10738    fn indent_leaves_a_lists_first_item_put() {
10739        // The first item of a list has no sibling above it to nest under, so Tab
10740        // is a no-op there — the marker stays at column zero rather than being
10741        // shoved into indentation twig can't read as a sub-list.
10742        for view in [View::Source, View::Wysiwyg] {
10743            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
10744            d.caret = 1; // on the FIRST item
10745            d.indent();
10746            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
10747            // The sibling below still nests, proving the guard is per-item.
10748            d.caret = d.source.find('b').unwrap();
10749            d.indent();
10750            assert_eq!(d.source, "- a\n  - b\n");
10751        }
10752    }
10753
10754    #[test]
10755    fn hidden_mode_keeps_typed_markup_literal() {
10756        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
10757        // twig escapes what would open markup, so the source is `\*hi\*` and the
10758        // AST is a plain string. Formatting is the commands' job in this mode.
10759        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
10760        d.insert("*hi*");
10761        assert_eq!(d.source, "\\*hi\\*");
10762        assert!(
10763            d.nodes()
10764                .iter()
10765                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
10766        );
10767    }
10768
10769    #[test]
10770    fn hidden_mode_escapes_a_line_start_block_marker() {
10771        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
10772        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
10773        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
10774        d.insert("# hi");
10775        assert_eq!(d.source, "\\# hi");
10776        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
10777    }
10778
10779    #[test]
10780    fn authoring_modes_keep_typed_markup_live() {
10781        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
10782        // (no escape), the same as source view — escaping is `None`'s alone, and
10783        // it's the axis, not the reveal, that decides.
10784        for (view, mode) in [
10785            (View::Wysiwyg, MarkupMode::Shortcuts),
10786            (View::Wysiwyg, MarkupMode::Full),
10787            (View::Source, MarkupMode::None),
10788        ] {
10789            let mut d = doc_in(view, "live_markup", "");
10790            d.set_markup_mode(mode);
10791            d.insert("*hi*");
10792            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
10793        }
10794    }
10795
10796    #[test]
10797    fn hidden_mode_overwrite_undoes_in_one_step() {
10798        // Typing over a selection escapes the replacement *and* stays a single
10799        // undo — the selection-delete and the literal insert fold together, so
10800        // one undo brings the whole selection back, like a plain overwrite.
10801        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
10802        d.anchor = Some(2);
10803        d.caret = 6; // "word"
10804        d.insert("*");
10805        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
10806        d.undo();
10807        assert_eq!(d.source, "a word b\n");
10808        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
10809    }
10810
10811    #[test]
10812    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
10813        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
10814        // the whole visual character, never stranding the hidden `\`.
10815        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
10816        d.insert("*");
10817        assert_eq!(d.source, "\\*");
10818        d.backspace();
10819        assert_eq!(d.source, "", "the escape backslash went with the *");
10820        // A *literal* backslash (source view, no escape) is an ordinary char.
10821        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
10822        s.caret = 3; // after `b`
10823        s.backspace();
10824        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
10825    }
10826
10827    #[test]
10828    fn hidden_mode_leaves_structural_markup_alone() {
10829        // Enter continues a bullet list by writing a real `- ` marker (an
10830        // `insert_raw`, not the typing path), so Hidden mode's escaping never
10831        // touches it — the list keeps working.
10832        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
10833        d.caret = 6;
10834        d.newline();
10835        d.insert("two");
10836        assert_eq!(d.source, "- item\n- two\n");
10837    }
10838
10839    #[test]
10840    fn markup_mode_defaults_to_none_and_round_trips() {
10841        // Diaryx's default is the clean `None` surface; a markup-fluent
10842        // frontend can climb the ladder, and the choice sticks.
10843        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
10844        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
10845        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
10846            d.set_markup_mode(mode);
10847            assert_eq!(d.markup_mode(), mode);
10848        }
10849    }
10850
10851    #[test]
10852    fn full_mode_reveals_only_the_caret_line() {
10853        // The mode's whole claim: the caret's line shows its raw delimiters and
10854        // every other line stays resolved. Two paragraphs with identical markup
10855        // so the only difference between the rows is where the caret is.
10856        let mut d = doc_in(
10857            View::Wysiwyg,
10858            "reveal_caret_line",
10859            "*one* here\n\n*two* there\n",
10860        );
10861        d.set_markup_mode(MarkupMode::Full);
10862
10863        caret_at(&mut d, "one");
10864        let rows = drawn_rows(&d);
10865        assert!(
10866            rows.iter().any(|r| r == "*one* here"),
10867            "caret's line raw: {rows:?}"
10868        );
10869        assert!(
10870            rows.iter().any(|r| r == "two there"),
10871            "other line resolved: {rows:?}"
10872        );
10873
10874        // Move to the other paragraph: the reveal follows, and the line just
10875        // left goes back to being resolved.
10876        caret_at(&mut d, "two");
10877        let rows = drawn_rows(&d);
10878        assert!(
10879            rows.iter().any(|r| r == "*two* there"),
10880            "caret's line raw: {rows:?}"
10881        );
10882        assert!(
10883            rows.iter().any(|r| r == "one here"),
10884            "left line resolved: {rows:?}"
10885        );
10886    }
10887
10888    #[test]
10889    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
10890        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
10891        // the same treatment as an emphasis's `*`: hidden while the caret is
10892        // elsewhere, shown in full where the caret lands. That falls out of
10893        // `delims` reading the bytes between the mark's span and its content
10894        // span, which is exactly `==🔴 ` and `==`, rather than from a table
10895        // of spellings — so the no-space form `==🟢green==` reveals right too.
10896        let mut d = doc_in(
10897            View::Wysiwyg,
10898            "reveal_coloured_mark",
10899            "a ==🔴 red== one\n\nb ==plain== two\n",
10900        );
10901        d.set_markup_mode(MarkupMode::Full);
10902
10903        caret_at(&mut d, "red");
10904        let rows = drawn_rows(&d);
10905        assert!(
10906            rows.iter().any(|r| r == "a ==🔴 red== one"),
10907            "the caret's line shows the colour it was written with: {rows:?}"
10908        );
10909        assert!(
10910            rows.iter().any(|r| r == "b plain two"),
10911            "and every other line stays resolved: {rows:?}"
10912        );
10913
10914        // Away from it, the emoji goes back to being markup — the reader sees
10915        // the words and the wash.
10916        caret_at(&mut d, "two");
10917        let rows = drawn_rows(&d);
10918        assert!(
10919            rows.iter().any(|r| r == "a red one"),
10920            "resolved again: {rows:?}"
10921        );
10922    }
10923
10924    #[test]
10925    fn hidden_modes_never_reveal_wherever_the_caret_is() {
10926        // The two rungs below `Full` share a rendering: delimiters stay hidden
10927        // even under the caret. `Shortcuts` differing from `None` only in what
10928        // typing does is exactly the point of splitting the axes.
10929        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
10930            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
10931            d.set_markup_mode(mode);
10932            caret_at(&mut d, "one");
10933            let rows = drawn_rows(&d);
10934            assert!(
10935                rows.iter().any(|r| r == "one here"),
10936                "{mode:?} hides: {rows:?}"
10937            );
10938            assert!(
10939                !rows.iter().any(|r| r.contains('*')),
10940                "{mode:?} shows no `*`: {rows:?}"
10941            );
10942        }
10943    }
10944
10945    #[test]
10946    fn revealed_delimiters_are_the_authors_own_spelling() {
10947        // Delimiters are re-read from the source rather than synthesized per
10948        // kind, so a line comes back spelled the way it was written: `_em_` does
10949        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
10950        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
10951        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
10952        d.set_markup_mode(MarkupMode::Full);
10953        caret_at(&mut d, "em");
10954        let rows = drawn_rows(&d);
10955        assert!(
10956            rows.iter().any(|r| r == body.trim_end()),
10957            "the revealed line is its own source: {rows:?}"
10958        );
10959    }
10960
10961    #[test]
10962    fn revealed_heading_shows_its_hashes() {
10963        // The `# ` marker is a block-level prefix, not an inline delimiter, so
10964        // it takes its own path — but it reveals on the same rule.
10965        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
10966        d.set_markup_mode(MarkupMode::Full);
10967
10968        caret_at(&mut d, "Title");
10969        assert!(
10970            drawn_rows(&d).iter().any(|r| r == "# Title"),
10971            "{:?}",
10972            drawn_rows(&d)
10973        );
10974
10975        caret_at(&mut d, "body");
10976        let rows = drawn_rows(&d);
10977        assert!(
10978            rows.iter().any(|r| r == "Title"),
10979            "hashes hidden again: {rows:?}"
10980        );
10981    }
10982
10983    #[test]
10984    fn revealed_delimiters_are_caret_stops() {
10985        // A delimiter that is drawn but can't be reached is worse than one
10986        // that's hidden: the mode exists so the markup can be *edited*. Every
10987        // revealed byte must be somewhere the caret can stand.
10988        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
10989        d.set_markup_mode(MarkupMode::Full);
10990        caret_at(&mut d, "em");
10991        let opener = d.source.find('*').unwrap();
10992        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
10993        assert!(
10994            d.vmap.is_stop(opener + 3),
10995            "the closing `*` is a caret stop"
10996        );
10997    }
10998
10999    #[test]
11000    fn setext_heading_reveals_nothing_across_its_newline() {
11001        // A setext heading's underline is on another line, so it is not the
11002        // caret line's to reveal — and emitting it would inject a `\n` glyph
11003        // that splits the row where the author wrote no break.
11004        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
11005        d.set_markup_mode(MarkupMode::Full);
11006        caret_at(&mut d, "Title");
11007        let rows = drawn_rows(&d);
11008        assert!(
11009            rows.iter().any(|r| r == "Title"),
11010            "title renders alone: {rows:?}"
11011        );
11012        assert!(
11013            !rows.iter().any(|r| r.contains('=')),
11014            "no underline leaks in: {rows:?}"
11015        );
11016    }
11017
11018    #[test]
11019    fn markup_mode_axes_split_the_ladder() {
11020        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
11021        // that authors markup but still hides it, and it's the only rung where
11022        // the two axes disagree.
11023        assert!(!MarkupMode::None.authors());
11024        assert!(!MarkupMode::None.reveals_caret_line());
11025        assert!(MarkupMode::Shortcuts.authors());
11026        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
11027        assert!(MarkupMode::Full.authors());
11028        assert!(MarkupMode::Full.reveals_caret_line());
11029    }
11030
11031    #[test]
11032    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
11033        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
11034        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
11035        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
11036        // nested bullet and `hello` stays prose: the file round-trips instead of
11037        // hiding a heading the user never asked for.
11038        for view in [View::Source, View::Wysiwyg] {
11039            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
11040            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
11041            d.indent();
11042            assert_eq!(d.source, "- hello\n  * \n");
11043            assert!(
11044                d.nodes().iter().all(|n| n.kind != Kind::Heading),
11045                "no heading"
11046            );
11047            // And it's genuinely a nested list, not a flat one.
11048            assert_eq!(
11049                d.nodes()
11050                    .iter()
11051                    .filter(|n| n.kind == Kind::BulletList)
11052                    .count(),
11053                2
11054            );
11055        }
11056    }
11057
11058    #[test]
11059    fn indenting_a_dash_item_with_content_keeps_its_dash() {
11060        // With content, `- x` can't be a setext underline, so there's nothing to
11061        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
11062        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
11063        d.caret = d.source.find('x').unwrap();
11064        d.indent();
11065        assert_eq!(d.source, "- hello\n  - x\n");
11066    }
11067
11068    #[test]
11069    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
11070        // The dash→`*` repair coalesces into the Tab, so a single undo restores
11071        // the whole pre-Tab state rather than stranding a half-collapsed doc.
11072        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
11073        d.caret = d.source.find("- \n").unwrap() + 2;
11074        d.indent();
11075        assert_eq!(d.source, "- hello\n  * \n");
11076        d.undo();
11077        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
11078    }
11079
11080    #[test]
11081    fn indent_leaves_a_nested_lists_first_item_put_too() {
11082        // The guard is about siblings, not depth: the first item of an *inner*
11083        // list (already nested under `a`) still has nothing before it at its own
11084        // level, so Tab can't take it deeper.
11085        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
11086        d.caret = d.source.find('b').unwrap();
11087        d.indent();
11088        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
11089        // But `c` (a sibling of `b`) nests under `b`.
11090        d.caret = d.source.find('c').unwrap();
11091        d.indent();
11092        assert_eq!(d.source, "- a\n  - b\n    - c\n");
11093    }
11094
11095    #[test]
11096    fn backspace_at_a_nested_item_start_outdents_it() {
11097        // Backspace with the caret right after a nested item's marker gives back
11098        // one level of nesting, the mirror of Tab — and renumbers the flattened
11099        // ordered list back to a clean run.
11100        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
11101        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
11102        d.backspace();
11103        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
11104    }
11105
11106    #[test]
11107    fn backspace_at_a_top_level_item_start_strips_the_marker() {
11108        // At the outermost level there's no nesting left to give back, so the same
11109        // keystroke drops the bullet and leaves a plain paragraph.
11110        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
11111        d.caret = d.source.find('b').unwrap(); // right after `- `
11112        d.backspace();
11113        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
11114    }
11115
11116    #[test]
11117    fn backspace_mid_item_still_deletes_a_character() {
11118        // The list behaviour is armed only at the item's content start; anywhere
11119        // else Backspace is the ordinary character delete.
11120        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
11121        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
11122        d.backspace();
11123        assert_eq!(d.source, "- b\n");
11124    }
11125
11126    #[test]
11127    fn backspace_at_a_heading_start_strips_the_marker() {
11128        // The `# ` is markup the rich view hides, so Backspace over it takes the
11129        // whole marker and leaves a paragraph. Deleting a byte of it instead left
11130        // `#Title` — no longer a heading, with the hash now literal text the user
11131        // never typed and has to delete again.
11132        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
11133        d.caret = d.source.find('T').unwrap(); // right after `## `
11134        d.backspace();
11135        assert_eq!(d.source, "Title\n");
11136        assert_eq!(
11137            d.caret, 0,
11138            "the caret stays with the text it was in front of"
11139        );
11140    }
11141
11142    #[test]
11143    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
11144        // Only the heading's own marker goes — the quote (or list) it sits in is
11145        // untouched, exactly as un-heading it should be.
11146        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
11147        d.caret = d.source.find('T').unwrap();
11148        d.backspace();
11149        assert_eq!(d.source, "> Title\n");
11150    }
11151
11152    #[test]
11153    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
11154        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
11155        // behind would surface the same stray hash the marker delete just avoided.
11156        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
11157        d.caret = d.source.find('T').unwrap();
11158        d.backspace();
11159        assert_eq!(d.source, "Title\n");
11160        // And it's one edit: a single undo puts the whole heading back.
11161        d.undo();
11162        assert_eq!(d.source, "# Title #\n");
11163    }
11164
11165    #[test]
11166    fn backspace_mid_heading_still_deletes_a_character() {
11167        // The heading behaviour is armed only at the content's start; anywhere
11168        // else Backspace is the ordinary character delete.
11169        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
11170        d.caret = d.source.find('b').unwrap();
11171        d.backspace();
11172        assert_eq!(d.source, "# b\n");
11173    }
11174
11175    #[test]
11176    fn source_view_backspace_still_edits_the_heading_marker_literally() {
11177        // In source view the `# ` is text on the screen the user is deleting a
11178        // byte of, so it keeps its literal meaning — the same split the list
11179        // ladder and Enter draw between the two views.
11180        let mut d = doc_with("bsp_head_src", "# Title\n");
11181        d.caret = d.source.find('T').unwrap();
11182        d.backspace();
11183        assert_eq!(d.source, "#Title\n");
11184    }
11185
11186    #[test]
11187    fn outdent_unnests_an_ordered_item_in_one_press() {
11188        // Shift+Tab gives back exactly the marker width the indent added, so a
11189        // nested ordered item unnests in a single press, and the flattened list
11190        // renumbers back to a clean 1, 2, 3.
11191        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
11192        d.caret = d.source.find('b').unwrap();
11193        d.outdent();
11194        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
11195        let lists = d
11196            .nodes()
11197            .iter()
11198            .filter(|n| n.kind == Kind::OrderedList)
11199            .count();
11200        assert_eq!(lists, 1, "back to one flat list");
11201    }
11202
11203    #[test]
11204    fn table_insert_row_adds_a_row_below_the_caret() {
11205        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11206        d.caret = d.source.find('1').unwrap(); // in the body row
11207        d.table_insert_row(true);
11208        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
11209    }
11210
11211    #[test]
11212    fn table_insert_and_delete_column_at_the_caret() {
11213        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11214        d.caret = d.source.find('a').unwrap(); // column 0
11215        d.table_insert_column(true); // add a column to the right of `a`
11216        assert_eq!(
11217            d.source,
11218            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
11219        );
11220        d.caret = d.source.find('b').unwrap(); // now the third column
11221        d.table_delete_column();
11222        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
11223    }
11224
11225    // ── ragged formats ───────────────────────────────────────────────────────
11226    // No format spells every gesture. HTML writes the inline marks as a tag pair
11227    // and no heading, list, quote or link; Markdown spells three of the eight
11228    // marks; djot spells all eight and no in-cell break. leaf asks twig per
11229    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
11230    // op discover the fact on its own — one of them didn't.
11231
11232    /// An HTML document in the rich view, ready for a gesture.
11233    fn html_doc(body: &str) -> Doc {
11234        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
11235        d.view = View::Wysiwyg;
11236        d.build_visual(80);
11237        d
11238    }
11239
11240    #[test]
11241    fn a_table_gesture_leaves_an_html_table_alone() {
11242        // The regression this guard exists for. twig's table editor consults no
11243        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
11244        // HTML `<table>` as a *pipe table* and reported success: the whole
11245        // element replaced by `| a | b |`, silently, on one press of a toolbar
11246        // button. Every grid op went the same way.
11247        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
11248        // A table of named operations, which is what it looks like.
11249        #[allow(clippy::type_complexity)]
11250        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
11251            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
11252            ("delete row", &|d: &mut Doc| d.table_delete_row()),
11253            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
11254            ("delete column", &|d: &mut Doc| d.table_delete_column()),
11255            ("align", &|d: &mut Doc| {
11256                d.table_set_alignment(Alignment::Right)
11257            }),
11258            ("move row", &|d: &mut Doc| d.table_move_row(true)),
11259            ("move column", &|d: &mut Doc| d.table_move_column(true)),
11260        ];
11261        for (name, op) in ops {
11262            let mut d = html_doc(src);
11263            d.caret = d.source.find('a').unwrap();
11264            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
11265            op(&mut d);
11266            assert_eq!(d.source, src, "{name} rewrote an HTML table");
11267            assert!(
11268                !d.dirty,
11269                "{name} marked the document dirty without editing it"
11270            );
11271            assert!(d.status.is_some(), "{name} refused without saying why");
11272        }
11273    }
11274
11275    #[test]
11276    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
11277        // A heading is a wrapping tag pair carrying its level in both ends, a
11278        // quote wraps a range rather than prefixing each line, a link's
11279        // destination lives in an attribute — different *shapes*, not a
11280        // different alphabet, so twig spells none of them and neither does leaf.
11281        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
11282        // A table of named operations, which is what it looks like.
11283        #[allow(clippy::type_complexity)]
11284        let ops: [(&str, &dyn Fn(&mut Doc)); 9] = [
11285            ("heading", &|d: &mut Doc| d.toggle_heading(2)),
11286            ("paragraph", &|d: &mut Doc| {
11287                d.set_block(BlockKind::Paragraph)
11288            }),
11289            ("quote", &|d: &mut Doc| d.toggle_blockquote()),
11290            ("list", &|d: &mut Doc| d.toggle_list(false)),
11291            ("task item", &|d: &mut Doc| d.toggle_task_item()),
11292            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
11293            ("link", &|d: &mut Doc| d.insert_link("https://example.dev")),
11294            ("image", &|d: &mut Doc| d.insert_image("pic.png", "alt")),
11295            ("video", &|d: &mut Doc| {
11296                d.insert_media(MediaKind::Video, "clip.mp4", "")
11297            }),
11298        ];
11299        for (name, op) in ops {
11300            let mut d = html_doc(src);
11301            let at = d.source.find("Hello").unwrap();
11302            d.caret = at;
11303            d.anchor = Some(at + 5); // a selection, for the ops that want one
11304            op(&mut d);
11305            assert_eq!(d.source, src, "{name} edited an HTML document");
11306            assert!(
11307                !d.dirty,
11308                "{name} marked the document dirty without editing it"
11309            );
11310            let status = d.status.as_deref().unwrap_or("");
11311            assert!(
11312                status.contains("html"),
11313                "{name}: the refusal should name the format, got {status:?}"
11314            );
11315        }
11316    }
11317
11318    #[test]
11319    fn html_spells_the_inline_marks_and_the_rule() {
11320        // The other half, and why one per-document flag stopped being enough:
11321        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
11322        // already emits and the parser reads straight back as the same mark —
11323        // and the rule button writes an `<hr>`. Refusing these on the old
11324        // "HTML is parse-only" reading would now be leaf's own limitation.
11325        let mut d = html_doc("<p>Hello world</p>\n");
11326        let at = d.source.find("world").unwrap();
11327        d.caret = at;
11328        d.anchor = Some(at + 5);
11329        d.toggle(InlineKind::Strong);
11330        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
11331        assert!(d.dirty);
11332        assert_eq!(d.status, None, "a supported gesture reports nothing");
11333
11334        // And off again — the toggle reverses, which is the property that makes
11335        // authoring in HTML worth offering rather than a one-way trip.
11336        d.toggle(InlineKind::Strong);
11337        assert_eq!(d.source, "<p>Hello world</p>\n");
11338
11339        let mut d = html_doc("<p>Hello world</p>\n");
11340        d.caret = d.source.find("world").unwrap();
11341        d.insert_thematic_break();
11342        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
11343    }
11344
11345    #[test]
11346    fn a_mark_the_format_cannot_spell_arms_nothing() {
11347        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
11348        // sticky mark for the next text typed. Guarding only the twig call
11349        // leaves that path live, promising a highlight the gesture will not
11350        // write and then swallowing the error inside `insert`.
11351        //
11352        // Markdown is still the case that carries this, and the reason is finer
11353        // than it was: since twig 3.3 leaf *parses* `==mark==` in Markdown, so a
11354        // highlight reads back and paints. What twig will not do is author one —
11355        // its Markdown syntax table spells the mark but marks it unauthorable,
11356        // because the reader on the other end may have the extension off. So the
11357        // button stays disabled over a Markdown document that renders highlights
11358        // perfectly well, and this test is what says so.
11359        let mut d = doc_with("mark", "Hello world\n");
11360        d.view = View::Wysiwyg;
11361        d.build_visual(80);
11362        d.caret = d.source.find("world").unwrap();
11363        d.toggle(InlineKind::Mark);
11364        assert!(d.pending_marks.is_empty(), "no mark should be armed");
11365        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
11366        d.insert("X");
11367        assert_eq!(d.source, "Hello Xworld\n");
11368    }
11369
11370    #[test]
11371    fn html_documents_still_take_typed_text() {
11372        // The guard covers *markup* gestures and must not touch plain editing:
11373        // twig's splicer is language-neutral, and typing into an HTML document
11374        // is the thing that does work today.
11375        let mut d = html_doc("<p>Hello world</p>\n");
11376        d.caret = d.source.find("world").unwrap();
11377        d.insert("big ");
11378        assert_eq!(d.source, "<p>Hello big world</p>\n");
11379        assert!(d.dirty);
11380        d.backspace();
11381        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
11382        d.undo();
11383        d.undo();
11384        assert_eq!(d.source, "<p>Hello world</p>\n");
11385    }
11386
11387    #[test]
11388    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
11389        // `authorable` only separates "there is a door in" from "there is not",
11390        // and HTML is on the near side of that line — which is exactly why a
11391        // toolbar must not be built from it.
11392        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
11393        assert!(html.authorable());
11394        assert!(
11395            !Doc::from_source("<r>x</r>".into(), Format::Xml)
11396                .unwrap()
11397                .authorable()
11398        );
11399
11400        let caps = html.capabilities();
11401        assert!(caps.bold && caps.italic && caps.code && caps.mark);
11402        assert!(caps.thematic_break && caps.cell_line_break);
11403        assert!(!caps.heading && !caps.blockquote && !caps.bullet_list);
11404        assert!(!caps.task && !caps.link && !caps.image && !caps.code_language);
11405        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
11406        // twig's table editor would happily re-emit as `| a | b |`.
11407        assert!(!caps.table);
11408
11409        // The two lightweight formats spell everything leaf offers — and still
11410        // differ from each other, which is the other half of why one boolean
11411        // can't serve.
11412        for fmt in [Format::Markdown, Format::Djot] {
11413            let caps = Capabilities::of(fmt);
11414            assert!(
11415                caps.heading && caps.blockquote && caps.ordered_list,
11416                "{fmt:?}"
11417            );
11418            assert!(
11419                caps.task && caps.link && caps.image && caps.table,
11420                "{fmt:?}"
11421            );
11422        }
11423        assert!(Capabilities::of(Format::Djot).mark);
11424        assert!(!Capabilities::of(Format::Markdown).mark);
11425        assert!(Capabilities::of(Format::Markdown).cell_line_break);
11426        assert!(!Capabilities::of(Format::Djot).cell_line_break);
11427
11428        // A parse-only format answers no to every one of them, so the coarse
11429        // predicate and the record agree there.
11430        let caps = Capabilities::of(Format::Xml);
11431        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
11432    }
11433
11434    #[test]
11435    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
11436        // The guard exists to name the *document's* format rather than twig's
11437        // internals, so the message has to survive being one leaf writes itself.
11438        // Checked against the gesture twig also refuses, since that is the pair
11439        // most at risk of drifting apart.
11440        let mut d = html_doc("<p>Hello</p>\n");
11441        d.caret = d.source.find("Hello").unwrap();
11442        d.set_code_language("zig");
11443        assert_eq!(
11444            d.status.as_deref(),
11445            Some("code language: not supported in html")
11446        );
11447        assert!(!d.dirty);
11448    }
11449
11450    #[test]
11451    fn table_set_alignment_respells_the_delimiter() {
11452        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
11453        d.caret = d.source.find('b').unwrap();
11454        d.table_set_alignment(Alignment::Right);
11455        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
11456    }
11457
11458    #[test]
11459    fn each_empty_table_cell_has_its_own_editable_home() {
11460        // Regression: an empty cell has no twig content_span, so both cells of a
11461        // `|  |  |` row collapsed onto the row's start (before the first `│`).
11462        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
11463        // tell the cells apart. Each empty cell must now have a distinct home
11464        // inside it.
11465        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
11466        let (c0, c1) = {
11467            let cells = &d.vmap.tables[0].grid[1].cells;
11468            (cells[0].start, cells[1].start)
11469        };
11470        assert!(
11471            c0 < c1,
11472            "the two empty cells have distinct homes: {c0} < {c1}"
11473        );
11474        d.caret = c0;
11475        d.insert("x");
11476        assert_eq!(
11477            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
11478            "typed inside the cell"
11479        );
11480    }
11481
11482    #[test]
11483    fn arrows_step_into_each_empty_table_cell() {
11484        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
11485        let (c0, c1) = {
11486            let cells = &d.vmap.tables[0].grid[1].cells;
11487            (cells[0].start, cells[1].start)
11488        };
11489        d.caret = d.source.find('b').unwrap(); // in the header's second cell
11490        let mut seen = std::collections::HashSet::new();
11491        for _ in 0..6 {
11492            d.move_right(false);
11493            seen.insert(d.caret);
11494        }
11495        assert!(
11496            seen.contains(&c0),
11497            "right arrow reaches the first empty cell"
11498        );
11499        assert!(
11500            seen.contains(&c1),
11501            "right arrow reaches the second empty cell"
11502        );
11503    }
11504
11505    #[test]
11506    fn table_op_off_a_table_is_a_no_op_with_a_status() {
11507        let mut d = doc_with("tbl_none", "just text\n");
11508        d.caret = 3;
11509        d.table_insert_row(true);
11510        assert_eq!(d.source, "just text\n", "nothing changed");
11511        assert!(d.status.is_some(), "a status explains why");
11512        assert!(!d.caret_in_table());
11513    }
11514
11515    #[test]
11516    fn enter_in_an_ordered_list_renumbers_the_following_items() {
11517        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
11518        // the renumber pass keeps them sequential, matching what the view draws.
11519        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
11520        d.caret = d.source.find('a').unwrap() + 1; // end of item a
11521        d.newline();
11522        d.insert("x");
11523        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
11524    }
11525
11526    #[test]
11527    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
11528        for view in [View::Source, View::Wysiwyg] {
11529            let mut d = doc_in(view, "outdent_noop", "hello\n");
11530            d.caret = 2;
11531            d.outdent();
11532            assert_eq!(d.source, "hello\n");
11533            assert!(!d.dirty, "a no-op is not a modification");
11534            d.undo();
11535            assert_eq!(
11536                d.status.as_deref(),
11537                Some("nothing to undo"),
11538                "spends no undo step"
11539            );
11540            assert_eq!(d.source, "hello\n");
11541        }
11542    }
11543
11544    #[test]
11545    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
11546        for view in [View::Source, View::Wysiwyg] {
11547            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
11548            d.anchor = Some(0);
11549            d.caret = 7; // through "two"
11550            d.indent();
11551            assert_eq!(
11552                d.source, "  one\n\n  two\n",
11553                "the blank line keeps no trailing pad"
11554            );
11555            // Selected, so a second Tab lands on the same lines rather than on
11556            // whatever the shifted offsets now cover.
11557            assert_eq!(d.selection(), Some((0, 12)));
11558            d.indent();
11559            assert_eq!(d.source, "    one\n\n    two\n");
11560        }
11561    }
11562
11563    #[test]
11564    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
11565        for view in [View::Source, View::Wysiwyg] {
11566            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
11567            d.anchor = Some(0);
11568            d.caret = 15;
11569            d.outdent();
11570            assert_eq!(d.source, "two\none\nnone\n");
11571        }
11572    }
11573
11574    #[test]
11575    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
11576        for view in [View::Source, View::Wysiwyg] {
11577            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
11578            d.anchor = Some(0);
11579            d.caret = 7;
11580            d.indent();
11581            assert_eq!(d.source, "  one\n\n  two\n");
11582            d.undo();
11583            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
11584            assert_eq!(
11585                d.selection(),
11586                Some((0, 7)),
11587                "with the selection it was aimed at"
11588            );
11589            d.redo();
11590            assert_eq!(d.source, "  one\n\n  two\n");
11591            assert_eq!(
11592                d.selection(),
11593                Some((0, 12)),
11594                "redo replays the caret the indent placed, not the one splice left"
11595            );
11596        }
11597    }
11598
11599    #[test]
11600    fn vertical_motion_keeps_the_column() {
11601        let mut d = doc_with("move", "abcd\nef\n");
11602        d.caret = 3; // "abc|d" on row 0, col 3
11603        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
11604        assert_eq!(d.caret, 7); // just after "ef"
11605    }
11606
11607    // ── goal column ──────────────────────────────────────────────────────────
11608
11609    #[test]
11610    fn vertical_motion_goal_column_survives_a_short_line() {
11611        // Regression: re-deriving the column from the clamped position on
11612        // every step permanently forgets it once a short line clamps it.
11613        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
11614        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
11615        assert_eq!(
11616            g("abcd|ef\nxy\nghijkl\n", |d| {
11617                d.move_down(false); // clamps to end of "xy"
11618                d.move_down(false); // restores col 4 on the long line
11619            }),
11620            "abcdef\nxy\nghij|kl\n"
11621        );
11622    }
11623
11624    #[test]
11625    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
11626        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
11627        assert_eq!(d.goal_col, None);
11628        d.caret = 4; // row 0, col 4
11629        d.move_down(false); // clamps into "xy"; goal stays the original col
11630        assert_eq!(d.goal_col, Some(4));
11631        assert_eq!(d.caret_pos(), (1, 2));
11632
11633        // A horizontal motion drops the goal column...
11634        d.move_left(false);
11635        assert_eq!(d.goal_col, None);
11636
11637        // ...so the next vertical motion picks up the *new* column (1), not
11638        // the stale one (4).
11639        d.move_down(false);
11640        assert_eq!(d.goal_col, Some(1));
11641        assert_eq!(d.caret_pos(), (2, 1));
11642    }
11643
11644    #[test]
11645    fn editing_clears_the_goal_column() {
11646        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
11647        d.caret = 4;
11648        d.move_down(false);
11649        assert_eq!(d.goal_col, Some(4));
11650        d.insert("Z");
11651        assert_eq!(d.goal_col, None);
11652    }
11653
11654    #[test]
11655    fn vertical_motion_on_an_empty_document_is_a_no_op() {
11656        let mut d = doc_with("empty_vert", "");
11657        d.move_down(false);
11658        assert_eq!(d.caret, 0);
11659        d.move_up(false);
11660        assert_eq!(d.caret, 0);
11661    }
11662
11663    // ── the document's edges ─────────────────────────────────────────────────
11664
11665    #[test]
11666    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
11667        // The reproduction, and the disagreement: Down on the last line ran to
11668        // the end of the document in the source view — by accident, an
11669        // out-of-range row clamping to the end of the string — and did nothing
11670        // whatever in the view leaf opens in. One rule now, in both.
11671        for (view, tag) in VIEWS {
11672            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
11673            d.caret = 1;
11674            d.move_down(false);
11675            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
11676            d.move_up(false);
11677            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
11678        }
11679    }
11680
11681    #[test]
11682    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
11683        // Down off the bottom is a motion like any other, so it latches a goal
11684        // column — and Up comes back to the column the caret left, not to the
11685        // one the document's end happened to be in.
11686        for (view, tag) in VIEWS {
11687            let gap = if view == View::Source { "\n" } else { "\n\n" };
11688            let src = format!("abcdef{gap}ghijkl");
11689            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
11690            d.caret = 2; // row 0, col 2
11691            d.move_down(false);
11692            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
11693            d.move_down(false);
11694            assert_eq!(
11695                d.caret,
11696                src.len(),
11697                "{tag}: Down off the bottom reaches the end"
11698            );
11699            d.move_up(false);
11700            assert_eq!(
11701                d.caret_pos().1,
11702                2,
11703                "{tag}: Up returns to the column Down left"
11704            );
11705        }
11706    }
11707
11708    #[test]
11709    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
11710        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
11711        // Up that did nothing still armed a goal column, and the next Down aimed
11712        // at a column the caret had never been in.
11713        for (view, tag) in VIEWS {
11714            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
11715            d.caret = 0;
11716            d.move_up(false);
11717            assert_eq!(d.caret, 0, "{tag}: already at the start");
11718            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
11719
11720            d.caret = d.source.len();
11721            d.move_down(false);
11722            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
11723            assert_eq!(
11724                d.goal_col, None,
11725                "{tag}: a no-op Down latched a goal column"
11726            );
11727        }
11728    }
11729
11730    // ── soft wrap ────────────────────────────────────────────────────────────
11731    // Every other test here builds the map at 80 columns, where no fixture is
11732    // long enough to fold. A wrap is where one offset belongs to two rows at
11733    // once, and it broke everything that asks the caret what row it is on.
11734
11735    /// The wrapped fixture these cases share, folded at 12 columns into
11736    /// `one two ` / `three four ` / `five six ` / `seven eight`.
11737    fn wrapped_doc(name: &str) -> Doc {
11738        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
11739        d.build_visual(12);
11740        d
11741    }
11742
11743    #[test]
11744    fn home_and_end_work_from_a_wrapped_row() {
11745        // The reproduction: offset 19 is the `f` of "five", the first character
11746        // of the third row — and also the offset the second row ends at. It
11747        // resolved to the *second* row, so End aimed at a place the caret was
11748        // already in and did nothing, while Home walked backwards onto a row the
11749        // caret had left.
11750        let mut d = wrapped_doc("wrap_home_end");
11751        d.caret = 19;
11752        assert_eq!(
11753            d.caret_pos(),
11754            (2, 0),
11755            "the wrap boundary opens the third row"
11756        );
11757        d.move_end(false);
11758        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
11759        d.move_home(false);
11760        assert_eq!(d.caret, 19, "Home left the row the caret was on");
11761    }
11762
11763    #[test]
11764    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
11765        // The row's end is the last offset that is only ever its own: the offset
11766        // past it opens the row below, and aiming there would send a second
11767        // press on to *that* row's end, and a third to the next — End walking
11768        // down the paragraph rather than sitting where it landed.
11769        let mut d = wrapped_doc("wrap_end_twice");
11770        d.caret = 12; // inside "three", on the second row
11771        d.move_end(false);
11772        assert_eq!(
11773            d.caret, 18,
11774            "the end of `three four`, before the space the wrap ate"
11775        );
11776        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
11777        d.move_end(false);
11778        assert_eq!(d.caret, 18, "a second End moved the caret");
11779        d.move_home(false);
11780        assert_eq!(d.caret, 8, "Home takes the row's own start");
11781    }
11782
11783    #[test]
11784    fn vertical_motion_crosses_a_soft_wrap() {
11785        // Down aimed at the row below's column 0, an offset that resolved *up*
11786        // to the row above's end — so it landed on the offset it already had and
11787        // the caret could never leave a paragraph's first row.
11788        let mut d = wrapped_doc("wrap_down");
11789        d.caret = 0;
11790        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
11791            d.move_down(false);
11792            assert_eq!(d.caret, want, "Down stalled");
11793            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
11794        }
11795        d.move_down(false);
11796        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
11797
11798        // ...and back up, one row per press. The goal column is the end of the
11799        // last row, past every other row's width, so each press clamps to the
11800        // row's own last offset rather than to the one that opens the next.
11801        let mut d = wrapped_doc("wrap_up");
11802        d.caret = 39;
11803        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
11804            d.move_up(false);
11805            assert_eq!(d.caret, want, "Up stalled");
11806            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
11807        }
11808    }
11809
11810    #[test]
11811    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
11812        // The kills take the same line Home and End do, so in WYSIWYG they take
11813        // the visual row — and a soft wrap has no newline in it to delete, so
11814        // nothing is joined by reaching the end of one.
11815        let mut d = wrapped_doc("wrap_kill");
11816        d.caret = 19; // the `f` of "five", opening the third row
11817        d.delete_to_line_end();
11818        // The space the wrap ate goes with the row it was drawn on: sparing it
11819        // would leave "four  seven", two spaces where the row had been.
11820        assert_eq!(d.source, "one two three four seven eight");
11821
11822        // Backwards from the row's last caret position — which is *before* that
11823        // space, so this one survives, being on the far side of the caret.
11824        let mut d = wrapped_doc("wrap_kill_back");
11825        d.caret = 27;
11826        d.delete_to_line_start();
11827        assert_eq!(d.source, "one two three four  seven eight");
11828    }
11829
11830    // ── document start / end ────────────────────────────────────────────────
11831
11832    #[test]
11833    fn move_doc_start_and_end_jump_to_the_edges() {
11834        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
11835        assert_eq!(
11836            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
11837            "|hello\nworld\n"
11838        );
11839        assert_eq!(
11840            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
11841            "hello\nworld\n|"
11842        );
11843        // Already at the edge: a no-op.
11844        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
11845        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
11846    }
11847
11848    #[test]
11849    fn move_doc_start_and_end_extend_the_selection() {
11850        assert_eq!(
11851            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
11852                .move_doc_end(true)),
11853            "hello wor[ld\n|]"
11854        );
11855        assert_eq!(
11856            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
11857                .move_doc_start(true)),
11858            "[|hello wor]ld\n"
11859        );
11860    }
11861
11862    #[test]
11863    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
11864        let mut d = doc_with("empty_edges", "");
11865        d.move_doc_end(false);
11866        assert_eq!(d.caret, 0);
11867        d.move_doc_start(false);
11868        assert_eq!(d.caret, 0);
11869    }
11870
11871    // ── arrow collapses an active selection ─────────────────────────────────
11872
11873    #[test]
11874    fn arrow_collapses_selection_to_its_near_edge() {
11875        let mut d = doc_with("collapse", "hello world\n");
11876
11877        // Forward selection (anchor before caret): Right -> end, Left -> start.
11878        d.anchor = Some(2);
11879        d.caret = 7;
11880        d.move_right(false);
11881        assert_eq!((d.caret, d.anchor), (7, None));
11882
11883        d.anchor = Some(2);
11884        d.caret = 7;
11885        d.move_left(false);
11886        assert_eq!((d.caret, d.anchor), (2, None));
11887
11888        // Backward selection (anchor after caret): edges are the same
11889        // regardless of which end the caret started on.
11890        d.anchor = Some(7);
11891        d.caret = 2;
11892        d.move_right(false);
11893        assert_eq!((d.caret, d.anchor), (7, None));
11894
11895        d.anchor = Some(7);
11896        d.caret = 2;
11897        d.move_left(false);
11898        assert_eq!((d.caret, d.anchor), (2, None));
11899    }
11900
11901    #[test]
11902    fn arrow_with_extend_keeps_growing_the_selection() {
11903        let mut d = doc_with("collapse_extend", "hello world\n");
11904        d.anchor = Some(2);
11905        d.caret = 7;
11906        d.move_right(true); // extend: no collapse, caret steps one further
11907        assert_eq!((d.caret, d.anchor), (8, Some(2)));
11908    }
11909
11910    #[test]
11911    fn arrow_without_a_selection_moves_one_character_as_before() {
11912        let mut d = doc_with("no_collapse", "hello\n");
11913        d.caret = 2;
11914        d.move_right(false);
11915        assert_eq!(d.caret, 3);
11916        d.move_left(false);
11917        assert_eq!(d.caret, 2);
11918    }
11919
11920    /// Press Right until it stops, collecting the offsets walked through. Every
11921    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
11922    /// two stops sharing one source offset can't be moved between, so the caret
11923    /// stalls on the first of them and the walk never reaches the rest.
11924    fn walk_right(d: &mut Doc) -> Vec<usize> {
11925        let mut seen = vec![d.caret];
11926        for _ in 0..2000 {
11927            let before = d.caret;
11928            d.move_right(false);
11929            if d.caret == before {
11930                break;
11931            }
11932            seen.push(d.caret);
11933        }
11934        seen
11935    }
11936
11937    #[test]
11938    fn the_caret_crosses_a_soft_break() {
11939        // A newline inside a paragraph is a `soft_break`, which twig gives no
11940        // span of its own — the space it renders as used to borrow the offset of
11941        // the character before it, and a caret can't move without changing
11942        // offset. Right must walk clean off the end of the first line.
11943        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
11944        d.caret = 0;
11945        let seen = walk_right(&mut d);
11946        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11947    }
11948
11949    #[test]
11950    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
11951        // The paragraph holds one soft break. Folded (the default) it lays out as
11952        // a single reflowed row; Preserve re-lays it as a row per source line.
11953        // The setter must invalidate the cached map for the change to show, and
11954        // again on the way back — so a round trip returns to the folded layout.
11955        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
11956        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
11957        d.build_visual(80);
11958        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
11959
11960        d.set_line_flow(LineFlow::Preserve);
11961        d.build_visual(80);
11962        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
11963
11964        d.set_line_flow(LineFlow::Fold);
11965        d.build_visual(80);
11966        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
11967    }
11968
11969    #[test]
11970    fn the_caret_still_crosses_a_preserved_soft_break() {
11971        // Preserve renders the soft break as a row boundary rather than a space,
11972        // but the caret must still reach every offset — the break's own offset is
11973        // the first row's end stop, so Right walks clean off the end of line one
11974        // onto line two, exactly as it does when the break is folded.
11975        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
11976        d.set_line_flow(LineFlow::Preserve);
11977        d.build_visual(80);
11978        d.caret = 0;
11979        let seen = walk_right(&mut d);
11980        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
11981    }
11982
11983    #[test]
11984    fn the_caret_walks_a_code_block() {
11985        // Every glyph of a code block used to map to the block's start, so the
11986        // whole block was a single offset and the caret couldn't move inside it.
11987        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
11988        let mut d = wysiwyg_doc("code_walk", src);
11989        d.caret = 0;
11990        let seen = walk_right(&mut d);
11991        // The fences are markup: hidden, and no caret stop. The code between
11992        // them is reached a character at a time.
11993        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
11994        for off in code.clone() {
11995            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
11996        }
11997        assert!(seen.contains(&code.end), "no stop after the last line");
11998    }
11999
12000    #[test]
12001    fn the_caret_walks_an_indented_code_block() {
12002        // An indented block's text has the four-space indent stripped, so it
12003        // isn't a verbatim slice and its lines have to be re-found. The caret
12004        // lands on the code, never in the indent.
12005        let src = "    indented\n    code\n";
12006        let mut d = wysiwyg_doc("indent_code_walk", src);
12007        d.caret = 0;
12008        let seen = walk_right(&mut d);
12009        assert!(seen.contains(&src.find("indented").unwrap()));
12010        assert!(seen.contains(&src.find("code").unwrap()));
12011        assert!(
12012            !seen.contains(&0) || seen[0] == 0,
12013            "the caret starts where it was put"
12014        );
12015        // Nothing in the stripped indent is a stop.
12016        for off in [1, 2, 3] {
12017            assert!(!seen.contains(&off), "landed in the indent at {off}");
12018        }
12019    }
12020
12021    #[test]
12022    fn the_caret_leaves_a_tight_heading() {
12023        // "# H" with text directly under it: the heading row's end and the
12024        // separator row's end are the same offset. Right used to find the
12025        // separator's copy, set the caret to where it already was, and stop.
12026        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
12027        d.caret = 2; // the "H"
12028        let seen = walk_right(&mut d);
12029        assert!(
12030            seen.len() > 2,
12031            "Right stalled at the heading's end: {seen:?}"
12032        );
12033        assert!(
12034            seen.contains(&8),
12035            "never reached the end of \"text\": {seen:?}"
12036        );
12037    }
12038
12039    #[test]
12040    fn the_caret_skips_the_gap_between_two_paragraphs() {
12041        // The blank line between two paragraphs is the boundary itself. The
12042        // caret used to be able to sit on it, and typing there landed in the
12043        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
12044        // soft break, so the text visibly snapped back up.
12045        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
12046        d.caret = 1; // the end of "A"
12047        d.move_right(false);
12048        assert_eq!(d.caret, 3, "Right stopped in the gap");
12049        d.insert("x");
12050        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
12051    }
12052
12053    #[test]
12054    fn down_from_a_paragraph_lands_on_the_next_one() {
12055        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
12056        d.caret = 0;
12057        d.move_down(false);
12058        assert_eq!(d.caret, 3, "Down stopped in the gap");
12059    }
12060
12061    #[test]
12062    fn clicking_the_gap_lands_on_real_text() {
12063        // A click can still *reach* the gap — it's drawn, so it's clickable.
12064        // It has to resolve to somewhere the caret can be.
12065        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
12066        d.click(1, 0, false); // the gap row
12067        assert!(
12068            d.caret == 1 || d.caret == 3,
12069            "click left the caret in the gap at {}",
12070            d.caret
12071        );
12072        d.insert("x");
12073        // Either edge of the boundary is a fair place to land; inside it isn't.
12074        assert!(
12075            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
12076            "click in the gap typed into the boundary: {:?}",
12077            d.source
12078        );
12079    }
12080
12081    #[test]
12082    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
12083        // Enter inserts a paragraph break, which leaves a blank line spare on
12084        // either side of a new one. That middle line is a real empty paragraph:
12085        // the caret lands there, and typing makes a paragraph rather than
12086        // extending a neighbour.
12087        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
12088        d.caret = 1;
12089        d.newline();
12090        assert_eq!(d.source, "A\n\n\n\nB\n");
12091        d.build_visual(80);
12092        let (row, _) = d.caret_pos();
12093        assert!(
12094            d.vmap.row_is_navigable(row),
12095            "the caret landed on a gap row"
12096        );
12097        d.insert("x");
12098        assert_eq!(
12099            d.source, "A\n\nx\n\nB\n",
12100            "the new paragraph merged into a neighbour"
12101        );
12102    }
12103
12104    #[test]
12105    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
12106        let mut d = wysiwyg_doc("gap_eof", "A\n");
12107        d.caret = 1;
12108        d.newline();
12109        d.build_visual(80);
12110        let (row, _) = d.caret_pos();
12111        assert!(
12112            d.vmap.row_is_navigable(row),
12113            "the caret landed on a gap row"
12114        );
12115        d.insert("x");
12116        assert!(
12117            d.source.starts_with("A\n\n") && d.source.contains('x'),
12118            "typing at the end merged into A: {:?}",
12119            d.source
12120        );
12121    }
12122
12123    #[test]
12124    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
12125        // A paragraph broken over two source lines is one paragraph. Selecting
12126        // it must not stop at the newline inside it — that newline is markup the
12127        // rich-text view exists to hide.
12128        let src = "one two\nthree four\n\nnext\n";
12129        let mut d = wysiwyg_doc("triple_para", src);
12130        d.select_block_at(2);
12131        assert_eq!(
12132            d.selected_text(),
12133            Some("one two\nthree four"),
12134            "stopped at the soft break"
12135        );
12136    }
12137
12138    #[test]
12139    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
12140        // The reader scrolls down past the caret's row. Nothing moved the
12141        // caret, so the view must stay where it was put — the old code revealed
12142        // the caret every frame, which dragged the view straight back and made
12143        // the document unscrollable past the caret.
12144        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
12145        d.caret = 0;
12146        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
12147        d.scroll = 4; // the wheel
12148        d.follow_caret(0, 3, 9);
12149        assert_eq!(
12150            d.scroll, 4,
12151            "the wheel was overruled by a caret that never moved"
12152        );
12153    }
12154
12155    #[test]
12156    fn moving_the_caret_brings_the_view_back_to_it() {
12157        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
12158        d.caret = 0;
12159        d.follow_caret(0, 3, 9);
12160        d.scroll = 6; // scrolled away
12161        d.move_right(false); // ...and now the caret moves
12162        let (row, _) = d.caret_pos();
12163        d.follow_caret(row, 3, 9);
12164        assert!(
12165            d.scroll <= row && row < d.scroll + 3,
12166            "caret row {row} off screen at scroll {}",
12167            d.scroll
12168        );
12169    }
12170
12171    #[test]
12172    fn scrolling_stops_at_the_last_row() {
12173        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
12174        d.caret = 0;
12175        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
12176        d.scroll = 999; // the wheel, spun hard
12177        d.follow_caret(0, 3, 3);
12178        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
12179    }
12180
12181    #[test]
12182    fn every_cell_of_a_wide_table_is_reachable() {
12183        // A table whose cells are far wider than the surface: the columns are
12184        // cut to fit and the text wraps inside them, so no cell hangs off the
12185        // right edge where the caret can never go.
12186        let src = "| Ingredient | Notes |\n|---|---|\n\
12187                   | flour milled coarse | sift it twice before folding it in |\n";
12188        let mut d = wysiwyg_doc("wide_table_walk", src);
12189        d.build_visual(30);
12190        d.caret = 0;
12191        let seen = walk_right(&mut d);
12192        for word in ["Ingredient", "Notes", "coarse", "folding"] {
12193            let at = src.find(word).unwrap();
12194            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
12195        }
12196    }
12197
12198    // ── view parity ──────────────────────────────────────────────────────────
12199    // `doc_with` pins the source view, so everything above tests a view users
12200    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
12201    // deletion golden cases through *both*, plus the WYSIWYG cases the two
12202    // can't share: where the source carries markup the rendered text is a
12203    // different string, and the views agreeing would itself be the bug.
12204
12205    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
12206
12207    /// Run `action` in both views on one `|`-marked fixture and assert they
12208    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
12209    /// source verbatim, so the two views are looking at the same text and any
12210    /// disagreement is one of them having lost the plot.
12211    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
12212        let (src, caret) = parse_caret(marked);
12213        let run = |view: View, tag: &str| {
12214            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
12215            d.caret = caret;
12216            action(&mut d);
12217            render_caret(&d)
12218        };
12219        let source = run(VIEWS[0].0, VIEWS[0].1);
12220        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
12221        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
12222        source
12223    }
12224
12225    #[test]
12226    fn word_motion_agrees_across_the_views_on_plain_prose() {
12227        let g = both_views;
12228        assert_eq!(
12229            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
12230            "hello |world"
12231        );
12232        assert_eq!(
12233            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
12234            "|hello world"
12235        );
12236        assert_eq!(
12237            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
12238            "hello| world"
12239        );
12240        assert_eq!(
12241            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
12242            "hello world|"
12243        );
12244        assert_eq!(
12245            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
12246            "foo|.bar"
12247        );
12248        assert_eq!(
12249            g("par_ext", "hello |world", |d| d.move_word_right(true)),
12250            "hello [world|]"
12251        );
12252    }
12253
12254    #[test]
12255    fn word_deletion_agrees_across_the_views_on_plain_prose() {
12256        let g = both_views;
12257        assert_eq!(
12258            g("par_db", "hello world|", |d| d.delete_word_back()),
12259            "hello |"
12260        );
12261        assert_eq!(
12262            g("par_df", "hello |world", |d| d.delete_word_forward()),
12263            "hello |"
12264        );
12265        assert_eq!(
12266            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
12267            "|bar baz"
12268        );
12269        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
12270    }
12271
12272    #[test]
12273    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
12274        let g = both_views;
12275        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
12276        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
12277        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
12278        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
12279    }
12280
12281    #[test]
12282    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
12283        // The reproduction: the stop table was built one stop per `char`, so
12284        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
12285        // source view, which steps by grapheme, can't reach and backspace can't
12286        // survive. The two views must land on the same offset.
12287        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
12288        for (view, tag) in VIEWS {
12289            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
12290            d.caret = 1;
12291            d.move_right(false);
12292            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
12293
12294            // ...and the edit that used to sever a joiner off the front of it.
12295            d.backspace();
12296            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
12297            assert_eq!(d.caret, 1);
12298        }
12299    }
12300
12301    #[test]
12302    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
12303        for (view, tag) in VIEWS {
12304            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
12305            d.caret = 0;
12306            d.move_right(false);
12307            assert_eq!(
12308                d.caret,
12309                "e\u{0301}".len(),
12310                "{tag} stopped on the combining mark"
12311            );
12312        }
12313    }
12314
12315    #[test]
12316    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
12317        // The general form: whatever route the caret takes through a document
12318        // full of clusters, it never lands between the codepoints of one — so no
12319        // motion-then-backspace sequence can leave a dangling joiner behind.
12320        use unicode_segmentation::UnicodeSegmentation;
12321
12322        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
12323        let mut d = wysiwyg_doc("cluster_walk", src);
12324        d.caret = 0;
12325        let boundaries: Vec<usize> = src
12326            .grapheme_indices(true)
12327            .map(|(i, _)| i)
12328            .chain(std::iter::once(src.len()))
12329            .collect();
12330        for off in walk_right(&mut d) {
12331            assert!(
12332                boundaries.contains(&off),
12333                "Right stopped at {off}, inside a grapheme cluster"
12334            );
12335        }
12336    }
12337
12338    #[test]
12339    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
12340        // The reproduction: ⌥→ from inside the opening `**` computed its
12341        // boundary over the raw source and landed on byte 8 — inside the
12342        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
12343        // "bold". The caret drew past the bold word and sat inside it.
12344        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
12345        d.caret = 2;
12346        d.move_word_right(false);
12347        assert!(
12348            d.vmap.is_stop(d.caret),
12349            "landed at {}, not a caret stop",
12350            d.caret
12351        );
12352        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
12353        // The rendered row is "a bold c": column 6 is the space just past "bold",
12354        // and now the caret is really there rather than only drawn there.
12355        assert_eq!(d.caret_pos(), (0, 6));
12356
12357        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
12358        d.move_word_left(false);
12359        assert_eq!(d.caret, 4);
12360        assert_eq!(d.caret_pos(), (0, 2));
12361    }
12362
12363    #[test]
12364    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
12365        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
12366        // inside the closing `**`, and left "a ** c\n" — delimiters with no
12367        // opener. Glyph space covers the word alone, which would leave
12368        // "a **** c": markup wrapped around nothing. The word and the styling
12369        // that was only ever the word's go together.
12370        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
12371        d.caret = 10;
12372        d.delete_word_back();
12373        assert_eq!(d.source, "a  c\n");
12374        assert_eq!(d.caret, 2);
12375
12376        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
12377        d.caret = 4; // the "b"
12378        d.delete_word_forward();
12379        assert_eq!(d.source, "a  c\n");
12380    }
12381
12382    #[test]
12383    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
12384        let src = "a ***bold*** c\n";
12385        let mut d = wysiwyg_doc("wys_word_del_nest", src);
12386        d.caret = src.find(" c").unwrap();
12387        d.delete_word_back();
12388        assert_eq!(
12389            d.source, "a  c\n",
12390            "the emph inside the strong empties it too"
12391        );
12392
12393        let src = "a `code` c\n";
12394        let mut d = wysiwyg_doc("wys_word_del_code", src);
12395        d.caret = src.find(" c").unwrap();
12396        d.delete_word_back();
12397        assert_eq!(d.source, "a  c\n");
12398    }
12399
12400    #[test]
12401    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
12402        // Only an *emptied* node goes. Take one word of two and the `**` still
12403        // has a job to do — over the word that's left, with the space the delete
12404        // pushed against the opening delimiter moved out in front of it, or the
12405        // run would be no run at all (`** words**` is literal asterisks — see
12406        // the mark-edge rule on `splice`).
12407        let src = "a **two words** c\n";
12408        let mut d = wysiwyg_doc("wys_word_del_partial", src);
12409        d.caret = src.find(" words").unwrap();
12410        d.delete_word_back();
12411        assert_eq!(d.source, "a  **words** c\n");
12412    }
12413
12414    #[test]
12415    fn source_view_word_motion_still_walks_the_markup() {
12416        // The other half of the decision: in the source view the `**` are
12417        // characters like any other — they're on the screen, so word motion has
12418        // to stop at them and a word-delete has to leave them behind. Only
12419        // WYSIWYG hides them, so only WYSIWYG steps over them.
12420        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
12421        assert_eq!(
12422            g("src_word_motion", "a |**bold** c\n", |d| d
12423                .move_word_right(false)),
12424            "a **bold|** c\n"
12425        );
12426        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
12427        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
12428        assert_eq!(
12429            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
12430            "a **| c\n"
12431        );
12432    }
12433
12434    #[test]
12435    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
12436        // The single invariant both bugs violated: the caret draws and edits at
12437        // the same place only when it's on a stop. `debug_assert_on_a_stop`
12438        // makes the same claim in-place; this pins it from the outside, over a
12439        // document with every kind of thing the map has to be careful about.
12440        // At two widths: the wide one every other test builds at, where no
12441        // fixture folds, and one narrow enough that they all do. A soft wrap is
12442        // where an offset stops being on exactly one row, and testing only the
12443        // width that never wraps is how the caret came to be pinned at the first
12444        // one Down reached.
12445        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
12446                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
12447        // A table of named operations, which is what it looks like.
12448        #[allow(clippy::type_complexity)]
12449        let motions: [(&str, fn(&mut Doc)); 8] = [
12450            ("right", |d| d.move_right(false)),
12451            ("left", |d| d.move_left(false)),
12452            ("word_right", |d| d.move_word_right(false)),
12453            ("word_left", |d| d.move_word_left(false)),
12454            ("down", |d| d.move_down(false)),
12455            ("up", |d| d.move_up(false)),
12456            ("home", |d| d.move_home(false)),
12457            ("end", |d| d.move_end(false)),
12458        ];
12459        for width in [80, 12] {
12460            let mut d = wysiwyg_doc("stop_invariant", src);
12461            d.build_visual(width);
12462            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12463            assert!(stops.len() > 20, "fixture should have plenty of stops");
12464            for start in stops {
12465                for (name, motion) in &motions {
12466                    d.caret = start;
12467                    d.anchor = None;
12468                    motion(&mut d);
12469                    assert!(
12470                        d.vmap.is_stop(d.caret),
12471                        "{name} from {start} at width {width} landed at {} — not a caret stop",
12472                        d.caret
12473                    );
12474                }
12475            }
12476        }
12477    }
12478
12479    #[test]
12480    fn no_wysiwyg_motion_is_a_dead_end() {
12481        // Down held to the bottom of a document reaches the bottom, and Up held
12482        // to the top reaches the top — from anywhere, at a width that wraps. The
12483        // invariant above says a motion lands somewhere legal; this one says it
12484        // gets somewhere at all, which is what a caret pinned at a wrap boundary
12485        // was quietly failing to do while every assertion around it held.
12486        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
12487                   - item one two three four five\n\nlast\n";
12488        for width in [80, 12] {
12489            let mut d = wysiwyg_doc("no_dead_end", src);
12490            d.build_visual(width);
12491            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12492            let (first, last) = (stops[0], stops[stops.len() - 1]);
12493            for &start in &stops {
12494                for (name, motion, want) in [
12495                    (
12496                        "down",
12497                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
12498                        last,
12499                    ),
12500                    ("up", |d: &mut Doc| d.move_up(false), first),
12501                ] {
12502                    d.caret = start;
12503                    d.anchor = None;
12504                    d.goal_col = None;
12505                    // Every row, plus the presses the edges take, plus slack.
12506                    for _ in 0..d.vmap.num_rows() + 4 {
12507                        motion(&mut d);
12508                    }
12509                    assert_eq!(
12510                        d.caret, want,
12511                        "{name} held from {start} at width {width} never arrived"
12512                    );
12513                }
12514            }
12515        }
12516    }
12517    // ── display columns ──────────────────────────────────────────────────────
12518    // A `col` is a terminal cell, not a character. The two are the same number
12519    // for the ASCII the fixtures above are written in, which is how they came
12520    // apart in the first place: `你` is one character drawn in two cells, so a
12521    // column counted in characters names a cell the text isn't in — one earlier
12522    // for every wide character to its left.
12523
12524    #[test]
12525    fn a_wide_character_is_two_columns_wide() {
12526        // The reproduction: `你` is one char and two cells, so the caret just
12527        // past it drew at column 1 — inside the character it had already left.
12528        for (view, tag) in VIEWS {
12529            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
12530            d.caret = "你".len();
12531            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
12532            d.caret = "你好".len();
12533            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
12534        }
12535    }
12536
12537    #[test]
12538    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
12539        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
12540        // two-cell — measuring six cells one at a time, but the character they
12541        // spell is drawn in two. Width belongs to the cluster, not the glyph,
12542        // and the frontends measure it the same way.
12543        let family = "👨‍👩‍👧";
12544        for (view, tag) in VIEWS {
12545            let src = format!("a{family}b\n");
12546            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
12547            d.caret = 1 + family.len();
12548            assert_eq!(
12549                d.caret_pos(),
12550                (0, 3),
12551                "{tag}: 'a' is one cell, the family two"
12552            );
12553        }
12554    }
12555
12556    #[test]
12557    fn both_cells_of_a_wide_character_mean_the_character() {
12558        // Clicking the far half of `好` is still clicking `好`: half a character
12559        // is not a place the caret can be, so it comes to rest at the
12560        // character's start — the column it would have been drawn at anyway.
12561        for (view, tag) in VIEWS {
12562            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
12563            for col in [2, 3] {
12564                d.caret = 0;
12565                d.click(0, col, false);
12566                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
12567                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
12568            }
12569            // Past the last cell is the line's end, as it is for ASCII.
12570            d.click(0, 9, false);
12571            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
12572        }
12573    }
12574
12575    #[test]
12576    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
12577        // The mapping is only a mapping if it inverts: the cell the caret is
12578        // drawn in has to be the cell that brings it back to the same offset.
12579        // Over a fixture where a character may be one cell or two, and one
12580        // codepoint or five.
12581        use unicode_segmentation::UnicodeSegmentation;
12582
12583        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
12584
12585        let mut d = doc_in(View::Source, "roundtrip_source", src);
12586        // Every offset the source view's caret can occupy: it steps by grapheme
12587        // cluster, so those are its boundaries.
12588        for (off, _) in src
12589            .grapheme_indices(true)
12590            .chain(std::iter::once((src.len(), "")))
12591        {
12592            d.caret = off;
12593            let (row, col) = d.caret_pos();
12594            d.click(row, col, false);
12595            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
12596        }
12597
12598        // And in WYSIWYG, where the offsets the caret can occupy are the map's
12599        // stops rather than every boundary.
12600        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
12601        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
12602        assert!(stops.len() > 20, "fixture should have plenty of stops");
12603        for off in stops {
12604            d.caret = off;
12605            let (row, col) = d.caret_pos();
12606            d.click(row, col, false);
12607            assert_eq!(
12608                d.caret, off,
12609                "wysiwyg: {off} → ({row}, {col}) → {}",
12610                d.caret
12611            );
12612        }
12613    }
12614
12615    #[test]
12616    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
12617        // Down from under `世` lands under the glyph in that cell, not two
12618        // characters further along the line. The goal is a column, so a line of
12619        // wide characters and a line of ASCII line up the way they're drawn.
12620        //
12621        // The gap differs by view: a bare newline inside a paragraph is a soft
12622        // break, which WYSIWYG draws as a space on a single row. The views share
12623        // a grid only where the source's lines are the renderer's rows too.
12624        for (view, tag) in VIEWS {
12625            let gap = if view == View::Source { "\n" } else { "\n\n" };
12626            let src = format!("你好世{gap}abcdef\n");
12627            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
12628            d.caret = "你好".len();
12629            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
12630            d.move_down(false);
12631            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
12632            assert!(
12633                d.source[d.caret..].starts_with('e'),
12634                "{tag}: landed on the wrong glyph"
12635            );
12636        }
12637    }
12638
12639    #[test]
12640    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
12641        // Down from column 3 onto `你好`, whose characters start at columns 0
12642        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
12643        // between the cells of one character, so the caret rests on it — and on
12644        // its start, which is the only offset there that is a caret stop.
12645        for (view, tag) in VIEWS {
12646            let gap = if view == View::Source { "\n" } else { "\n\n" };
12647            let src = format!("abcdef{gap}你好\n");
12648            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
12649            let line = src.find('你').unwrap();
12650            d.caret = 3;
12651            d.move_down(false);
12652            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
12653            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
12654        }
12655    }
12656
12657    #[test]
12658    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
12659        // The column the cell's text is laid out in is measured in cells, so the
12660        // caret walking that text has to be too — the two agreeing is the whole
12661        // point of the grid staying square.
12662        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
12663        let at = d.source.find("你").unwrap();
12664        d.caret = at;
12665        let (row, col) = d.caret_pos();
12666        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
12667        // cells further along.
12668        assert_eq!(col, 2, "the cell's first character");
12669        d.move_right(false);
12670        assert_eq!(
12671            d.caret_pos(),
12672            (row, 4),
12673            "`好` is drawn past `你`'s two cells"
12674        );
12675        assert_eq!(d.caret, at + "你".len());
12676    }
12677
12678    // ── active inline marks ───────────────────────────────────────────────────
12679
12680    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
12681    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
12682        let (src, caret) = parse_caret(marked);
12683        let mut d = doc_in(view, name, &src);
12684        d.caret = caret;
12685        d.active_inline_marks().iter().collect()
12686    }
12687
12688    /// The marks over the selection `[start, end)`.
12689    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
12690        let mut d = doc_in(view, name, src);
12691        d.anchor = Some(start);
12692        d.caret = end;
12693        d.active_inline_marks().iter().collect()
12694    }
12695
12696    #[test]
12697    fn a_caret_in_a_mark_reports_it() {
12698        for (view, tag) in VIEWS {
12699            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
12700            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
12701            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
12702            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
12703            // Plain text under no mark lights nothing — the toolbar's resting state.
12704            assert_eq!(m("a| **bold** b"), [], "{tag}");
12705            assert!(m("plain t|ext").is_empty(), "{tag}");
12706        }
12707    }
12708
12709    #[test]
12710    fn nested_marks_all_report() {
12711        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
12712        // the ancestor chain is a chain, and every mark on it is in force.
12713        for (view, tag) in VIEWS {
12714            assert_eq!(
12715                marks(
12716                    view,
12717                    &format!("marks_nested_{tag}"),
12718                    "**bold and *bo|th*** end"
12719                ),
12720                [InlineKind::Strong, InlineKind::Emph],
12721                "{tag}"
12722            );
12723        }
12724    }
12725
12726    #[test]
12727    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
12728        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
12729        // the first byte of its text and the byte after its last — both inside
12730        // the mark's span, both places typing lands inside the bold. The offset
12731        // past the closing delimiter is the next text, and reports nothing.
12732        let src = "a **bold** b";
12733        let inner_start = src.find("bold").unwrap(); // 4
12734        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
12735        for (view, tag) in VIEWS {
12736            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
12737            for off in [2, 3, inner_start, inner_end, 9] {
12738                d.caret = off;
12739                assert!(
12740                    d.active_inline_marks().contains(InlineKind::Strong),
12741                    "{tag}: offset {off} is inside the strong span"
12742                );
12743            }
12744            for off in [0, 1, 10, 11, 12] {
12745                d.caret = off;
12746                assert!(
12747                    !d.active_inline_marks().contains(InlineKind::Strong),
12748                    "{tag}: offset {off} is outside the strong run"
12749                );
12750            }
12751        }
12752    }
12753
12754    #[test]
12755    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
12756        // Regression: twig resolves an offset that is one node's end and the
12757        // next one's start to the node that *starts* there, so `**bold**|\n`
12758        // isn't bold. With nothing following there's no tie to break and the
12759        // chain still ended at the mark, which made a trailing `\n` — not the
12760        // text — decide whether the caret after a bold word reported bold. It's
12761        // the offset past the mark either way, and typing there is plain either
12762        // way. A blank document typed into is exactly this shape.
12763        for (view, tag) in VIEWS {
12764            let m = |name: String, marked| marks(view, &name, marked);
12765            assert_eq!(
12766                m(format!("marks_eob_{tag}"), "**bold**|"),
12767                [],
12768                "{tag}: no trailing newline"
12769            );
12770            assert_eq!(
12771                m(format!("marks_eol_{tag}"), "**bold**|\n"),
12772                [],
12773                "{tag}: with one"
12774            );
12775            // And the last offset that *is* in the mark still is.
12776            assert_eq!(
12777                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
12778                [InlineKind::Strong],
12779                "{tag}"
12780            );
12781        }
12782    }
12783
12784    #[test]
12785    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
12786        let src = "a **bold** b";
12787        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
12788        for (view, tag) in VIEWS {
12789            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
12790            // The whole bold word, and a slice of it.
12791            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
12792            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
12793            // Ending exactly at the closing delimiter's start is still all-bold:
12794            // an exclusive end sits *past* the last selected character, so the
12795            // question is asked of the character, not the boundary.
12796            assert_eq!(
12797                m(b, d_ + 2),
12798                [InlineKind::Strong],
12799                "{tag}: through the close"
12800            );
12801            // Half in, half out: Bold lit here would claim a press turns it off.
12802            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
12803            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
12804        }
12805    }
12806
12807    #[test]
12808    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
12809        // Both ends are bold, but the space between them isn't — two runs are two
12810        // nodes, which is exactly what the node id catches and a kind-only
12811        // comparison would not.
12812        let src = "**one** **two**";
12813        for (view, tag) in VIEWS {
12814            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
12815            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
12816        }
12817    }
12818
12819    #[test]
12820    fn marks_read_the_document_as_it_is_edited() {
12821        // The point of asking twig every frame instead of caching: the answer has
12822        // to follow the toggle that changed it.
12823        let mut d = wysiwyg_doc("marks_live", "one two\n");
12824        d.anchor = Some(0);
12825        d.caret = 3;
12826        assert!(d.active_inline_marks().is_empty(), "plain to start");
12827        d.toggle(InlineKind::Strong);
12828        assert_eq!(d.source, "**one** two\n");
12829        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
12830        assert!(d.active_inline_marks().contains(InlineKind::Strong));
12831        d.toggle(InlineKind::Strong);
12832        assert!(d.active_inline_marks().is_empty(), "and off again");
12833    }
12834
12835    #[test]
12836    fn a_link_is_not_an_inline_mark() {
12837        // `link`/`str` are inline nodes, but nothing on the inline toolbar
12838        // toggles them — a set with a "link mark" in it would have no button.
12839        for (view, tag) in VIEWS {
12840            assert_eq!(
12841                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
12842                [],
12843                "{tag}"
12844            );
12845        }
12846    }
12847
12848    // ── blank documents ───────────────────────────────────────────────────────
12849
12850    #[test]
12851    fn a_blank_document_is_untitled_empty_and_markdown() {
12852        let mut d = Doc::blank().unwrap();
12853        assert!(d.is_untitled());
12854        assert_eq!(d.path, PathBuf::new());
12855        assert_eq!(
12856            d.file_name(),
12857            "untitled",
12858            "the header has to show something"
12859        );
12860        assert_eq!(d.format_name(), "markdown");
12861        assert_eq!(d.source, "");
12862        assert!(!d.dirty, "nothing typed yet is nothing to lose");
12863        assert_eq!(d.disk_state(), DiskState::Untitled);
12864        // And it's a document you can be in: the default view renders it.
12865        d.build_visual(80);
12866        assert_eq!(d.caret, 0);
12867    }
12868
12869    #[test]
12870    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
12871        let mut d = Doc::blank().unwrap();
12872        d.insert("hello");
12873        assert!(d.dirty);
12874        d.save();
12875        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
12876        assert!(d.dirty, "it must not come away believing it saved");
12877        assert!(d.is_untitled(), "and it still has no file");
12878    }
12879
12880    #[test]
12881    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
12882        let p = temp_path("blank_save_as");
12883        let mut d = Doc::blank().unwrap();
12884        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
12885        // be kept literal (`\#`); this test is about save-as, not escaping (which
12886        // has its own test), so it types nothing that escaping would touch.
12887        d.insert("hi");
12888        d.save_as(p.clone());
12889        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
12890        assert!(!d.is_untitled());
12891        assert!(!d.dirty);
12892        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
12893        assert_eq!(
12894            d.disk_state(),
12895            DiskState::Unchanged,
12896            "the watermark is stamped"
12897        );
12898        // And ⌘S is a plain save from here on.
12899        d.insert("!");
12900        d.save();
12901        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
12902        let _ = std::fs::remove_file(&p);
12903    }
12904
12905    // ── a file that isn't there yet ───────────────────────────────────────────
12906
12907    /// A unique path in the temp dir with the given extension, guaranteed not to
12908    /// exist — what `leaf notes.md` is handed when the file has never been made.
12909    fn missing_path(name: &str, ext: &str) -> PathBuf {
12910        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
12911        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12912        let mut p = std::env::temp_dir();
12913        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
12914        let _ = std::fs::remove_file(&p);
12915        p
12916    }
12917
12918    #[test]
12919    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
12920        let p = missing_path("named", "md");
12921        let mut d = Doc::open_or_create(p.clone()).unwrap();
12922
12923        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
12924        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
12925        assert!(
12926            !d.is_untitled(),
12927            "it has the name the user asked for — ^S must not detour to Save As"
12928        );
12929        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
12930        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
12931        assert!(!p.exists(), "and opening it wrote nothing");
12932        // And it's a document you can be in.
12933        d.build_visual(80);
12934        assert_eq!(d.caret, 0);
12935    }
12936
12937    #[test]
12938    fn a_new_file_is_created_by_its_first_save() {
12939        let p = missing_path("first_save", "md");
12940        let mut d = Doc::open_or_create(p.clone()).unwrap();
12941        d.insert("hello\n");
12942        assert!(d.dirty);
12943        d.save();
12944
12945        assert_eq!(
12946            std::fs::read_to_string(&p).unwrap(),
12947            "hello\n",
12948            "a plain ^S wrote it — no Save As, no name to invent"
12949        );
12950        assert!(!d.dirty);
12951        assert_eq!(d.disk_state(), DiskState::Unchanged);
12952        let _ = std::fs::remove_file(&p);
12953    }
12954
12955    #[test]
12956    fn a_new_file_takes_its_format_from_the_extension() {
12957        // The one thing `blank` can't do: with no name it has to assume Markdown,
12958        // and typing djot into a Markdown parse is the wrong buffer.
12959        let dj = missing_path("format", "dj");
12960        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
12961        let md = missing_path("format", "md");
12962        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
12963    }
12964
12965    #[test]
12966    fn a_new_file_reports_itself_missing_until_it_is_saved() {
12967        // Not `Untitled` — that's the answer for a document with no path, and it
12968        // would tell a frontend there is nothing a save could collide with. Here
12969        // there is a path, and the file simply isn't at it yet.
12970        let p = missing_path("disk_state", "md");
12971        let mut d = Doc::open_or_create(p.clone()).unwrap();
12972        assert_eq!(d.disk_state(), DiskState::Missing);
12973
12974        // Somebody else creates it while the buffer is open: that's an overwrite
12975        // the frontend has to be able to prompt about, exactly as for an opened
12976        // file. Their bytes, not ours, so `Changed`.
12977        std::fs::write(&p, "theirs\n").unwrap();
12978        assert_eq!(d.disk_state(), DiskState::Changed);
12979
12980        // Saving makes the file ours and re-stamps the watermark.
12981        d.insert("ours\n");
12982        d.save();
12983        assert_eq!(d.disk_state(), DiskState::Unchanged);
12984        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
12985        let _ = std::fs::remove_file(&p);
12986    }
12987
12988    #[test]
12989    fn open_or_create_still_opens_a_file_that_is_there() {
12990        let d = doc_with("open_or_create_existing", "body\n");
12991        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
12992        assert_eq!(reopened.source, "body\n");
12993        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
12994    }
12995
12996    #[test]
12997    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
12998        // A mistyped flag or a stray argument must not become a buffer promising
12999        // to save somewhere — the same refusal `open` gives a real file.
13000        let mut p = std::env::temp_dir();
13001        p.push("leaf_test_new_bad_ext.wat");
13002        assert!(Doc::open_or_create(p).is_err());
13003        let mut none = std::env::temp_dir();
13004        none.push("leaf_test_new_no_ext");
13005        assert!(Doc::open_or_create(none).is_err());
13006    }
13007
13008    #[test]
13009    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
13010        // Opening reads nothing, so there is nothing to fail on yet; the write is
13011        // where it fails, and it says so rather than claiming a save.
13012        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
13013        let mut d = Doc::open_or_create(p).unwrap();
13014        d.insert("x");
13015        d.save();
13016        assert!(
13017            d.status.as_deref().unwrap().starts_with("save failed:"),
13018            "got {:?}",
13019            d.status
13020        );
13021        assert!(d.dirty, "it must not come away believing it saved");
13022    }
13023
13024    // ── save as ───────────────────────────────────────────────────────────────
13025
13026    /// A unique path in the temp dir that no fixture wrote — a Save As target.
13027    fn temp_path(name: &str) -> PathBuf {
13028        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
13029        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13030        let mut p = std::env::temp_dir();
13031        p.push(format!("leaf_test_target_{name}_{seq}.md"));
13032        let _ = std::fs::remove_file(&p);
13033        p
13034    }
13035
13036    #[test]
13037    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
13038        let mut d = doc_with("save_as_move", "original\n");
13039        let old = d.path.clone();
13040        let new = temp_path("save_as_move");
13041        d.insert("edited: ");
13042        d.save_as(new.clone());
13043
13044        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
13045        assert_eq!(
13046            std::fs::read_to_string(&old).unwrap(),
13047            "original\n",
13048            "Save As doesn't touch the file it came from"
13049        );
13050        assert_eq!(d.path, new, "the document moved");
13051        assert!(!d.dirty);
13052        assert_eq!(
13053            d.status.as_deref(),
13054            Some(&*format!("saved {}", d.file_name()))
13055        );
13056
13057        // Every later save follows it, which is the whole difference from a copy.
13058        d.caret = 0;
13059        d.insert("re-");
13060        d.save();
13061        assert_eq!(
13062            std::fs::read_to_string(&new).unwrap(),
13063            "re-edited: original\n"
13064        );
13065        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
13066        let _ = std::fs::remove_file(&new);
13067    }
13068
13069    #[test]
13070    fn save_as_overwrites_an_existing_target() {
13071        // The picker already asked; asking again down here is the same question
13072        // twice, and the second one has no way to be answered.
13073        let new = temp_path("save_as_over");
13074        std::fs::write(&new, "theirs\n").unwrap();
13075        let mut d = doc_with("save_as_over", "ours\n");
13076        d.save_as(new.clone());
13077        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
13078        let _ = std::fs::remove_file(&new);
13079    }
13080
13081    #[test]
13082    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
13083        let mut d = doc_with("save_as_fail", "body\n");
13084        let old = d.path.clone();
13085        d.insert("x");
13086        // A directory that doesn't exist: the write can't land.
13087        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
13088        d.save_as(bad);
13089
13090        assert_eq!(
13091            d.path, old,
13092            "the document must not move to a file that isn't there"
13093        );
13094        assert!(d.dirty, "and must not believe it saved");
13095        assert!(
13096            d.status.as_deref().unwrap().starts_with("save failed:"),
13097            "the same failure a plain save reports, got {:?}",
13098            d.status
13099        );
13100        // The original is still the document's file, and still saveable.
13101        d.save();
13102        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
13103        assert!(!d.dirty);
13104    }
13105
13106    #[test]
13107    fn save_as_renames_without_reparsing_the_format() {
13108        // `.dj` on the name doesn't make the buffer djot: it was parsed as
13109        // Markdown and still is, and saying otherwise would be a conversion the
13110        // user never asked for (and an undo history thrown away to do it).
13111        let mut d = doc_with("save_as_format", "**b**\n");
13112        let mut new = temp_path("save_as_format");
13113        new.set_extension("dj");
13114        d.save_as(new.clone());
13115        assert_eq!(d.format_name(), "markdown");
13116        let _ = std::fs::remove_file(&new);
13117    }
13118
13119    // ── external change / reload ──────────────────────────────────────────────
13120
13121    #[test]
13122    fn an_untouched_file_reports_unchanged() {
13123        let mut d = doc_with("disk_clean", "body\n");
13124        assert_eq!(d.disk_state(), DiskState::Unchanged);
13125        // Editing the buffer is not editing the file.
13126        d.insert("x");
13127        assert_eq!(d.disk_state(), DiskState::Unchanged);
13128        assert!(d.dirty);
13129        // Saving re-stamps the watermark rather than reporting our own bytes back.
13130        d.save();
13131        assert_eq!(d.disk_state(), DiskState::Unchanged);
13132    }
13133
13134    #[test]
13135    fn a_file_written_underneath_reports_changed() {
13136        let mut d = doc_with("disk_changed", "body\n");
13137        std::fs::write(&d.path, "someone else\n").unwrap();
13138        assert_eq!(d.disk_state(), DiskState::Changed);
13139        // Dirty *and* changed is the clobber: both halves are readable, and
13140        // leaf-core takes neither side.
13141        d.insert("x");
13142        assert!(d.dirty && d.disk_state() == DiskState::Changed);
13143        // Saving anyway is allowed — the frontend asked, or chose not to.
13144        d.save();
13145        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
13146        assert_eq!(d.disk_state(), DiskState::Unchanged);
13147    }
13148
13149    #[test]
13150    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
13151        // The hash is what makes this honest: the file was written (a fresh
13152        // mtime), and nothing about the document is stale.
13153        let d = doc_with("disk_same_bytes", "body\n");
13154        std::fs::write(&d.path, "body\n").unwrap();
13155        assert_eq!(d.disk_state(), DiskState::Unchanged);
13156    }
13157
13158    #[test]
13159    fn a_deleted_file_reports_missing() {
13160        let mut d = doc_with("disk_missing", "body\n");
13161        std::fs::remove_file(&d.path).unwrap();
13162        assert_eq!(d.disk_state(), DiskState::Missing);
13163        // A save recreates it, and the document is whole again.
13164        d.save();
13165        assert_eq!(d.disk_state(), DiskState::Unchanged);
13166        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
13167    }
13168
13169    #[test]
13170    fn reload_replaces_the_document_with_the_file() {
13171        for (view, tag) in VIEWS {
13172            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
13173            d.insert("edited ");
13174            assert!(d.dirty);
13175            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
13176            d.reload();
13177
13178            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
13179            assert!(!d.dirty, "{tag}: the file is what we have");
13180            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
13181            assert_eq!(
13182                d.status.as_deref(),
13183                Some(&*format!("reloaded {}", d.file_name()))
13184            );
13185            // The reloaded tree is live, not the old parse.
13186            d.caret = d.source.find("three").unwrap();
13187            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
13188        }
13189    }
13190
13191    #[test]
13192    fn reload_clamps_the_caret_and_drops_the_selection() {
13193        let mut d = doc_with("reload_caret", "a long first line\n");
13194        d.caret = 12;
13195        d.anchor = Some(4);
13196        std::fs::write(&d.path, "short\n").unwrap();
13197        d.reload();
13198        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
13199        assert_eq!(
13200            d.anchor, None,
13201            "a selection over bytes that changed is a lie"
13202        );
13203        assert!(d.selection().is_none());
13204
13205        // A caret the file still has room for stays put.
13206        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
13207        d.caret = 2;
13208        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
13209        d.reload();
13210        assert_eq!(d.caret, 2);
13211    }
13212
13213    /// A silent reload is something that happened *to* a reader — a formatter,
13214    /// a `git checkout` — so it has to be undoable like anything else that
13215    /// changes the document, and undoable as one step rather than as however
13216    /// many the file happens to differ by.
13217    #[test]
13218    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
13219        let mut d = doc_with("reload_undo", "body\n");
13220        d.insert("x");
13221        assert_eq!(d.source, "xbody\n");
13222        std::fs::write(&d.path, "replaced\n").unwrap();
13223        d.reload();
13224        assert_eq!(d.source, "replaced\n");
13225        assert!(!d.dirty, "a reload lands clean");
13226
13227        // One ^Z takes the whole swap off, and hands back the unsaved work it
13228        // replaced — which is unsaved again, because the file no longer says it.
13229        d.undo();
13230        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
13231        assert!(d.dirty, "and what it comes back to is unsaved");
13232        // …and the history under it is still there.
13233        d.undo();
13234        assert_eq!(
13235            d.source, "body\n",
13236            "the typing before the reload undoes too"
13237        );
13238        // Redo walks back up through the reload.
13239        d.redo();
13240        d.redo();
13241        assert_eq!(d.source, "replaced\n");
13242    }
13243
13244    /// A file rewritten with the bytes it already had is not an edit, so it
13245    /// must not leave an undo step behind for something nobody did.
13246    #[test]
13247    fn reloading_identical_bytes_pushes_no_undo_step() {
13248        let mut d = doc_with("reload_same", "body\n");
13249        d.insert("x");
13250        std::fs::write(&d.path, "xbody\n").unwrap();
13251        d.reload();
13252        assert_eq!(d.source, "xbody\n");
13253        assert!(!d.dirty, "the file now says what the buffer does");
13254        d.undo();
13255        assert_eq!(
13256            d.source, "body\n",
13257            "one step back is the typing, not a no-op"
13258        );
13259    }
13260
13261    #[test]
13262    fn a_reload_that_cant_read_leaves_the_document_alone() {
13263        let mut d = doc_with("reload_gone", "body\n");
13264        d.insert("x");
13265        std::fs::remove_file(&d.path).unwrap();
13266        d.reload();
13267        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
13268        assert!(d.dirty);
13269        assert!(
13270            d.status.as_deref().unwrap().starts_with("reload failed:"),
13271            "{:?}",
13272            d.status
13273        );
13274
13275        // And an untitled document has nothing to reload from.
13276        let mut d = Doc::blank().unwrap();
13277        d.insert("typed");
13278        d.reload();
13279        assert_eq!(d.source, "typed");
13280        assert_eq!(d.status.as_deref(), Some("no file to reload"));
13281    }
13282
13283    #[test]
13284    fn a_read_only_document_refuses_every_door() {
13285        let mut d = doc_with("readonly", "one two three\n");
13286        d.insert("x");
13287        assert!(d.dirty, "writable first, so the undo step exists");
13288        d.set_read_only(true);
13289        let before = d.source.clone();
13290        d.insert("y");
13291        d.backspace();
13292        d.undo();
13293        d.redo();
13294        assert_eq!(d.source, before, "no door moved a byte");
13295        d.set_read_only(false);
13296        d.undo();
13297        assert_ne!(d.source, before, "off again, the same doors work");
13298    }
13299
13300    /// The doors that go to twig's own verbs rather than through the splice.
13301    /// Typed text in the rendered view under the default markup mode is the
13302    /// everyday one — it is what a keystroke in leaf-web or the Apple views
13303    /// becomes — and it walked straight past the gate.
13304    #[test]
13305    fn a_read_only_document_refuses_the_doors_around_the_splice() {
13306        let mut d = wysiwyg_doc(
13307            "readonly-doors",
13308            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
13309        );
13310        d.set_markup_mode(MarkupMode::None);
13311        d.set_read_only(true);
13312        let before = d.source.clone();
13313        d.place_caret(3, false);
13314        d.insert("y");
13315        d.insert_link("https://example.com");
13316        d.insert_image("a.png", "alt");
13317        d.insert_thematic_break();
13318        d.insert_footnote();
13319        d.place_caret(0, false);
13320        d.place_caret(3, true);
13321        d.toggle(InlineKind::Strong);
13322        d.toggle_heading(2);
13323        d.set_block(BlockKind::Paragraph);
13324        d.toggle_list(false);
13325        d.toggle_blockquote();
13326        d.toggle_task_item();
13327        d.newline();
13328        d.indent();
13329        d.set_code_language("rust");
13330        let in_cell = d.source.find("| c").unwrap() + 2;
13331        d.place_caret(in_cell, false);
13332        assert!(d.caret_in_table(), "the caret is in the grid");
13333        assert!(!d.cell_line_break(), "the cell break reports the refusal");
13334        assert_eq!(d.source, before, "no door moved a byte");
13335        assert!(!d.dirty, "nothing to save");
13336        d.set_read_only(false);
13337        d.place_caret(3, false);
13338        d.insert("y");
13339        assert_ne!(d.source, before, "off again, the same doors work");
13340    }
13341
13342    #[test]
13343    fn a_selection_quote_carries_its_context_on_char_boundaries() {
13344        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
13345        let start = d.source.find("exact").unwrap();
13346        d.place_caret(start, false);
13347        d.place_caret(start + "exact".len(), true);
13348        let q = d.selection_quote(3).unwrap();
13349        assert_eq!(q.exact, "exact");
13350        assert_eq!(
13351            q.prefix, "你好 ",
13352            "chars, not bytes — the multibyte pair counts as two"
13353        );
13354        assert_eq!(q.suffix, " 世界");
13355        assert_eq!(&d.source[q.start..q.end], "exact");
13356        // At the edges the context clips rather than erring.
13357        d.place_caret(0, false);
13358        d.place_caret(6, true);
13359        let q = d.selection_quote(40).unwrap();
13360        assert_eq!(q.prefix, "");
13361        assert_eq!(q.exact, "before");
13362        // No selection is no quote.
13363        d.place_caret(0, false);
13364        assert!(d.selection_quote(3).is_none());
13365    }
13366
13367    #[test]
13368    fn highlights_are_kept_sorted_and_answer_point_queries() {
13369        let mut d = doc_with("hl", "one two three\n");
13370        d.set_highlights(vec![
13371            Highlight {
13372                start: 8,
13373                end: 13,
13374                id: "b".into(),
13375                color: None,
13376                marker: None,
13377            },
13378            Highlight {
13379                start: 0,
13380                end: 3,
13381                id: "a".into(),
13382                color: Some("#ffe066".into()),
13383                marker: None,
13384            },
13385            Highlight {
13386                start: 5,
13387                end: 5,
13388                id: "empty".into(),
13389                color: None,
13390                marker: None,
13391            },
13392        ]);
13393        assert_eq!(
13394            d.highlights()
13395                .iter()
13396                .map(|h| h.id.as_str())
13397                .collect::<Vec<_>>(),
13398            ["a", "b"],
13399            "sorted by start, the empty range dropped"
13400        );
13401        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
13402        assert_eq!(d.highlight_at(3), None, "end is exclusive");
13403        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
13404        d.set_highlights(Vec::new());
13405        assert!(d.highlights().is_empty(), "a replace is a replace");
13406    }
13407
13408    /// `Highlight::covering` and the cursor over it are what both painters ask
13409    /// per glyph, so they have to answer the same as the scan they replaced —
13410    /// including in the gaps, which is where most glyphs are.
13411    #[test]
13412    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
13413        let hl = |start: usize, end: usize, id: &str| Highlight {
13414            start,
13415            end,
13416            id: id.into(),
13417            color: None,
13418            marker: None,
13419        };
13420        // Disjoint, as search hits are: in a range, in a gap, and past the end.
13421        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
13422        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
13423        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
13424        assert_eq!(
13425            Highlight::covering(&hits, 105),
13426            None,
13427            "a gap covers nothing"
13428        );
13429        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
13430        assert_eq!(Highlight::covering(&hits, 9_999), None);
13431        assert_eq!(Highlight::covering(&[], 0), None);
13432
13433        // Nested: first by start, so a hit inside an annotation still resolves
13434        // to the annotation — and the range that stops short doesn't mask it.
13435        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
13436        assert_eq!(
13437            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
13438            Some("outer")
13439        );
13440        assert_eq!(
13441            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
13442            Some("outer")
13443        );
13444    }
13445
13446    /// The cursor is an optimisation, so the only thing worth asserting is that
13447    /// it is not also a change of answer — at every offset, over a list with a
13448    /// nest in it, walked forwards and then backwards.
13449    #[test]
13450    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
13451        let hl = |start: usize, end: usize, id: &str| Highlight {
13452            start,
13453            end,
13454            id: id.into(),
13455            color: None,
13456            marker: None,
13457        };
13458        let mut list = vec![
13459            hl(0, 20, "outer"),
13460            hl(5, 10, "inner"),
13461            hl(30, 33, "hit"),
13462            hl(40, 43, "hit"),
13463        ];
13464        list.sort_by_key(|h| (h.start, h.end));
13465
13466        let mut cursor = HighlightCursor::new(&list);
13467        for offset in 0..50 {
13468            assert_eq!(
13469                cursor.at(offset).map(|h| h.id.as_str()),
13470                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
13471                "cursor disagrees at {offset}"
13472            );
13473        }
13474        // Backwards: the cursor re-seats rather than answering from where it
13475        // had got to, so a painter that revisits a row is still told the truth.
13476        for offset in (0..50).rev() {
13477            assert_eq!(
13478                cursor.at(offset).map(|h| h.id.as_str()),
13479                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
13480                "cursor disagrees walking back at {offset}"
13481            );
13482        }
13483    }
13484}