Skip to main content

leaf_core/
doc.rs

1//! The document model: a `twig::Editor` plus a byte-offset caret and selection.
2//!
3//! Where bough moves a selection through the *tree*, leaf moves a *caret*
4//! through the *characters* — a normal text editor's model — and expresses
5//! every mutation as one of twig's offset-addressed ops:
6//!
7//!   - typing / delete  → `edit_range(start, end, text)`   (P0)
8//!   - re-anchoring      → the returned `Change`            (P1)
9//!   - cursor context    → `node_at` / `ancestors_at`       (P3)
10//!   - the toolbar       → `wrap_range`/`toggle_inline`/`set_block`,
11//!                         `toggle_block_container`/`insert_link`   (P5)
12//!
13//! twig reparses after every edit and leaves everything outside the splice
14//! byte-for-byte untouched, so the document stays a live, navigable AST while
15//! you type into it.
16
17// `PathBuf` names the `path` field and the untitled marker on every build;
18// `Path` is only touched by the filesystem I/O gated behind the `fs` feature.
19// The docs in this file lay their `- key → meaning` lists out in aligned
20// columns, which puts a continuation line further right than clippy's
21// list-indent rule likes. A lazy continuation renders as the same paragraph
22// either way, and the alignment is what makes those tables readable, so the
23// layout wins over the lint.
24#![allow(clippy::doc_overindented_list_items)]
25
26use std::collections::HashMap;
27use std::ops::Range;
28#[cfg(feature = "fs")]
29use std::path::Path;
30use std::path::PathBuf;
31
32#[cfg(feature = "fs")]
33use anyhow::Context;
34use anyhow::{Result, anyhow};
35use twig::{
36    Alignment, BlockContainerKind, BlockKind, Change, Editor, FlatNode, Format, Gesture,
37    InlineKind, Kind, MarkdownExtensions, NodeId, QueryMatch,
38};
39use unicode_segmentation::GraphemeCursor;
40
41use crate::html;
42use crate::source::{self, SourceMap};
43use crate::style::{Align, FontFamily, LineSpacing, MarkColor, SizeStep};
44use crate::wysiwyg::{self, MediaKind, MediaStop, VisualMap};
45
46/// Which view the body shows.
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub enum View {
49    /// The raw document with a caret in source bytes.
50    Source,
51    /// Markup resolved to real styles, caret riding the rendered glyphs.
52    Wysiwyg,
53}
54
55/// How much of the source markup the WYSIWYG view exposes — a per-editor
56/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
57/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
58/// terms, and every rung below is about *delimiters*, whatever grammar spells
59/// them. The examples are Markdown only because that is what most documents are.
60///
61/// A single ladder over two underlying axes, because only three of their four
62/// combinations are coherent:
63///
64/// | | authoring off | authoring on |
65/// |---|---|---|
66/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
67/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
68///
69/// The empty quadrant would show delimiters on the caret's line and then escape
70/// the ones you type — a surface that displays a syntax it refuses to accept.
71/// Someone who wants to read raw markup without authoring it has
72/// [`View::Source`], which is the better tool for it.
73///
74/// The two axes are read separately by the code that cares — see
75/// [`reveals_caret_line`](Self::reveals_caret_line) and
76/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
77/// as a ladder.
78#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
79pub enum MarkupMode {
80    /// Delimiters stay hidden even on the caret's line, and typed syntax stays
81    /// literal — twig escapes anything that would open markup, so formatting
82    /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
83    /// reading surface for people who don't write markup by hand; the default,
84    /// and what Diaryx ships.
85    #[default]
86    None,
87    /// Delimiters stay hidden, but typing them authors real markup: `*x*`
88    /// becomes italic and the asterisks disappear into the styling
89    /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
90    /// clean surface back once it has been applied.
91    Shortcuts,
92    /// The caret's line shows its raw markup while every other line renders
93    /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
94    /// — for people fluent in the document's grammar who want to see and edit
95    /// the delimiters they type.
96    Full,
97}
98
99impl MarkupMode {
100    /// Whether the rich view shows raw delimiters on the line holding the caret.
101    /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
102    /// WYSIWYG builder.
103    pub fn reveals_caret_line(self) -> bool {
104        matches!(self, MarkupMode::Full)
105    }
106
107    /// Whether typed markup characters author real formatting. The editing axis
108    /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
109    pub fn authors(self) -> bool {
110        !matches!(self, MarkupMode::None)
111    }
112}
113
114/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
115/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
116/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
117/// either flow. The renderer consults it when it lays a block's inline content
118/// into visual rows.
119#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
120pub enum LineFlow {
121    /// A soft break folds into a space and the paragraph reflows to the
122    /// viewport width — flowing prose, where the source's line wrapping is
123    /// insignificant. The default, and what Diaryx ships.
124    #[default]
125    Fold,
126    /// A soft break renders as a line break exactly where it was written, so
127    /// the author's source line structure shows on screen unchanged — the mode
128    /// for people who lay out their prose deliberately (one sentence or clause
129    /// per line, semantic line breaks). The break is still a soft break in the
130    /// source; only its rendering changes.
131    Preserve,
132}
133
134/// What the file behind a document looks like right now, against the bytes leaf
135/// last read from it or wrote to it — the question a frontend asks before it
136/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
137/// happen) or when its window regains focus. See [`Doc::disk_state`].
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum DiskState {
140    /// The file holds exactly the bytes leaf last read or wrote.
141    Unchanged,
142    /// Someone else wrote the file since. Saving overwrites their work; see
143    /// [`Doc::reload`] for the other direction.
144    Changed,
145    /// The file is gone — deleted or renamed away. A save recreates it.
146    Missing,
147    /// There is a path, but the file couldn't be read (permissions, a directory
148    /// in the way): leaf can't tell, and won't guess.
149    Unreadable,
150    /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
151    /// changed under a document that was never on disk.
152    Untitled,
153}
154
155/// The inline marks in force at a point in the document — what a toolbar
156/// lights up. A `Copy` bitset rather than a `HashSet`, because
157/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
158/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub struct InlineMarks(u8);
161
162impl InlineMarks {
163    /// Every kind, in the order [`InlineMarks::iter`] yields them.
164    const ALL: [InlineKind; 8] = [
165        InlineKind::Strong,
166        InlineKind::Emph,
167        InlineKind::Verbatim,
168        InlineKind::Mark,
169        InlineKind::Superscript,
170        InlineKind::Subscript,
171        InlineKind::Insert,
172        InlineKind::Delete,
173    ];
174
175    pub const fn empty() -> Self {
176        InlineMarks(0)
177    }
178
179    /// Private: the set is an *answer*, and adding a mark to it doesn't mark
180    /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
181    fn insert(&mut self, kind: InlineKind) {
182        self.0 |= Self::bit(kind);
183    }
184
185    /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
186    fn flip(&mut self, kind: InlineKind) {
187        self.0 ^= Self::bit(kind);
188    }
189
190    /// The symmetric difference: which marks differ between the two sets. Used
191    /// to resolve the marks already in force at the caret against the pending
192    /// delta — a bit set in the delta flips the base mark for the next keystroke.
193    fn xor(self, other: InlineMarks) -> InlineMarks {
194        InlineMarks(self.0 ^ other.0)
195    }
196
197    /// Whether `kind` is in force — the toolbar's "is Bold active?".
198    pub fn contains(self, kind: InlineKind) -> bool {
199        self.0 & Self::bit(kind) != 0
200    }
201
202    pub fn is_empty(self) -> bool {
203        self.0 == 0
204    }
205
206    /// The marks in force, for a frontend that renders whatever is on rather
207    /// than asking after a fixed list.
208    pub fn iter(self) -> impl Iterator<Item = InlineKind> {
209        Self::ALL.into_iter().filter(move |&k| self.contains(k))
210    }
211
212    fn bit(kind: InlineKind) -> u8 {
213        1 << match kind {
214            InlineKind::Strong => 0,
215            InlineKind::Emph => 1,
216            InlineKind::Verbatim => 2,
217            InlineKind::Mark => 3,
218            InlineKind::Superscript => 4,
219            InlineKind::Subscript => 5,
220            InlineKind::Insert => 6,
221            InlineKind::Delete => 7,
222        }
223    }
224}
225
226impl FromIterator<InlineKind> for InlineMarks {
227    fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
228        let mut m = InlineMarks::empty();
229        for k in iter {
230            m.insert(k);
231        }
232        m
233    }
234}
235
236/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
237/// into one undo step (a run of typed characters undoes together); `Other` never
238/// coalesces, so a paste, format toggle, or block change is always its own step.
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum EditKind {
241    Insert,
242    Delete,
243    /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
244    /// rather than `Insert`'s because a composition is not typing: each step
245    /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
246    /// though no two steps insert the same bytes, and it must not fold into the
247    /// typed characters on either side of it.
248    Compose,
249    Other,
250}
251
252/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
253/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
254/// the caret), `Forward` is Delete (one starting at it).
255#[derive(Clone, Copy)]
256enum BreakEdge {
257    Backward,
258    Forward,
259}
260
261/// A re-spelling of one inline mark run, held ready in case the edit about to
262/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
263/// Every offset in it is in the coordinates the document will have *after* the
264/// plain edit, since that is when it may be applied.
265struct MarkEdgeFix {
266    /// The run's kind, and an offset inside what was its content: together they
267    /// answer "did the plain edit actually break this mark?" — the question that
268    /// decides whether any of this is applied at all.
269    kind: InlineKind,
270    probe: usize,
271    /// The byte range to re-spell (the run's delimiters included) and its new
272    /// spelling, with the edge whitespace moved outside the delimiters.
273    start: usize,
274    end: usize,
275    text: String,
276    /// Where the caret belongs afterwards — the same place on screen it would
277    /// have had, which is now on the other side of a delimiter.
278    caret: usize,
279    /// The marks in force for text typed at that caret. The caret can land
280    /// outside a run it was inside, and the marks have to survive the move or
281    /// the toolbar goes dark mid-word.
282    want: InlineMarks,
283}
284
285/// The caret and selection at one moment — the part of a history step twig's
286/// `Change` cannot carry, because the caret is leaf's state and twig only knows
287/// about bytes. leaf serializes it into the opaque per-state blob twig now
288/// stores in its own undo history (see `record_caret`), so undo and redo hand
289/// back the caret that matches the source they restore.
290#[derive(Clone, Copy)]
291struct CaretState {
292    caret: usize,
293    anchor: Option<usize>,
294}
295
296impl CaretState {
297    /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
298    /// then an anchor-present flag and the anchor. twig copies these bytes and
299    /// never reads them.
300    fn to_blob(self) -> [u8; 17] {
301        let mut b = [0u8; 17];
302        b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
303        if let Some(a) = self.anchor {
304            b[8] = 1;
305            b[9..].copy_from_slice(&(a as u64).to_le_bytes());
306        }
307        b
308    }
309
310    /// Recover a state from twig's blob, or `None` when it is empty or the wrong
311    /// length — a state twig restored that never had a caret set on it, which
312    /// leaves the caller to fall back to the edit site.
313    fn from_blob(b: &[u8]) -> Option<Self> {
314        let b: &[u8; 17] = b.try_into().ok()?;
315        let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
316        let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
317        Some(CaretState { caret, anchor })
318    }
319}
320
321/// A footnote reference and the note it names — the answer to
322/// [`Doc::footnote_at`].
323///
324/// The two `Option`s move together: a reference whose definition is missing has
325/// neither a body to show nor a place to jump to, and one that resolved has
326/// both.
327#[derive(Clone, PartialEq, Eq, Debug)]
328pub struct FootnoteRef {
329    /// The reference's label — the `1` of `[^1]`, with neither the `^` that
330    /// spells it a footnote nor the brackets around it.
331    pub label: String,
332    /// The note's body as source bytes (see
333    /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
334    /// document defines no `[^label]:` to read one from.
335    pub text: Option<String>,
336    /// Where the note's *body* starts, for a "go to note" that moves the caret
337    /// there. `None` alongside a `None` `text`.
338    ///
339    /// The body rather than the definition, because this is an offset to put a
340    /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
341    /// aiming at the definition's first byte snaps to the nearest real stop,
342    /// which is up in the paragraph above the note. It is also simply where a
343    /// reader following a reference wants to land: at the note's first word,
344    /// ready to read or amend it.
345    pub offset: Option<usize>,
346    /// Where the note's body ends, exclusive — so a frontend can ask which
347    /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
348    ///
349    /// The rows are the note with its markup resolved: `see *later*` reaches a
350    /// frontend as an italic run, not as asterisks. `text` is the source bytes
351    /// and stays the honest answer for anything that wants the note as written
352    /// (a search index, a copy); this pair of offsets is for anything that wants
353    /// it as *read*. `None` alongside a `None` `offset`.
354    pub end: Option<usize>,
355}
356
357/// A footnote definition and the reference that sends a reader to it — the
358/// answer to [`Doc::footnote_definition_at`], and the other half of the round
359/// trip [`FootnoteRef`] starts.
360///
361/// A note is a place a reader *arrives*, so the useful thing to know while
362/// standing in one is the way back. Without this the jump to a note is a
363/// one-way door: the definitions sit at the foot of the document, so returning
364/// by hand means scrolling back up and finding the sentence again.
365#[derive(Clone, PartialEq, Eq, Debug)]
366pub struct FootnoteDef {
367    /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
368    /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
369    pub label: String,
370    /// Where the reference's *label* is, for a "back to reference" that moves
371    /// the caret there. `None` for a note nothing refers to — an orphan, which
372    /// is worth being able to say rather than silently doing nothing.
373    ///
374    /// The label rather than the reference's first byte, for
375    /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
376    /// and its label is the only part of it the caret can rest on.
377    ///
378    /// The *first* reference, when a label is cited more than once: a repeated
379    /// citation has no one true home, and the first is both the one a reader
380    /// most likely came from and the only choice that doesn't depend on how
381    /// they got here.
382    pub offset: Option<usize>,
383}
384
385/// Where a locator lands — the answer to [`Doc::locate`].
386///
387/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
388/// document, and a place is a span rather than a point: a reader following one
389/// wants the caret at its first byte, and a reader merely *peeking* at one wants
390/// the block it covers drawn. Both are served by carrying the whole span, and
391/// only one of the two can be recovered from an offset alone.
392#[derive(Clone, PartialEq, Eq, Debug)]
393pub struct Landing {
394    /// The first byte of the block the locator names — where a caret goes.
395    pub start: usize,
396    /// One past its last byte, so a frontend can map the pair through
397    /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
398    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
399    pub end: usize,
400}
401
402/// A selection cited out of the source: the text itself, up to a requested
403/// number of characters either side, and the byte range it came from. See
404/// [`Doc::selection_quote`].
405///
406/// The prefix and suffix are what make the quote *re-findable*: the same text
407/// can occur twice, and a little of what surrounded it is how a later reader —
408/// or the same document after an edit — tells the occurrences apart. The Web
409/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
410/// the name.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct Quote {
413    /// The selected source, verbatim.
414    pub exact: String,
415    /// What immediately preceded it — possibly empty, at the document's start.
416    pub prefix: String,
417    /// What immediately followed it — possibly empty, at the document's end.
418    pub suffix: String,
419    /// Byte offset in the source where the selection begins.
420    pub start: usize,
421    /// Byte offset where it ends (exclusive).
422    pub end: usize,
423}
424
425/// A host-painted range of the source — an annotation's footprint, a search
426/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
427/// glyphs whose source falls inside it) and hands back the `id` when the
428/// reader activates it; what the range *means* is entirely the host's.
429///
430/// Ranges are source bytes, like the caret and the selection, so a host that
431/// anchors quotes against the source ([`Doc::selection_quote`] is the other
432/// half of that loop) can paint what it found without any coordinate
433/// conversion. A range that drifts off the text it meant is the host's to
434/// re-anchor; leaf draws what it is told.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct Highlight {
437    /// Byte offset in the source where the wash begins.
438    pub start: usize,
439    /// Byte offset where it ends (exclusive).
440    pub end: usize,
441    /// The host's name for it, handed back on activation. Opaque to leaf.
442    pub id: String,
443    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
444    /// nothing for the theme's default wash.
445    pub color: Option<String>,
446    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
447    /// a marker gets a small glyph in the margin beside its first line, and
448    /// the glyph — not the wash — is what activates it: the wash is ink, the
449    /// marker is the control, which is what lets a reader put a caret in (or
450    /// copy from) annotated text without a card leaping at them. The name is
451    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
452    /// as a class.
453    pub marker: Option<String>,
454}
455
456impl Highlight {
457    /// The range covering source `offset` in a list [`Doc::set_highlights`]
458    /// sorted, first by start where several overlap — the one place that
459    /// question is answered, for the frontends that paint by asking it as well
460    /// as for [`Doc::highlight_at`].
461    ///
462    /// The list is sorted by `(start, end)`, so the scan can stop at the first
463    /// range starting past `offset` rather than running to the end. A painter
464    /// asking once per glyph wants [`HighlightCursor`] instead; this is the
465    /// one-shot form, for the host asking what the reader just activated.
466    pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
467        highlights
468            .iter()
469            .take_while(|h| h.start <= offset)
470            .find(|h| offset < h.end)
471    }
472}
473
474/// [`Highlight::covering`] for a caller walking the document in order — which
475/// is every painter, since a frontend draws rows top to bottom and glyphs left
476/// to right.
477///
478/// The one-shot form is a scan from the front of the list per glyph, and a
479/// document with two hundred search hits pays that two hundred times a row. A
480/// range that ends at or before an offset can never cover that offset *or any
481/// later one*, so the cursor retires those permanently and each glyph costs the
482/// ranges that actually reach it. The answer is identical to
483/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
484/// the part that was being redone dropped, not a cheaper approximation.
485///
486/// Offsets are expected to arrive non-decreasing. One that goes backwards is
487/// still answered correctly: the cursor re-seats to the front, since a painter
488/// that revisits a row is asking a question the retired ranges may own again.
489pub struct HighlightCursor<'a> {
490    highlights: &'a [Highlight],
491    /// The first range not yet retired.
492    at: usize,
493    /// The last offset asked about, to notice a caller going backwards.
494    last: usize,
495}
496
497impl<'a> HighlightCursor<'a> {
498    pub fn new(highlights: &'a [Highlight]) -> Self {
499        HighlightCursor {
500            highlights,
501            at: 0,
502            last: 0,
503        }
504    }
505
506    /// The range covering `offset`, advancing the cursor past every range that
507    /// can no longer cover anything.
508    pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
509        if offset < self.last {
510            self.at = 0;
511        }
512        self.last = offset;
513        while self
514            .highlights
515            .get(self.at)
516            .is_some_and(|h| h.end <= offset)
517        {
518            self.at += 1;
519        }
520        Highlight::covering(&self.highlights[self.at..], offset)
521    }
522}
523
524/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
525/// purpose: the only useful question is whether two of them are the same map,
526/// and what is behind it — which `Doc` built it, and the (revision, wrap,
527/// reveal line) it was built from — is core's business.
528///
529/// The document is part of it because the rest is not unique to one: two
530/// documents opened at the same width are both at revision zero with no reveal
531/// line, and a frontend holding one copy of a map across the two would take
532/// the second's key for the first's and paint the wrong document.
533#[derive(Clone, PartialEq, Eq, Debug)]
534pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Range<usize>>)>);
535
536pub struct Doc {
537    editor: Editor,
538    pub format: Format,
539    pub path: PathBuf,
540    /// Current source, refreshed from the editor after every successful edit.
541    pub source: String,
542    /// The caret, as a byte offset into `source` (always on a char boundary).
543    pub caret: usize,
544    /// The selection's fixed end, if a selection is active; the moving end is
545    /// the caret. `None` means no selection.
546    pub anchor: Option<usize>,
547    pub dirty: bool,
548    pub status: Option<String>,
549    pub view: View,
550    /// Whether the document refuses to change — a *reading* surface over the
551    /// same rendering, selection, and navigation the editor has.
552    ///
553    /// Enforced here rather than by each frontend hiding its input paths,
554    /// because every mutation funnels through a few doors —
555    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
556    /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
557    /// verbs directly rather than through the splice (a typed literal, a link,
558    /// an image, a rule, a footnote, a cell's line break) — and guarded doors
559    /// are a guarantee where a frontend's suppressed keyboard is a hope. A
560    /// gated door reports exactly like a rolled-back splice, a path every
561    /// caller already handles. `a_read_only_document_refuses_every_door` is
562    /// the list; a new `self.editor.insert_*` call belongs on it.
563    read_only: bool,
564    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
565    /// State like the selection rather than like the text: no edit history,
566    /// no dirty bit, redrawn from whatever the host last set.
567    highlights: Vec<Highlight>,
568    /// How much of the source markup the rich view exposes — a frontend preference (see
569    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
570    /// [`reveal_line`](Self::reveal_line), the editing one by
571    /// [`insert`](Self::insert).
572    markup_mode: MarkupMode,
573    /// Whether soft breaks fold into the reflowed paragraph or render where
574    /// they were written (see [`LineFlow`]) — an independent frontend
575    /// preference the WYSIWYG builder consults when it lays out a block.
576    line_flow: LineFlow,
577    /// The kind of the last edit, for coalescing: twig owns the undo *history*
578    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
579    /// call, so leaf decides when a run continues and tells twig to coalesce.
580    last_edit_kind: Option<EditKind>,
581    /// The inline marks the user has toggled *at a collapsed caret* with no
582    /// selection — "start typing bold here". Held as the XOR delta from the marks
583    /// already in force at [`pending_at`](Self::pending_at): a set bit means
584    /// "flip this kind for the next typed text", so it both turns a mark on where
585    /// none is (type into bold) and off where one already covers the caret (type
586    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
587    /// freshly typed text and then clears it — a mark once realised is carried by
588    /// the caret sitting inside the run, not by this delta.
589    pending_marks: InlineMarks,
590    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
591    /// delta is live only while the caret still stands here with no selection;
592    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
593    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
594    pending_at: Option<usize>,
595    /// The source as of the last open/save — `dirty` is `source != clean_source`,
596    /// so undoing back to the saved state correctly clears the modified flag.
597    clean_source: String,
598    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
599    /// while the document has no file behind it. [`Doc::disk_state`] compares
600    /// the file against this to catch an edit made *outside* leaf before a save
601    /// silently overwrites it — `clean_source` only knows what leaf itself did.
602    ///
603    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
604    /// writes inside one filesystem timestamp tick are indistinguishable, a
605    /// clock that steps backwards (or a writer that restores an mtime) hides a
606    /// real change, and a `touch` invents one. The whole point of the watermark
607    /// is to not clobber someone's work, so it reads the bytes and compares what
608    /// is actually there. That costs a file read per question, which is why the
609    /// question is asked on a user event (focus, save) and not every frame.
610    disk_hash: Option<u64>,
611    /// The "sticky" display column vertical motion aims for, in the active
612    /// view's grid. Set on the first `move_up`/`move_down` of a run and
613    /// reused by every subsequent one in that run, so passing through a
614    /// shorter line doesn't permanently forget the original column. Any
615    /// horizontal motion or edit clears it.
616    ///
617    /// A column, not a character index: dropping down a line of `你好` onto one
618    /// of ASCII has to land under the glyph the caret was drawn beneath, which
619    /// is the only thing the user can see to aim by. Where the goal falls inside
620    /// a wide character on the target line, the mapping resolves it to that
621    /// character — the caret lands on it rather than between its cells.
622    goal_col: Option<usize>,
623    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
624    /// and clicks read it to stay in visible space.
625    pub vmap: VisualMap,
626    /// The syntax map for the source view; empty in the WYSIWYG view, which
627    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
628    /// frontend that never calls it paints raw source unstyled, which is what
629    /// every frontend did before this map existed.
630    pub smap: SourceMap,
631    /// The revision `smap` was built from, or `None` before the first build.
632    /// The map is a pure function of the text alone — no width, no caret, no
633    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
634    /// whole key.
635    smap_key: Option<u64>,
636    /// Everything the map is built from, as one number: bumped whenever the
637    /// document's text changes, and never by a motion, a selection, or a save.
638    /// A frontend can hold work against it — see [`Doc::revision`].
639    revision: u64,
640    /// How many history steps stand behind the caret, and how many ahead of
641    /// it — the answer to a native Edit menu's "may Undo be enabled?", which
642    /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
643    /// the funnel every edit comes through, and moved back and forth by
644    /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
645    /// an exact depth: a coalesced run of typing is one of twig's steps but
646    /// several of these, and twig's own cap on history is not mirrored here.
647    /// Neither error can make `can_undo` false while a step remains, which is
648    /// the only property a menu needs; the one place the bound can be wrong the
649    /// other way — the cap has retired every step — is reconciled the moment
650    /// twig reports nothing to undo.
651    undo_steps: usize,
652    redo_steps: usize,
653    /// What `vmap` was built from, or `None` before the first build. The map is
654    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
655    /// moved, rebuilding it produces the identical map — see
656    /// [`Doc::build_visual`].
657    ///
658    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
659    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
660    /// text and width alone, and a caret motion still rebuilds nothing.
661    vmap_key: Option<(u64, Option<usize>, Option<Range<usize>>)>,
662    /// Which `Doc` this is, distinct from every other one built in this
663    /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
664    /// can never be mistaken for another document's — see
665    /// [`Doc::visual_key`]. Nothing else reads it.
666    identity: u64,
667    /// Per-block row cache backing the incremental rebuild: when the text
668    /// changes, only the top-level blocks whose bytes moved are re-rendered and
669    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
670    /// builds; a pure accelerator, so it's never read for correctness.
671    block_cache: wysiwyg::BlockCache,
672    /// How many visual rows each block image reserves, keyed by its destination —
673    /// set by the frontend through [`Doc::set_media_rows`] once it has decoded and
674    /// measured the pictures. Core does no image I/O, so this is the only way it
675    /// learns a picture's height; a destination not in the map reserves the bare
676    /// one-row placeholder. Threaded into the builder so [`wysiwyg::build_cached`]
677    /// sizes each placeholder, and folded into `vmap_key` so a height change
678    /// rebuilds the map.
679    media_rows: HashMap<String, usize>,
680
681    // View geometry the renderer stamps each frame, so mouse events can map a
682    // screen cell back to a byte offset.
683    pub scroll: usize,
684    pub body_origin: (u16, u16),
685    /// Width of the body rectangle last painted by the frontend. Zero means
686    /// unknown (used by tests or a frontend that has not drawn yet).
687    pub body_width: u16,
688    pub body_height: u16,
689    /// The caret as of the last frame drawn, or `None` before the first.
690    ///
691    /// Scrolling is the viewport's business, not the caret's: the view follows
692    /// the caret when the caret *moves*, but a wheel that doesn't touch the
693    /// caret has to be free to scroll away from it — otherwise the view is
694    /// pinned to the caret and stops dead at the edge of the document you can
695    /// see. Comparing against this is what tells the two apart, and it catches a
696    /// caret set by any route, including a frontend assigning the field itself.
697    pub drawn_caret: Option<usize>,
698}
699
700/// The Markdown extensions every leaf document is parsed with — four of them,
701/// each departing from twig's defaults for a reason leaf can state.
702///
703/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
704/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
705/// node the frontends can frame and rasterize instead of opaque `raw_block`
706/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
707/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
708/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
709/// plain tinted container, agnostic of `name`.
710///
711/// `highlight` and `highlight_colors` are the pair that makes Markdown read
712/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
713/// `data-color`. leaf already had somewhere to put both: the
714/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
715/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
716/// from a Djot one it was converted from — the button wrote `==…==` and the
717/// reparse read it straight back as text.
718/// They are on together because a colour is inert without the highlight itself,
719/// and a document that writes `==🔴 x==` means the colour by it.
720///
721/// Every flag is inert for non-Markdown formats, so it's safe to pass them
722/// unconditionally. Threading this through every constructor (not just `open`)
723/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
724/// way — twig reparses with these same flags after each edit.
725pub(crate) fn parse_extensions() -> MarkdownExtensions {
726    MarkdownExtensions {
727        html_elements: true,
728        directives: true,
729        highlight: true,
730        highlight_colors: true,
731        ..Default::default()
732    }
733}
734
735/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
736/// mapping twig's error into the `anyhow` context every constructor shares.
737fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
738    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
739}
740
741/// Does `format` spell a table as a **pipe table** — the one grid twig's table
742/// editor knows how to emit?
743///
744/// This is the single capability leaf still has to answer for itself, and the
745/// only hand-maintained format list left in this file. Every other gesture is
746/// [`Format::supports`], which is twig's own answer read across the C ABI — but
747/// twig deliberately leaves the table ops out of that query, because they read
748/// no `Syntax` table at all. They rewrite a grid that is already in the source
749/// and refuse on *position*, never on format. Handed a caret inside an HTML
750/// `<table>`, `table_insert_row` therefore re-emits the whole element as
751/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
752/// `dirty` flag, and nothing downstream able to tell it from a good edit.
753///
754/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
755/// wildcard answers "no" for a format leaf has never heard of: a new twig
756/// language that *does* spell pipe tables loses its grid controls until this
757/// line is updated, which shows up as a missing button. The other default hands
758/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
759fn spells_pipe_tables(format: Format) -> bool {
760    matches!(format, Format::Markdown | Format::Djot)
761}
762
763/// Which of leaf's authoring controls this document's format can actually
764/// spell — one flag per toolbar button, resolved once so a frontend can build
765/// its chrome instead of discovering each refusal on a click.
766///
767/// Every field but [`table`](Self::table) is `Format::supports_with` on the
768/// gesture the matching [`Doc`] method calls, so this record cannot drift from
769/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
770/// doesn't export.
771///
772/// `supports_with` rather than `supports` because two of these are facts about
773/// the *parse options* as much as about the format. `Format::supports` answers
774/// for twig's defaults, and leaf never parses with those — it parses with
775/// [`parse_extensions`], and a document's toolbar has to describe the document
776/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
777/// without it would mint bytes its own reparse hands back as plain text, which
778/// is why twig asks before it writes.
779///
780/// **The formats are ragged, and that is the point.** A single per-document
781/// boolean was enough while the two authorable formats were Markdown and djot
782/// and everything else spelled nothing. HTML is neither: it writes seven of the
783/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
784/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
785/// pair, and since 3.5 a quote, a list, a code block, a link and an image
786/// printed as fresh nodes; it spells no task box (a form control there) and
787/// no footnote, and its `<table>` is one twig reads but will not write. So
788/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
789/// table buttons do not, and no one flag can say that. Markdown and djot
790/// differ from each other too:
791/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
792#[derive(Clone, Copy, Debug, Eq, PartialEq)]
793pub struct Capabilities {
794    /// ⌘B — `InlineKind::Strong`.
795    pub bold: bool,
796    /// ⌘I — `InlineKind::Emph`.
797    pub italic: bool,
798    /// Inline code — `InlineKind::Verbatim`.
799    pub code: bool,
800    /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
801    /// under the `highlight` extension [`parse_extensions`] turns on: the
802    /// button writes `==text==`, which is what the reparse reads back.
803    pub mark: bool,
804    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
805    pub underline: bool,
806    /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
807    /// out of the box, since twig parses it out of the box.
808    pub strike: bool,
809    /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
810    /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
811    /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
812    /// so a toolbar offering the swatches wherever the button lights would offer
813    /// them in a document that cannot write one. Pair with
814    /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
815    /// a highlight to colour as much as a format that spells one.
816    pub mark_color: bool,
817    pub superscript: bool,
818    pub subscript: bool,
819    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
820    pub heading: bool,
821    pub blockquote: bool,
822    pub bullet_list: bool,
823    pub ordered_list: bool,
824    /// The checkbox controls: giving an item a box, and ticking one.
825    pub task: bool,
826    pub link: bool,
827    /// Covers [`Doc::insert_media`] too — see the note there on why the three
828    /// media kinds stand or fall together.
829    pub image: bool,
830    /// The horizontal-rule button. HTML spells this one (`<hr>`).
831    pub thematic_break: bool,
832    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
833    /// the pair; HTML has no footnote of its own, so the button goes away rather
834    /// than writing brackets that would render as brackets.
835    pub footnote: bool,
836    /// Setting a fenced block's language — a control only ever offered with the
837    /// caret already in a fence.
838    pub code_language: bool,
839    /// The grid controls: insert/delete/move a row or column, set a column's
840    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
841    /// question — an HTML `<table>` holds the caret and still can't be edited.
842    pub table: bool,
843    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
844    /// idiomatic in-cell break.
845    pub cell_line_break: bool,
846    /// The alignment control — [`Doc::set_alignment`], twig's
847    /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
848    /// block's attributes, Markdown under the `html_elements`
849    /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
850    /// through its `[…]` line.
851    pub alignment: bool,
852    /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
853    /// [`alignment`](Self::alignment) and so the same answer, and its own flag
854    /// because a toolbar dims controls one at a time and the pair may yet
855    /// diverge.
856    pub line_spacing: bool,
857    /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
858    /// over a selection. **Narrower than the block pair**: AsciiDoc's
859    /// `[#id.role]#text#` keeps an id and a role and has no slot for a
860    /// `data-` key, so twig refuses the span there and this is `false` while
861    /// [`alignment`](Self::alignment) is `true`. The block-level form of the
862    /// same property — the caret in a paragraph, no selection — goes through
863    /// `SetBlockAttrs` and still works, which is why the flag describes the
864    /// control rather than the caret.
865    pub font_size: bool,
866    /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
867    /// [`font_size`](Self::font_size) is.
868    pub font_family: bool,
869    /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
870    /// and not to be confused with [`mark_color`](Self::mark_color): that is a
871    /// highlight's background and rides the `mark` node twig already owns,
872    /// this is a run's foreground and rides an attributed span.
873    pub text_color: bool,
874    /// The page-break button — [`Doc::insert_page_break`], twig's
875    /// `Gesture::InsertDirective`. Markdown under the `directives` extension
876    /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
877    /// it as an empty `::: page-break` fence.
878    ///
879    /// **Those two and no others**, though twig spells the gesture in HTML and
880    /// AsciiDoc as well — see [`Capabilities::of`].
881    pub page_break: bool,
882}
883
884impl Capabilities {
885    /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
886    /// twig computes each from a static table — but a frontend that wants to
887    /// hold them can.
888    ///
889    /// The extensions are not a parameter because they are not a choice a
890    /// caller makes: every leaf document is parsed with [`parse_extensions`],
891    /// so the format is the whole of what varies.
892    pub fn of(format: Format) -> Self {
893        let exts = parse_extensions();
894        let supports = |g| format.supports_with(exts, g);
895        let inline = |k| supports(Gesture::ToggleInline(k));
896        let container = |k| supports(Gesture::ToggleBlockContainer(k));
897        Self {
898            bold: inline(InlineKind::Strong),
899            italic: inline(InlineKind::Emph),
900            code: inline(InlineKind::Verbatim),
901            mark: inline(InlineKind::Mark),
902            underline: inline(InlineKind::Insert),
903            strike: inline(InlineKind::Delete),
904            mark_color: supports(Gesture::SetMarkColor),
905            superscript: inline(InlineKind::Superscript),
906            subscript: inline(InlineKind::Subscript),
907            heading: supports(Gesture::SetBlock),
908            blockquote: container(BlockContainerKind::BlockQuote),
909            bullet_list: container(BlockContainerKind::BulletList),
910            ordered_list: container(BlockContainerKind::OrderedList),
911            // Both halves of the checkbox story, and leaf offers no control that
912            // needs only one: the item gesture mints the box, the checked one
913            // ticks it, and a format spelling a `task_marker` spells both.
914            task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
915            link: supports(Gesture::InsertLink),
916            image: supports(Gesture::InsertImage),
917            thematic_break: supports(Gesture::InsertThematicBreak),
918            footnote: supports(Gesture::InsertFootnote),
919            code_language: supports(Gesture::SetCodeLanguage),
920            table: spells_pipe_tables(format),
921            cell_line_break: supports(Gesture::InsertLineBreak),
922            // The presentation vocabulary, one gesture per level: the two
923            // line-level properties are a block's attributes and the three
924            // run-level ones a span's. They are asked separately because the
925            // formats answer differently — AsciiDoc spells the block and not
926            // the span — and a toolbar that dimmed all five together would dim
927            // three controls that work.
928            alignment: supports(Gesture::SetBlockAttrs),
929            line_spacing: supports(Gesture::SetBlockAttrs),
930            font_size: supports(Gesture::WrapRangeAttrs),
931            font_family: supports(Gesture::WrapRangeAttrs),
932            text_color: supports(Gesture::WrapRangeAttrs),
933            // Narrower than the gesture, on purpose. Twig spells
934            // `InsertDirective` in HTML and AsciiDoc too, and spells it
935            // *differently* there — `<page-break></page-break>` and `<<<` —
936            // and the walker reads only the two spellings above. An HTML page
937            // break draws as nothing at all (no row, no caret home) and an
938            // AsciiDoc one as an empty unlabelled row, so the button would
939            // write a break the author cannot see and cannot get back to.
940            // The proposal claims Markdown and djot, and this is that claim.
941            // Widening it is the walker's work, not this line's — see
942            // `docs/tasks/page-break-in-html-and-asciidoc.md`.
943            page_break: supports(Gesture::InsertDirective)
944                && matches!(format, Format::Markdown | Format::Djot),
945        }
946    }
947}
948
949/// The source of [`Doc::identity`], one per document ever built.
950static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
951
952impl Doc {
953    #[cfg(feature = "fs")]
954    pub fn open(path: PathBuf) -> Result<Self> {
955        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
956        Self::from_disk_bytes(path, bytes)
957    }
958
959    /// An empty document *named* `path`, for a file that isn't there yet — what
960    /// every other terminal editor gives you when you name a file that doesn't
961    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
962    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
963    /// the header shows the name the user asked for.
964    ///
965    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
966    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
967    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
968    /// parse is still an error: a mistyped flag or a stray argument should say
969    /// so, not open a buffer promising to save somewhere.
970    ///
971    /// The watermark is the hash of *no bytes*, not `None`, and that is the
972    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
973    /// answering [`DiskState::Untitled`] for a document that has a path and
974    /// intends to write to it. Hashing `""` instead makes the answers the true
975    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
976    /// recreates it, which is exactly what this is for), and
977    /// [`DiskState::Changed`] if somebody creates it underneath us between
978    /// launch and save, so the frontend's overwrite prompt guards a new file as
979    /// it guards an opened one.
980    ///
981    /// Nothing is written here. A buffer that is never typed into never touches
982    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
983    /// open — the write is where that fails, and it says so then.
984    #[cfg(feature = "fs")]
985    pub fn create(path: PathBuf) -> Result<Self> {
986        Self::from_disk_bytes(path, Vec::new())
987    }
988
989    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
990    /// the call a CLI frontend wants for its path argument.
991    ///
992    /// The decision is made from the failed read itself rather than a `exists()`
993    /// check first, so there is no window between the two for the file to appear
994    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
995    /// a directory in the way is still an error, because pretending those are
996    /// "no file yet" would offer to save over something leaf couldn't read.
997    #[cfg(feature = "fs")]
998    pub fn open_or_create(path: PathBuf) -> Result<Self> {
999        match std::fs::read(&path) {
1000            Ok(bytes) => Self::from_disk_bytes(path, bytes),
1001            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1002            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1003        }
1004    }
1005
1006    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1007    /// stand in for) the file at `path`, parsed as the format its extension
1008    /// names. Keeping the two on one path is what makes a new file's document
1009    /// identical in every respect to an opened one but its contents.
1010    #[cfg(feature = "fs")]
1011    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1012        let format = detect_format(&path)?;
1013        let editor = new_editor(&bytes, format)?;
1014        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1015        let disk_hash = Some(hash_bytes(source.as_bytes()));
1016        // Store the document's *absolute* path. A relative one (`leaf README.md`)
1017        // has an empty parent, so a frontend can't resolve a relative image
1018        // destination (`![](pic.png)`) against the document's directory and the
1019        // picture silently falls back to its text placeholder. `absolute` is
1020        // purely lexical — it prefixes the current directory and normalizes, but
1021        // reads nothing and resolves no symlinks — so `file_name` and save are
1022        // unchanged; it only gives `path.parent()` something to join against.
1023        let path = std::path::absolute(&path).unwrap_or(path);
1024        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1025    }
1026
1027    /// Build a document from an in-memory string, the format named explicitly —
1028    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1029    /// path and sniffs the format from its extension). A wasm or FFI host, which
1030    /// has no path to read, uses this: it hands over bytes it fetched however it
1031    /// could, and later persists [`Doc::source`] however it can (a browser
1032    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1033    ///
1034    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1035    /// true) exactly like a [`Doc::blank`] that has been given content.
1036    pub fn from_source(source: String, format: Format) -> Result<Self> {
1037        let editor = new_editor(source.as_bytes(), format)?;
1038        Ok(Doc::from_parts(
1039            editor,
1040            format,
1041            PathBuf::new(),
1042            source,
1043            None,
1044        ))
1045    }
1046
1047    /// An untitled, empty document — the `+` button and a `leaf` launched with
1048    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1049    ///
1050    /// It is Markdown, because a format has to be chosen before a name exists to
1051    /// read one from: `detect_format` reads the extension and an untitled
1052    /// document has neither. Markdown is what leaf's own files are, what its
1053    /// block markers are already written for (`insert_block_prefix`), and the
1054    /// extension a Save As will overwhelmingly pick — a wrong guess here would
1055    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1056    /// *doesn't* revisit this: see [`Doc::save_as`].
1057    pub fn blank() -> Result<Self> {
1058        let format = Format::Markdown;
1059        let editor = new_editor(b"", format)?;
1060        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1061        // field two frontends already read; making it an `Option` to say this
1062        // would break both). `is_untitled` is the question to ask, not the
1063        // representation to copy.
1064        Ok(Doc::from_parts(
1065            editor,
1066            format,
1067            PathBuf::new(),
1068            String::new(),
1069            None,
1070        ))
1071    }
1072
1073    /// The fields every constructor agrees on, so `open` and `blank` can't drift
1074    /// apart in the ones neither of them has an opinion about.
1075    // `identity` is taken from a counter rather than from the `Doc`'s address,
1076    // which moves — a session that holds one is moved into and out of
1077    // containers freely, and an identity that changed with it would defeat the
1078    // one comparison it exists for.
1079    fn from_parts(
1080        editor: Editor,
1081        format: Format,
1082        path: PathBuf,
1083        source: String,
1084        disk_hash: Option<u64>,
1085    ) -> Self {
1086        Doc {
1087            editor,
1088            format,
1089            path,
1090            disk_hash,
1091            clean_source: source.clone(),
1092            source,
1093            caret: 0,
1094            anchor: None,
1095            dirty: false,
1096            status: None,
1097            read_only: false,
1098            highlights: Vec::new(),
1099            // leaf opens in the rich-text (WYSIWYG) view by default — the
1100            // markup-resolved surface is leaf's differentiator. Frontends can
1101            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1102            // toggles at runtime.
1103            view: View::Wysiwyg,
1104            // `None` by default — the clean surface Diaryx ships, with typed
1105            // syntax kept literal; a markup-fluent frontend can climb the
1106            // ladder to `Shortcuts` or `Full`.
1107            markup_mode: MarkupMode::default(),
1108            // Fold by default — flowing prose that reflows to the viewport, the
1109            // behaviour every frontend had before this preference existed.
1110            line_flow: LineFlow::default(),
1111            last_edit_kind: None,
1112            pending_marks: InlineMarks::empty(),
1113            pending_at: None,
1114            goal_col: None,
1115            vmap: VisualMap::default(),
1116            smap: SourceMap::default(),
1117            // No map yet — the first `build_source` always builds.
1118            smap_key: None,
1119            revision: 0,
1120            undo_steps: 0,
1121            redo_steps: 0,
1122            // No map yet — the first `build_visual` always builds.
1123            vmap_key: None,
1124            identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1125            block_cache: wysiwyg::BlockCache::default(),
1126            media_rows: HashMap::new(),
1127            scroll: 0,
1128            body_origin: (0, 0),
1129            body_width: 0,
1130            body_height: 0,
1131            drawn_caret: None,
1132        }
1133    }
1134
1135    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1136    /// has never been saved. The question a ⌘S handler asks to know it should
1137    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1138    /// header asks to know the name it shows is a placeholder.
1139    pub fn is_untitled(&self) -> bool {
1140        self.path.as_os_str().is_empty()
1141    }
1142
1143    pub fn toggle_view(&mut self) {
1144        self.view = match self.view {
1145            View::Source => View::Wysiwyg,
1146            View::Wysiwyg => View::Source,
1147        };
1148        self.scroll = 0;
1149        self.status = None;
1150        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1151        // lift it to the first rendered offset.
1152        self.clamp_caret();
1153    }
1154
1155    /// The current markup-exposure preference (see [`MarkupMode`]).
1156    pub fn markup_mode(&self) -> MarkupMode {
1157        self.markup_mode
1158    }
1159
1160    /// Set the markup-exposure preference. Both of its axes take effect at
1161    /// once: the editing one on the next [`insert`](Self::insert), and the
1162    /// rendering one on the next build — which is why this drops the cached
1163    /// visual map and the per-block render cache, exactly as
1164    /// [`set_line_flow`](Self::set_line_flow) does.
1165    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1166        if self.markup_mode == mode {
1167            return;
1168        }
1169        self.markup_mode = mode;
1170        // Neither cache is keyed on the mode, and moving between `Full` and the
1171        // hidden modes changes every row the caret's line renders to — so
1172        // invalidate both explicitly.
1173        self.vmap_key = None;
1174        self.block_cache = wysiwyg::BlockCache::default();
1175    }
1176
1177    /// The source byte range of the line the caret sits on, when that line
1178    /// should render its raw delimiters — `None` in every mode and view that
1179    /// hides them, which is what the builder reads as "reveal nothing".
1180    ///
1181    /// A *source* line (newline to newline), not a visual row: a wrapped
1182    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1183    /// line across several rows, and revealing half a delimiter pair because the
1184    /// other half wrapped would be worse than revealing neither. The range
1185    /// excludes the terminating newline and is empty-but-present on a blank
1186    /// line, which reveals nothing but still keys the caches correctly.
1187    ///
1188    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1189    /// there is nothing there to reveal.
1190    pub(crate) fn reveal_line(&self) -> Option<Range<usize>> {
1191        if !self.markup_mode.reveals_caret_line() || self.view != View::Wysiwyg {
1192            return None;
1193        }
1194        Some(source_line_range(&self.source, self.caret))
1195    }
1196
1197    /// The current soft-break flow preference (see [`LineFlow`]).
1198    pub fn line_flow(&self) -> LineFlow {
1199        self.line_flow
1200    }
1201
1202    /// Set the soft-break flow preference. The mode changes how every block lays
1203    /// out, so a change drops the cached visual map and the per-block render
1204    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1205    ///
1206    /// [`build_visual`]: Self::build_visual
1207    pub fn set_line_flow(&mut self, mode: LineFlow) {
1208        if self.line_flow == mode {
1209            return;
1210        }
1211        self.line_flow = mode;
1212        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1213        // so invalidate them explicitly, or the next build would reuse rows laid
1214        // out under the old flow.
1215        self.vmap_key = None;
1216        self.block_cache = wysiwyg::BlockCache::default();
1217    }
1218
1219    pub fn view_name(&self) -> &'static str {
1220        match self.view {
1221            View::Source => "source",
1222            View::Wysiwyg => "wysiwyg",
1223        }
1224    }
1225
1226    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1227    /// (called by the renderer each frame it's in the WYSIWYG view).
1228    /// Build the WYSIWYG map, wrapped at `width` display columns.
1229    ///
1230    /// Cheap to call every frame, which is what both frontends do: the map is a
1231    /// pure function of the document and the wrap width, so a call that would
1232    /// rebuild the same map returns the one already built. Only an edit (or a
1233    /// resize) pays.
1234    ///
1235    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1236    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1237    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1238    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1239    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1240    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1241    pub fn build_visual(&mut self, width: usize) {
1242        self.build_map(Some(width));
1243    }
1244
1245    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1246    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1247    /// than a fixed character column.
1248    pub fn build_visual_unwrapped(&mut self) {
1249        self.build_map(None);
1250    }
1251
1252    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1253    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1254    /// styling for [`View::Wysiwyg`].
1255    ///
1256    /// A frontend calls this before painting raw source. One that doesn't gets
1257    /// an empty map and paints unstyled text, so this is additive: nothing
1258    /// breaks by not calling it.
1259    ///
1260    /// Built at most once per revision, and the revision is the whole key — the
1261    /// map has no width and no caret in it, so it survives every resize, every
1262    /// motion, and every selection change.
1263    ///
1264    /// The builds it does do cost a whole-arena marshal, which is precisely what
1265    /// the WYSIWYG path works to avoid, so this has no incremental path where
1266    /// that one has two. From `cargo run --release -p leaf-core --example
1267    /// bench`, per keystroke, against the WYSIWYG build the source view is
1268    /// *not* doing:
1269    ///
1270    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1271    /// |------:|-------:|--------:|----------------:|-------------------:|
1272    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1273    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1274    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1275    ///
1276    /// Linear, two thirds of it the marshal, and the build itself five to seven
1277    /// times cheaper than the one it stands in for at every size. Comfortable
1278    /// well past any document a person edits in a terminal — a megabyte is where
1279    /// it would want [`Editor::dirty_range`] and the same splice treatment
1280    /// `build_spliced` gives the other map. The door is open; nothing has needed
1281    /// it yet.
1282    pub fn build_source(&mut self) {
1283        if self.smap_key == Some(self.revision) {
1284            return;
1285        }
1286        let nodes = self.nodes();
1287        self.smap = source::build(&nodes, &self.source);
1288        self.smap_key = Some(self.revision);
1289    }
1290
1291    /// Tell the model how many visual rows each block image should reserve, keyed
1292    /// by the image's destination. A terminal frontend calls this once it has
1293    /// decoded and measured its pictures — core does no image I/O, so this is the
1294    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1295    /// placeholder out that tall (the label row plus blank filler rows the
1296    /// frontend paints the raster over). A destination left out of the map falls
1297    /// back to the bare one-row placeholder, which is also what a frontend that
1298    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1299    /// by never calling this.
1300    ///
1301    /// Cheap to call every frame with the same map: only a *change* invalidates
1302    /// the built map (and the block-row cache, since a height isn't part of a
1303    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1304    /// no-op, so a frontend can just hand over its current measurements each frame.
1305    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1306        if self.media_rows == rows {
1307            return;
1308        }
1309        self.media_rows = rows;
1310        // A height lives outside the block's source bytes, so the content-keyed
1311        // block cache would hand back the old-height rows on a hit. Drop it (and
1312        // the splice layout it carries) so the next build re-renders every block
1313        // at the new heights, and force that build by clearing the map key.
1314        self.block_cache = wysiwyg::BlockCache::default();
1315        self.vmap_key = None;
1316    }
1317
1318    /// The revision the document's text is at — bumped by every edit, undo,
1319    /// redo, and reload, and by nothing else. A frontend caches against this to
1320    /// tell a repaint that needs new work from one that doesn't.
1321    ///
1322    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1323    /// lands on the same text two revisions later. Work is only ever rebuilt
1324    /// needlessly, never wrongly reused.
1325    pub fn revision(&self) -> u64 {
1326        self.revision
1327    }
1328
1329    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1330    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1331    /// unbuilt map before the first one.
1332    ///
1333    /// This is *not* [`revision`](Self::revision). The revision says where the
1334    /// text is; this says where the map is, and the two part company the moment
1335    /// an edit lands, until something rebuilds. A frontend that keeps its own
1336    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1337    /// under an oversized heading — compares this against the value it held when
1338    /// it took the copy, and learns whether `vmap` is still the map it stashed
1339    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1340    /// map would paint a stale document; restoring nothing hands core's
1341    /// incremental rebuild a map it never built.
1342    ///
1343    /// "Somebody else" includes another document. The key names the `Doc`
1344    /// as well as the build, so a frontend that draws two documents through
1345    /// one stash — a host with several buffers, or one that opens the next
1346    /// document where the last one stood — never has the copy it took of one
1347    /// accepted by the other, however alike their builds are.
1348    pub fn visual_key(&self) -> VisualKey {
1349        VisualKey(self.identity, self.vmap_key.clone())
1350    }
1351
1352    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1353    /// runs on every call: the caret moves without the document changing, and
1354    /// keeping it on a legal stop is this function's job either way.
1355    fn build_map(&mut self, wrap: Option<usize>) {
1356        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1357        // as well as the text, so the line joins the key: moving within a line
1358        // still reuses the map, and crossing into another one rebuilds it. In
1359        // every other mode `reveal_line` is `None` and the key is what it was,
1360        // so caret motion goes on costing nothing.
1361        let reveal = self.reveal_line();
1362        let key = (self.revision, wrap, reveal.clone());
1363        if self.vmap_key.as_ref() != Some(&key) {
1364            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1365            // A subtree is pulled only for the block(s) that actually changed, so
1366            // the FFI marshal shrinks from O(document) to O(edited block).
1367            let top = self.top_blocks();
1368
1369            // Fast path: when twig reports a dirty byte range, try to patch the
1370            // previous map in place — a single-block edit moves the prefix,
1371            // shifts the suffix, and re-renders only one block. `build_spliced`
1372            // returns `None` (and we fall back to the always-correct full rebuild)
1373            // whenever the edit reshaped the block structure, hit a table, or
1374            // there's no previous map to patch.
1375            // Preserve soft breaks as written when the flow preference asks for
1376            // it — the builder renders each as its own visual row instead of
1377            // folding it into the reflowed paragraph.
1378            let preserve_soft = self.line_flow == LineFlow::Preserve;
1379            let spliced = match self.editor.dirty_range() {
1380                Some(dirty) => {
1381                    let prev = std::mem::take(&mut self.vmap);
1382                    let source = &self.source;
1383                    let cache = &mut self.block_cache;
1384                    let media_rows = &self.media_rows;
1385                    let editor = &mut self.editor;
1386                    wysiwyg::build_spliced(
1387                        prev,
1388                        source,
1389                        wrap,
1390                        preserve_soft,
1391                        &top,
1392                        dirty,
1393                        media_rows,
1394                        reveal.clone(),
1395                        cache,
1396                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1397                    )
1398                }
1399                None => None,
1400            };
1401            self.vmap = spliced.unwrap_or_else(|| {
1402                let source = &self.source;
1403                let cache = &mut self.block_cache;
1404                let media_rows = &self.media_rows;
1405                let editor = &mut self.editor;
1406                wysiwyg::build_cached(
1407                    &top,
1408                    source,
1409                    wrap,
1410                    preserve_soft,
1411                    media_rows,
1412                    reveal,
1413                    cache,
1414                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1415                )
1416            });
1417            // Acknowledge the dirty range so the next edit's range starts fresh.
1418            self.editor.clear_dirty();
1419            self.vmap_key = Some(key);
1420        }
1421        self.clamp_caret();
1422    }
1423
1424    fn nodes(&mut self) -> Vec<FlatNode> {
1425        self.editor.nodes().unwrap_or_default()
1426    }
1427
1428    /// The document's top-level blocks for the incremental render. See
1429    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1430    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1431        wysiwyg::top_blocks(&mut self.editor)
1432    }
1433
1434    pub fn format_name(&self) -> &'static str {
1435        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1436        // required. It also covers `Asciidoc`, which twig parses but cannot
1437        // serialize — leaf never opens a document in it (see `Doc::open`).
1438        match self.format {
1439            Format::Djot => "djot",
1440            Format::Markdown => "markdown",
1441            Format::Xml => "xml",
1442            Format::Html => "html",
1443            _ => "unknown",
1444        }
1445    }
1446
1447    /// Whether this document's format offers *any* door in — `false` only for a
1448    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1449    /// a frontend may as well open the file read-only.
1450    ///
1451    /// This is a much weaker claim than the name suggests, and driving per-button
1452    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1453    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1454    /// while a heading, a quote, a list, a task box, a link and a code fence all
1455    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1456    /// [`supports`](Self::supports) — per control.
1457    pub fn authorable(&self) -> bool {
1458        self.format.is_authorable()
1459    }
1460
1461    /// Whether this document can spell `gesture`, which is twig's own answer
1462    /// rather than a copy of it: `Format::supports_with` reads the same
1463    /// `Syntax` table the `Editor` method consults before refusing, chosen by
1464    /// the very [`parse_extensions`] this document's editor reparses with — so
1465    /// what the toolbar offers and what the splice will accept are one table.
1466    ///
1467    /// It is a fact about the *document*, not about the caret. `true` does not
1468    /// promise the gesture succeeds where it is standing — a link over a table
1469    /// border still fails — only that it will not fail with
1470    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1471    /// will work here".
1472    pub fn supports(&self, gesture: Gesture) -> bool {
1473        self.format.supports_with(parse_extensions(), gesture)
1474    }
1475
1476    /// Every control's enabled state in one read — what a toolbar builds itself
1477    /// from when a document opens or its format changes. See [`Capabilities`].
1478    pub fn capabilities(&self) -> Capabilities {
1479        Capabilities::of(self.format)
1480    }
1481
1482    /// Refuse a gesture this document's format cannot spell, saying so in the
1483    /// status line. `true` means the caller must return without calling twig.
1484    ///
1485    /// Most of these refusals duplicate one twig would make anyway, and they are
1486    /// made here regardless because a message naming the *document's* format
1487    /// reads better than one naming twig's internals. Two of them are not
1488    /// duplicates and are the reason this is a guard rather than an error
1489    /// translation:
1490    ///
1491    /// - The table family (see [`table_op`](Self::table_op)) consults no
1492    ///   `Syntax` table, so twig does not refuse it at all.
1493    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1494    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1495    ///   could not keep.
1496    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1497        self.refuse_unless(what, self.supports(gesture))
1498    }
1499
1500    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1501    /// answers itself — today only [`spells_pipe_tables`].
1502    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1503        if supported {
1504            return false;
1505        }
1506        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1507        true
1508    }
1509
1510    /// The name to show for this document. An untitled one has no file to name
1511    /// it, and both frontends put this straight on screen — an empty path
1512    /// renders as an empty header, so it says so instead.
1513    pub fn file_name(&self) -> String {
1514        if self.is_untitled() {
1515            return "untitled".into();
1516        }
1517        self.path
1518            .file_name()
1519            .map(|s| s.to_string_lossy().into_owned())
1520            .unwrap_or_else(|| self.path.display().to_string())
1521    }
1522
1523    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1524    /// caret and anchor coincide (an empty selection is no selection).
1525    pub fn selection(&self) -> Option<(usize, usize)> {
1526        self.anchor
1527            .map(|a| (a.min(self.caret), a.max(self.caret)))
1528            .filter(|(s, e)| s != e)
1529    }
1530
1531    /// The selected text, or `None` when there's no selection — the source
1532    /// slice a copy/cut hands to the system clipboard.
1533    pub fn selected_text(&self) -> Option<&str> {
1534        self.selection().map(|(s, e)| &self.source[s..e])
1535    }
1536
1537    /// The selection as a quote with a little of what surrounds it — the shape
1538    /// a host that cites, annotates, or searches for a passage wants, cut from
1539    /// the **source** rather than from anything rendered, so the quote is
1540    /// findable in the document again by plain string search.
1541    ///
1542    /// `context` is a count of characters (not bytes) on each side, clipped at
1543    /// the document's edges; the slices land on char boundaries by
1544    /// construction. `None` when nothing is selected.
1545    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1546        let (start, end) = self.selection()?;
1547        let mut before = start;
1548        for _ in 0..context {
1549            match self.source[..before].chars().next_back() {
1550                Some(c) => before -= c.len_utf8(),
1551                None => break,
1552            }
1553        }
1554        let mut after = end;
1555        for _ in 0..context {
1556            match self.source[after..].chars().next() {
1557                Some(c) => after += c.len_utf8(),
1558                None => break,
1559            }
1560        }
1561        Some(Quote {
1562            exact: self.source[start..end].to_string(),
1563            prefix: self.source[before..start].to_string(),
1564            suffix: self.source[end..after].to_string(),
1565            start,
1566            end,
1567        })
1568    }
1569
1570    /// Whether the document refuses to change — see the field.
1571    pub fn read_only(&self) -> bool {
1572        self.read_only
1573    }
1574
1575    /// Turn the read-only gate on or off. A frontend preference like
1576    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1577    /// itself changes, only what may be done to it from here on.
1578    pub fn set_read_only(&mut self, on: bool) {
1579        self.read_only = on;
1580    }
1581
1582    /// The host-painted ranges, sorted by start — see [`Highlight`].
1583    pub fn highlights(&self) -> &[Highlight] {
1584        &self.highlights
1585    }
1586
1587    /// Replace the host-painted ranges wholesale. The whole set each time,
1588    /// rather than add/remove verbs: the host owns the list (it derives it
1589    /// from its own state — annotations, search hits), and a replace can
1590    /// never leave the two disagreeing about what should be on screen.
1591    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1592        highlights.retain(|h| h.start < h.end);
1593        highlights.sort_by_key(|h| (h.start, h.end));
1594        self.highlights = highlights;
1595    }
1596
1597    /// The highlight covering source `offset`, if one does — first by start
1598    /// when several overlap, which makes overlapping washes resolvable rather
1599    /// than undefined. What a frontend asks when the reader activates a spot.
1600    ///
1601    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1602    /// asking the same question per glyph, against a slice they were handed
1603    /// rather than against a `Doc`, and one answer for both is what keeps a
1604    /// wash and an activation agreeing about which range a spot is in.
1605    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1606        Highlight::covering(&self.highlights, offset)
1607    }
1608
1609    /// The AST breadcrumb at the caret (root → deepest), e.g.
1610    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1611    pub fn breadcrumb(&mut self) -> String {
1612        match self.editor.ancestors_at(self.caret) {
1613            Ok(chain) => chain
1614                .iter()
1615                .map(|m| m.kind.as_str())
1616                .collect::<Vec<_>>()
1617                .join(" › "),
1618            Err(_) => String::new(),
1619        }
1620    }
1621
1622    // ── editing ──────────────────────────────────────────────────────────────
1623
1624    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1625    /// after it. The public form of the internal splice — a pixel frontend that
1626    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1627    /// edits through this, the same twig `edit_range` the caret ops use.
1628    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1629        self.splice(start, end, text, EditKind::Other);
1630    }
1631
1632    /// Insert typed `text` at the caret, replacing the selection if there is one.
1633    /// A single typed character coalesces with the run of typing before it; a
1634    /// newline or a multi-character insert is its own undo step.
1635    ///
1636    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1637    pub fn insert(&mut self, text: &str) {
1638        // The read-only gate, up front: the paths below reach twig by several
1639        // verbs, not all of them through the splice — see the field.
1640        if self.read_only {
1641            return;
1642        }
1643        // Typing against a block picture would dissolve it, and typing past a
1644        // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1645        // the text a paragraph first, so what the caret was standing beside
1646        // stays what it was.
1647        self.open_paragraph_at_block_edge(text);
1648        // Armed sticky marks (⌘b with no selection) turn the next typed text
1649        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1650        // the exception: it takes no mark of its own and keeps the delta armed
1651        // for the character behind it — see `insert_space_with_marks`.
1652        let pending = self.pending_here();
1653        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1654            if text.trim().is_empty() {
1655                self.insert_space_with_marks(self.caret, text, pending);
1656            } else {
1657                self.insert_with_marks(self.caret, text, pending);
1658            }
1659            return;
1660        }
1661        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1662        // anything that would open markup, so a Diaryx user never mints
1663        // formatting by keyboard (it comes from commands instead). The other two
1664        // rungs of the ladder author markup from what you type, which is the
1665        // whole difference between them and this one. Only in the rendered view
1666        // (source view is for typing raw markup) and only where the format has a
1667        // literal spelling at all: escaping is a backslash before a byte from the
1668        // format's own alphabet, and a format with no such alphabet (HTML escapes
1669        // with entities, XML spells nothing) would have `\&` written into it,
1670        // which is two literal characters and not an escape. Marks (⌘b) still
1671        // format — that path returned above; and leaf's own structural inserts go
1672        // through `insert_raw`, never here, so a list marker or quote gutter is
1673        // written as the markup it is.
1674        if !self.markup_mode.authors()
1675            && self.view == View::Wysiwyg
1676            && !text.is_empty()
1677            && self.supports(Gesture::InsertLiteral)
1678        {
1679            self.insert_literal_typed(text);
1680            return;
1681        }
1682        self.insert_raw(text);
1683    }
1684
1685    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1686    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1687    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1688    /// markup by design and must not be escaped.
1689    fn insert_raw(&mut self, text: &str) {
1690        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1691        self.splice(s, e, text, typed_edit_kind(text));
1692    }
1693
1694    /// Open a paragraph for text about to be inserted at one of a block media's
1695    /// two caret stops, or at a table's trailing stop, and leave the caret
1696    /// standing in it.
1697    ///
1698    /// A block image is a paragraph whose entire content is the picture, and the
1699    /// caret's only homes on it are in front of it and just past it (see
1700    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1701    /// *that* paragraph — and a paragraph holding anything besides the image is
1702    /// no longer a block image but a line of text with an inline one in it. The
1703    /// frontend that was painting a photo there paints a text run instead; the
1704    /// picture is still in the file, and nothing said a word. Those two offsets
1705    /// are also exactly where a click on the picture lands, so the whole accident
1706    /// is one tap and one keystroke.
1707    ///
1708    /// So the break goes in first and the text lands in the new empty paragraph —
1709    /// what pressing Return before typing would have done, which is a habit no
1710    /// one should have to learn from losing a photo. A no-op everywhere else, and
1711    /// over a selection (which is replaced, not joined into).
1712    ///
1713    /// A picture inside a quote or a list leaves its container, because `\n\n`
1714    /// ends the block. The alternative is worse: the `\n> ` / next-item
1715    /// continuation [`newline`](Self::newline) writes stays in the same
1716    /// *paragraph*, which is the thing being prevented.
1717    ///
1718    /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1719    /// accident from the other side of a different block: the stop sits at the
1720    /// end of the table's last source line, and a line glued under a table is
1721    /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1722    /// break goes in there too, and the text lands under the table.
1723    ///
1724    /// Only in the rendered view. Source view is for typing raw markup, where
1725    /// putting a character against an image is exactly what it looks like.
1726    fn open_paragraph_at_block_edge(&mut self, text: &str) {
1727        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1728            return;
1729        }
1730        if self.selection().is_some() {
1731            return;
1732        }
1733        // The map may be a revision behind (nothing has drawn since the last
1734        // edit), and this asks it about offsets — a stale answer would splice a
1735        // break into the wrong place. Free when it is already current, which it
1736        // is whenever a frontend drew a frame between keystrokes.
1737        self.rebuild_map();
1738        let at = self.caret;
1739        let side = match self.vmap.block_media_stop(at) {
1740            Some((side, _)) => side,
1741            None if self.vmap.table_end_stop(at) => MediaStop::After,
1742            None => return,
1743        };
1744        if !self.splice(at, at, "\n\n", EditKind::Other) {
1745            return;
1746        }
1747        // The break is part of the keystroke, not an edit of its own: leave the
1748        // run marked as typing so the character about to arrive folds into it and
1749        // one undo puts the document back the way it was found. (A paste, or a
1750        // multi-character insert, is `EditKind::Other` and stays its own step —
1751        // as it would have been anywhere else in the document.)
1752        self.last_edit_kind = Some(EditKind::Insert);
1753        if side == MediaStop::Before {
1754            // The break went in above the picture and the caret rode to the end
1755            // of it — which is still hard against the picture. Step back onto the
1756            // blank line it opened, so the text lands above rather than in front.
1757            self.caret = at;
1758        }
1759    }
1760
1761    /// A delete key pressed at one of a block picture's two caret stops, handled
1762    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1763    /// the key was consumed.
1764    ///
1765    /// The caret rests in front of a block image and just past it, never inside
1766    /// its markup — which the rendered view doesn't show. So the byte a delete
1767    /// key nominally takes there is one the writer cannot see, and taking it
1768    /// leaves the picture as broken markup rather than as anything anyone asked
1769    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1770    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1771    /// missing from a document with nobody having touched it — the same
1772    /// dissolution [`open_paragraph_at_block_edge`](Self::open_paragraph_at_block_edge)
1773    /// prevents from the typing side, and it cost this repository's own test vault
1774    /// a photo before it was found.
1775    ///
1776    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1777    /// when it is behind the caret, Delete when it is in front — which is what
1778    /// every editor does with an embed, and one undo away. The key aimed *away*
1779    /// from it would otherwise delete the paragraph break and merge a neighbour
1780    /// into the picture's own paragraph, which dissolves it just as surely; it
1781    /// steps the caret over the boundary instead and leaves the
1782    /// next press to delete in the block it has reached — the same "first press
1783    /// steps out of the atom, second press deletes" every delete key here gets,
1784    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1785    /// above, and reaches it on the second press rather than taking the break and
1786    /// the picture with it on the first).
1787    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1788        // The map answers about offsets, so it has to be this revision's — see
1789        // the same call in `open_paragraph_at_block_edge`.
1790        self.rebuild_map();
1791        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1792            return false;
1793        };
1794        let aimed_at_it = side
1795            == if forward {
1796                MediaStop::Before
1797            } else {
1798                MediaStop::After
1799            };
1800        if !aimed_at_it {
1801            let over = if forward {
1802                self.vmap.stop_after(self.caret)
1803            } else {
1804                self.vmap.stop_before(self.caret)
1805            };
1806            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1807                self.caret = off;
1808                self.anchor = None;
1809                self.goal_col = None;
1810            }
1811            return true;
1812        }
1813        // Take the break that held the picture apart from its neighbour with it,
1814        // so the delete doesn't leave a blank paragraph standing where the
1815        // picture was. The last arm is a picture that is the whole document.
1816        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1817            (span.start - 2, span.end)
1818        } else if self.source[span.end..].starts_with("\n\n") {
1819            (span.start, span.end + 2)
1820        } else {
1821            (span.start, span.end)
1822        };
1823        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1824        true
1825    }
1826
1827    /// The Hidden-mode typing path: replace any selection, then insert `text`
1828    /// escaped so it stays literal. When it replaces a selection the two edits
1829    /// fold into one undo step, so an overwrite undoes atomically (and restores
1830    /// the selection) exactly as a plain one does.
1831    fn insert_literal_typed(&mut self, text: &str) {
1832        let kind = typed_edit_kind(text);
1833        match self.selection() {
1834            Some((s, e)) => {
1835                if !self.splice(s, e, "", EditKind::Other) {
1836                    return;
1837                }
1838                // Typing over a whole marked run takes its delimiters with it
1839                // (the empty content couldn't hold them — see
1840                // `repair_mark_edges`) and leaves its marks armed at the caret.
1841                // The text taking the run's place inherits them, exactly as it
1842                // would have by landing inside a run that survived.
1843                let pending = self.pending_here();
1844                if !pending.is_empty() && !text.trim().is_empty() {
1845                    self.insert_with_marks(self.caret, text, pending);
1846                    return;
1847                }
1848                self.insert_literal_at(self.caret, text, kind, true);
1849            }
1850            None => {
1851                self.insert_literal_at(self.caret, text, kind, false);
1852            }
1853        }
1854    }
1855
1856    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
1857    /// at a collapsed caret, but only while the caret still stands where they
1858    /// were armed and nothing is selected. Empty otherwise, so a stale delta
1859    /// never styles text it wasn't meant for.
1860    fn pending_here(&self) -> InlineMarks {
1861        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
1862            self.pending_marks
1863        } else {
1864            InlineMarks::empty()
1865        }
1866    }
1867
1868    /// Drop the armed sticky marks — any caret motion, selection, or edit does
1869    /// this, so "start bold here" only ever applies at the exact spot it was
1870    /// asked for.
1871    fn clear_pending(&mut self) {
1872        self.pending_marks = InlineMarks::empty();
1873        self.pending_at = None;
1874    }
1875
1876    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
1877    /// force is wrapped around the freshly typed text; a mark the caret already
1878    /// stands inside is *shed* — the text is inserted past the run's end so it
1879    /// lands unmarked ("type normally again"). The caret comes to rest inside any
1880    /// added runs, so continued typing inherits the marks with no re-wrapping,
1881    /// and the delta is cleared: the marks now live in the document, not here.
1882    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1883        let base = self.mark_spans_at(at);
1884        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
1885        // Nothing to shed, and a run of exactly these marks standing just behind
1886        // the caret: carry on writing *that* run rather than opening a second
1887        // one beside it.
1888        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
1889            return;
1890        }
1891        // Shed the marks we're turning off: step the insertion point past the
1892        // end of each run the caret sits in, so the new text falls outside it.
1893        let mut ins_at = at;
1894        for (kind, span) in &base {
1895            if marks.contains(*kind) {
1896                ins_at = ins_at.max(span.end);
1897            }
1898        }
1899        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
1900            return;
1901        }
1902        // The plain splice inserted exactly `text` at `ins_at`; that byte range
1903        // is the content every added mark wraps.
1904        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
1905        for kind in marks.iter() {
1906            if !base_set.contains(kind) {
1907                let (ncs, nce) = self.wrap_span(cs, ce, kind);
1908                cs = ncs;
1909                ce = nce;
1910            }
1911        }
1912        self.caret = ce.min(self.source.len());
1913        self.anchor = None;
1914        self.last_edit_kind = None;
1915        // Realised: the marks are in the document now, and the caret sits inside
1916        // them, so there is no delta left to carry. Arm nothing, but remember the
1917        // spot so a *further* toggle before typing starts a clean delta here.
1918        self.pending_marks = InlineMarks::empty();
1919        self.pending_at = Some(self.caret);
1920        self.clamp_caret();
1921        self.record_caret();
1922    }
1923
1924    /// Carry on the marked run just behind `at` — moving its closing delimiters
1925    /// out past the new text — instead of opening a second run of the same marks
1926    /// beside it. Returns whether it did.
1927    ///
1928    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
1929    /// A space typed after a bold word steps the caret out of the run, because
1930    /// `**bold **` is not bold; the next character has to step back *in*, or the
1931    /// writer who typed one bold phrase is left with `**bold** **and**` — two
1932    /// runs that read the same to a reader but spell the file in a way nobody
1933    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
1934    /// words it isn't marking), and the marks behind it must be exactly the ones
1935    /// armed — a run of *some* other kind is a neighbour, not this phrase.
1936    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
1937        if text.is_empty() || text.trim() != text {
1938            return false;
1939        }
1940        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
1941        // Walk in through the delimiters stacked at that point, innermost last:
1942        // `***both*** ` closes two runs with one `***`, and rejoining means
1943        // getting behind all of them.
1944        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
1945        while let Some((kind, content_end)) = self
1946            .editor
1947            .ancestors_at(prev_boundary(&self.source, cut))
1948            .unwrap_or_default()
1949            .into_iter()
1950            .filter(|m| m.span.end == cut)
1951            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
1952        {
1953            if content_end >= cut {
1954                break; // a mark with no closing delimiter to step behind
1955            }
1956            kinds.insert(kind);
1957            cut = content_end;
1958        }
1959        if cut == gap_at || kinds != marks {
1960            return false;
1961        }
1962        // Re-spell the tail: the gap, then the new text, then the delimiters that
1963        // used to close in front of them — read out of the document rather than
1964        // written from a table, so whatever twig spells them with is what moves.
1965        let tail = format!(
1966            "{}{text}{}",
1967            &self.source[gap_at..at],
1968            &self.source[cut..gap_at]
1969        );
1970        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
1971            return false;
1972        }
1973        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
1974        self.anchor = None;
1975        self.last_edit_kind = None;
1976        self.pending_marks = InlineMarks::empty();
1977        self.pending_at = Some(self.caret);
1978        self.clamp_caret();
1979        self.record_caret();
1980        true
1981    }
1982
1983    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
1984    /// never itself wrapped: a mark around a space draws nothing a reader can
1985    /// see, and in Markdown and Djot it draws its own delimiters instead
1986    /// (`** **`). So the space goes in unmarked — outside any run the armed
1987    /// marks are shedding — and the marks stay armed for the character after it,
1988    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
1989    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
1990        let base = self.mark_spans_at(at);
1991        // What the *next* character carries: the armed delta resolved against the
1992        // marks in force here, which the space must not quietly drop.
1993        let want = base
1994            .iter()
1995            .map(|(k, _)| *k)
1996            .collect::<InlineMarks>()
1997            .xor(marks);
1998        let mut ins_at = at;
1999        for (kind, span) in &base {
2000            if marks.contains(*kind) {
2001                ins_at = ins_at.max(span.end);
2002            }
2003        }
2004        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2005            return;
2006        }
2007        self.rearm(want);
2008        self.record_caret();
2009    }
2010
2011    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2012    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2013    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2014    /// added split evenly around the content — half the growth on each side.
2015    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2016        // The read-only gate — this door reaches twig without the splice.
2017        if self.read_only {
2018            return (s, e);
2019        }
2020        match self.editor.toggle_inline(s, e, kind) {
2021            Ok(change) => {
2022                self.last_edit_kind = None;
2023                self.refresh();
2024                self.dirty = self.source != self.clean_source;
2025                let added = (change.new.end - change.new.start).saturating_sub(e - s);
2026                let half = added / 2;
2027                (change.new.start + half, change.new.end - half)
2028            }
2029            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2030            // rather than lose the keystroke.
2031            Err(e2) => {
2032                self.status = Some(format!("{kind:?}: {e2}"));
2033                (s, e)
2034            }
2035        }
2036    }
2037
2038    /// The safe offset to splice a block-level break at, given a caret that may
2039    /// sit exactly between an inline mark's content and its own closing
2040    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2041    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2042    /// with nothing following it on the line: the closing `**` renders no
2043    /// glyph of its own, so the caret's "end of line" offset lands right
2044    /// before it). Splicing a paragraph/list/quote break at `off` itself would
2045    /// sever the delimiter from its content, stranding it alone on the new
2046    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2047    /// nested marks closing at the same point (`**_x_**`) all clear together.
2048    /// A no-op everywhere else — mid-run, or past real trailing content, no
2049    /// mark's `content_span` ends exactly at `off`.
2050    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2051        let off = off.min(self.source.len());
2052        self.editor
2053            .ancestors_at(off)
2054            .unwrap_or_default()
2055            .into_iter()
2056            .filter(|m| inline_kind(&m.kind).is_some())
2057            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2058            .map(|m| m.span.end)
2059            .max()
2060            .unwrap_or(off)
2061    }
2062
2063    /// The offset a *delete* aimed at the character before `off` should stop at,
2064    /// when `off` is the start of a run's text and the bytes behind it are that
2065    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2066    /// byte behind the caret at the start of a bold word is not a character the
2067    /// writer can see, let alone one they aimed Backspace at: taking it leaves
2068    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2069    /// delete steps over the whole delimiter to the visible character in front of
2070    /// it instead. Walks out to the *outermost* mark opening there, so
2071    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2072    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2073        let off = off.min(self.source.len());
2074        self.editor
2075            .ancestors_at(off)
2076            .unwrap_or_default()
2077            .into_iter()
2078            .filter(|m| inline_kind(&m.kind).is_some())
2079            .filter(|m| {
2080                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2081            })
2082            .map(|m| m.span.start)
2083            .min()
2084            .unwrap_or(off)
2085    }
2086
2087    /// `off` moved *inside* the run whose closing delimiters end there — the
2088    /// other offset the rich view draws in the same place, since a `**` renders
2089    /// no glyph of its own. `**bold**` has a caret home on each side of its
2090    /// closing delimiter, one column apart on screen and eight bytes and a whole
2091    /// run apart in the file, and a plain ← lands on the outer one whenever a
2092    /// space follows the phrase. The inner one is what the writer is pointing at
2093    /// there: the end of their bold word. Walks in through every mark closing at
2094    /// that point, innermost last, so `***both***` lands inside both. A no-op
2095    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2096    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2097        let mut off = off.min(self.source.len());
2098        loop {
2099            let inner = self
2100                .editor
2101                .ancestors_at(prev_boundary(&self.source, off))
2102                .unwrap_or_default()
2103                .into_iter()
2104                .filter(|m| inline_kind(&m.kind).is_some() && m.span.end == off)
2105                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2106                .filter(|&end| end < off)
2107                .max();
2108            match inner {
2109                Some(end) => off = end,
2110                None => return off,
2111            }
2112        }
2113    }
2114
2115    /// The mirror at the opening edge: `off` moved inside the run whose
2116    /// delimiters *start* there, onto the first character of its text. See
2117    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2118    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2119        let mut off = off.min(self.source.len());
2120        loop {
2121            let inner = self
2122                .editor
2123                .ancestors_at(off)
2124                .unwrap_or_default()
2125                .into_iter()
2126                .filter(|m| inline_kind(&m.kind).is_some() && m.span.start == off)
2127                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2128                .filter(|&start| start > off)
2129                .min();
2130            match inner {
2131                Some(start) => off = start,
2132                None => return off,
2133            }
2134        }
2135    }
2136
2137    /// The inline mark kinds whose span covers `off`, each with that span — the
2138    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2139    /// ids instead. Used to shed a mark by stepping past the end of its run.
2140    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2141        let off = off.min(self.source.len());
2142        self.editor
2143            .ancestors_at(off)
2144            .unwrap_or_default()
2145            .into_iter()
2146            .filter(|m| off < m.span.end)
2147            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2148            .collect()
2149    }
2150
2151    /// Insert clipboard `text` at the caret, replacing the selection if there is
2152    /// one — always its own undo step, whatever its length.
2153    ///
2154    /// Provenance is the whole point, and only the caller has it. `insert` reads
2155    /// a lone character as a keystroke and folds it into the run around it,
2156    /// which is right for typing and wrong for a one-character paste: that paste
2157    /// would vanish mid-run on an undo it was never part of, and the characters
2158    /// the user actually typed would go with it. Length can't tell the two
2159    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2160    /// caller comes through is what says which happened.
2161    pub fn paste(&mut self, text: &str) {
2162        // Pasting against a block picture or a table's end joins the block
2163        // exactly as typing does, and for the same reason — see
2164        // `open_paragraph_at_block_edge`.
2165        self.open_paragraph_at_block_edge(text);
2166        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2167        self.splice(s, e, text, EditKind::Other);
2168    }
2169
2170    /// Replace `[start, end)` with `text` as one step of an IME composition —
2171    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2172    /// folds into a single undo.
2173    ///
2174    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2175    /// dozen calls here, each replacing the last one's provisional bytes, and an
2176    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2177    /// unspools backwards through kana — the intermediate states were never text
2178    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2179    /// look like any other edit), so the door the caller comes through is what
2180    /// says so, exactly as it is for [`paste`](Self::paste) versus
2181    /// [`insert`](Self::insert).
2182    ///
2183    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2184    /// composition folds into this one.
2185    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2186        self.splice(start, end, text, EditKind::Compose);
2187    }
2188
2189    /// Close the open composition run, so the next one is its own undo step.
2190    /// Call when the IME commits or withdraws a composition.
2191    ///
2192    /// Only clears a *composition* run: a frontend that reports an end it never
2193    /// began (some IMEs unmark unprompted) would otherwise split the run of
2194    /// typing around it into two undo steps for no reason the user can see.
2195    pub fn end_composition(&mut self) {
2196        if self.last_edit_kind == Some(EditKind::Compose) {
2197            self.last_edit_kind = None;
2198        }
2199    }
2200
2201    // ── the clipboard's rich flavor ──────────────────────────────────────────
2202
2203    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2204    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2205    /// nothing is selected, or when the selection doesn't render (the caller
2206    /// still has [`selected_text`](Self::selected_text), which is what to publish
2207    /// as `text/plain` either way).
2208    ///
2209    /// **The fragment is a source substring, and that is the honest limit here.**
2210    /// It's parsed standalone, so a selection whose meaning depends on its
2211    /// surroundings converts as what it literally says rather than what it looks
2212    /// like on screen: half a list item is a paragraph, a row torn out of a table
2213    /// is the text of a row, the `**` of a bold run selected without its closing
2214    /// `**` is two asterisks. Every one of those still *renders* — there's no
2215    /// error to report — it just renders as the fragment and not as the document.
2216    /// Widening the range to whole blocks would publish text the user didn't
2217    /// select, which is a worse lie than a fragment being a fragment; the plain
2218    /// flavor has the same substring, so the two flavors at least agree.
2219    pub fn selection_html(&mut self) -> Option<String> {
2220        let (start, end) = self.selection()?;
2221        let inline = self.selection_is_inline(start, end);
2222        let html = html::render_fragment(&self.source[start..end], self.format)?;
2223        Some(match inline {
2224            true => html::strip_sole_paragraph(html),
2225            false => html,
2226        })
2227    }
2228
2229    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2230    /// format first. Its own undo step, like any [`paste`](Self::paste).
2231    ///
2232    /// Returns whether it landed. `false` means the HTML didn't convert to
2233    /// anything worth pasting — the caller should fall back to the plain flavor
2234    /// rather than treat it as an error. The `html` module has the full list of
2235    /// what that covers: a table twig won't build, markup it doesn't recognise,
2236    /// an empty result.
2237    pub fn paste_html(&mut self, html: &str) -> bool {
2238        match html::parse_fragment(html, self.format) {
2239            Some(source) => {
2240                self.paste(&source);
2241                true
2242            }
2243            None => false,
2244        }
2245    }
2246
2247    /// Does the selection live *inside* a single top-level block?
2248    ///
2249    /// The question [`selection_html`](Self::selection_html) needs and the
2250    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2251    /// whether the user selected one word of a sentence or a whole paragraph, and
2252    /// only the document knows which. Selecting a word and pasting into Docs
2253    /// should extend the line you paste into; selecting the paragraph should make
2254    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2255    /// is an artifact of standalone parsing), and one that covers a whole block —
2256    /// or spans two — keeps its structure.
2257    ///
2258    /// Reads the block from twig rather than guessing from the bytes:
2259    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2260    /// block containing an offset, and two ends inside the same one cannot have
2261    /// crossed a block boundary.
2262    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2263        // The last *character*, not `end - 1`: the selection's end is exclusive
2264        // and may sit mid-codepoint's-worth of bytes past the last char.
2265        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2266            return false;
2267        };
2268        let (Some(head), Some(tail)) =
2269            (self.top_block_span(start), self.top_block_span(start + off))
2270        else {
2271            return false;
2272        };
2273        head == tail && !(start <= head.start && end >= head.end)
2274    }
2275
2276    /// The byte span of the top-level block containing `offset`, or `None` at an
2277    /// offset that belongs to no block (the blank line between two of them).
2278    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2279        self.editor
2280            .ancestors_at(offset)
2281            .ok()?
2282            .get(1)
2283            .map(|m| m.span.clone())
2284    }
2285
2286    // ── indentation ──────────────────────────────────────────────────────────
2287
2288    /// One indent level.
2289    ///
2290    /// Two spaces, not the four both frontends type for Tab today, because in a
2291    /// markdown document four columns isn't a width — it's a *meaning*. Four
2292    /// spaces at the head of a line is markdown's indented-code-block marker, so
2293    /// one Tab on a paragraph would reparse it into code and style it as such;
2294    /// two cannot, and the line stays the prose it was. Two is also exactly
2295    /// where a `- ` bullet's content starts, so an indented line lands under its
2296    /// parent item's text instead of beside it — the column a list-aware indent
2297    /// has to hit anyway, which keeps this width from being relitigated later.
2298    const INDENT: &'static str = "  ";
2299
2300    /// Indent the selected lines — or the caret's line, with no selection — by
2301    /// one level (Tab).
2302    pub fn indent(&mut self) {
2303        self.reindent(true);
2304        // Nesting changes an ordered list's numbering (the nested item restarts,
2305        // its old siblings resume) — keep the source markers in step.
2306        self.renumber_here();
2307        // Nesting an empty `-` item under a text line reparses that text as a
2308        // setext heading; swap the dash for a `*` before it can (a no-op unless
2309        // the collapse actually happened).
2310        self.avoid_setext_collapse();
2311    }
2312
2313    /// Take one indent level back off the selected lines, or the caret's line
2314    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2315    ///
2316    /// A line with *less* than a full level gives back what it has rather than
2317    /// refusing: outdent's job is to walk a line left, and real documents — hand
2318    /// written, or reflowed by some other editor — are full of indentation that
2319    /// was never a clean multiple of anything. Refusing there would strand the
2320    /// line at a depth Shift+Tab couldn't undo.
2321    pub fn outdent(&mut self) {
2322        self.reindent(false);
2323        self.renumber_here();
2324    }
2325
2326    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2327    ///
2328    /// One splice across the whole line range, never one per line: a Tab is one
2329    /// thing the user did, so it has to be one undo step and one reparse. Per
2330    /// line, twig would reparse the document once per line and leave a stack of
2331    /// steps that Shift+⌘Z walks back one line at a time.
2332    fn reindent(&mut self, add: bool) {
2333        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2334        let start = source_line_range(&self.source, sel_start).start;
2335        let end = source_line_range(&self.source, sel_end).end;
2336        let region = self.source[start..end].to_string();
2337        let lines: Vec<&str> = region.split('\n').collect();
2338        // A blank line has no text to move, and padding it would leave nothing
2339        // but trailing whitespace — but Tab on a blank line *is* a request for
2340        // indentation to type into, so the skip only applies where the op has
2341        // other lines to do real work on.
2342        let skip_blank = add && lines.len() > 1;
2343
2344        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2345        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2346        let mut line_off = start;
2347        for (i, full) in lines.iter().enumerate() {
2348            if i > 0 {
2349                out.push('\n');
2350            }
2351            // A list item moves by having its whole leading prefix *replaced*,
2352            // never by having spaces pushed in front of the line. twig spells
2353            // both prefixes, so the quote markers, the parent's indent and an
2354            // ordered marker's extra column all come out right without leaf
2355            // measuring any of them — and a line that only looks like an item
2356            // (a Djot continuation) reports no marker and is left to the plain
2357            // path, where a Tab is just a Tab.
2358            let marker = self.list_marker_on_line(line_off);
2359            let own = marker
2360                .as_ref()
2361                .map(|m| m.marker_start - m.line_start)
2362                .unwrap_or(0);
2363            let delta = if add {
2364                if skip_blank && full.trim().is_empty() {
2365                    out.push_str(full);
2366                    0
2367                } else if marker.is_some() && self.first_item_of_list(line_off) {
2368                    // The first item of a list has no preceding sibling to nest
2369                    // under, so a Tab here can't spell a sub-list — twig would
2370                    // reparse the shoved-over marker as the same list, only
2371                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2372                    // item where it is, the way every list editor refuses to
2373                    // over-indent a list's first line.
2374                    out.push_str(full);
2375                    0
2376                } else if marker.is_some() {
2377                    // Nesting means standing where a *continuation* of this line
2378                    // would stand: past the parent's marker, inside its content
2379                    // column. That is `continuation_prefix`, less a checkbox.
2380                    let new = self.nesting_prefix_at(line_off);
2381                    let delta = new.len() as isize - own as isize;
2382                    out.push_str(&new);
2383                    out.push_str(&full[own..]);
2384                    delta
2385                } else {
2386                    out.push_str(Self::INDENT);
2387                    out.push_str(full);
2388                    Self::INDENT.len() as isize
2389                }
2390            } else if marker.is_some() {
2391                // Unnesting is the mirror: stand where the parent item's own
2392                // line starts, which drops exactly the level it contributed.
2393                let new = self.outdent_prefix_at(line_off);
2394                let delta = new.len() as isize - own as isize;
2395                out.push_str(&new);
2396                out.push_str(&full[own..]);
2397                delta
2398            } else {
2399                // A plain line gives back the ordinary step.
2400                let strip = outdent_width(full, Self::INDENT.len());
2401                out.push_str(&full[strip..]);
2402                -(strip as isize)
2403            };
2404            deltas.push(delta);
2405            line_off += full.len() + 1;
2406        }
2407        // Nothing to give back. Returning before the splice keeps an outdent at
2408        // column zero from spending an undo step on a document it never changed.
2409        if deltas.iter().all(|d| *d == 0) {
2410            return;
2411        }
2412
2413        // Every line's text keeps its offset *within the line*, so the caret is
2414        // remapped by its column, not by its byte offset — which the prefixes on
2415        // the lines above it have already invalidated.
2416        let remap = |off: usize| -> usize {
2417            let (mut old_ls, mut new_ls) = (start, start);
2418            for (line, delta) in lines.iter().zip(&deltas) {
2419                let old_le = old_ls + line.len();
2420                let new_len = (line.len() as isize + delta) as usize;
2421                if off <= old_le {
2422                    let col = (off - old_ls) as isize;
2423                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2424                }
2425                old_ls = old_le + 1;
2426                new_ls += new_len + 1;
2427            }
2428            start + out.len()
2429        };
2430        let placed = match self.selection() {
2431            // Keep the rewritten region selected, the way a container toggle
2432            // keeps its own: it leaves a second Tab aimed at the same lines
2433            // rather than at whatever the shifted offsets now happen to cover.
2434            Some(_) => (start + out.len(), Some(start)),
2435            None => (remap(self.caret), None),
2436        };
2437
2438        // A rolled-back splice leaves the old source in place, where every offset
2439        // computed above addresses text that was never written.
2440        if !self.splice(start, end, &out, EditKind::Other) {
2441            return;
2442        }
2443        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2444        // rewrite is the last line's end — nowhere the caret was. Place it, then
2445        // re-record the caret so this is the state redo restores, not the one
2446        // `splice` left behind from the `Change`.
2447        self.caret = placed.0.min(self.source.len());
2448        self.anchor = placed.1;
2449        self.clamp_caret();
2450        self.record_caret();
2451    }
2452
2453    /// The Enter key.
2454    ///
2455    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2456    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2457    /// caret is in decides what actually gets written.
2458    ///
2459    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2460    ///                            block at the caret and reopens its container
2461    ///   - list item            → likewise: the next item, its indent, quote
2462    ///                            prefix and `[ ]` box all reproduced by twig —
2463    ///                            except an *empty* item, which exits the list
2464    ///   - block quote          → likewise: a new paragraph inside the quote
2465    ///   - heading              → a new *paragraph*, not another heading
2466    ///   - code block           → a literal newline (stay in the block)
2467    ///   - blank line           → a literal newline (one Backspace undoes it)
2468    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2469    ///                            visible line
2470    ///
2471    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2472    /// and it is better at it: it drops the whitespace the caret was sitting in
2473    /// front of instead of stranding it at the head of the second half, and it
2474    /// knows continuations leaf's marker scan never covered — a checklist item
2475    /// continues as an *unchecked* checklist item rather than a plain bullet.
2476    ///
2477    /// The exceptions above are exceptions because `split_block` is either wrong
2478    /// there or refuses: parting a fence yields two fences with the code split
2479    /// between them, parting a heading yields a second heading where every editor
2480    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2481    /// table all report an error rather than a split.
2482    pub fn newline(&mut self) {
2483        if self.view == View::Source {
2484            self.insert_raw("\n");
2485            return;
2486        }
2487        // Enter over a selection replaces it with a paragraph break.
2488        if let Some((s, e)) = self.selection() {
2489            self.splice(s, e, "\n\n", EditKind::Other);
2490            return;
2491        }
2492        // A caret resting exactly between an inline mark's content and its own
2493        // closing delimiter (`**bold**` with nothing after it on the line —
2494        // the WYSIWYG caret's natural end-of-line position) must not splice a
2495        // block break there: every path below eventually does via
2496        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2497        // delimiter would strand it alone on the new line.
2498        self.caret = self.skip_trailing_close_delims(self.caret);
2499        // The block the caret is in. `block_offset_for_caret` nudges off a line
2500        // end (where the caret sits at the doc level); on a bare line (e.g. an
2501        // empty list item) fall back to the caret so the enclosing list/quote is
2502        // still visible in the ancestors.
2503        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2504        let kinds: Vec<Kind> = self
2505            .editor
2506            .ancestors_at(off)
2507            .map(|c| c.into_iter().map(|m| m.kind).collect())
2508            .unwrap_or_default();
2509        let has = |k: Kind| kinds.contains(&k);
2510
2511        if has(Kind::CodeBlock) {
2512            self.insert_raw("\n");
2513            return;
2514        }
2515        // An *empty* list item exits the list — the standard double-Enter — which
2516        // `split_block` reports as an error rather than a split (there is no
2517        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2518        // the AST gate — it answers from the tree, so a `- ` that reads as a
2519        // marker byte-for-byte but opens no item (a setext underline, a Djot
2520        // continuation line) never reaches here.
2521        if let Some(marker) = self.list_marker_on_line(self.caret)
2522            && self.item_is_empty(&marker)
2523        {
2524            self.exit_list(&marker);
2525            return;
2526        }
2527        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2528        // not another full paragraph break — so it moves down one line and one
2529        // Backspace undoes it, not two. (`split_block` errors here too.)
2530        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2531        let line_end = self.source[self.caret..]
2532            .find('\n')
2533            .map_or(self.source.len(), |i| self.caret + i);
2534        if self.source[line_start..line_end].trim().is_empty() {
2535            self.insert_raw("\n");
2536            return;
2537        }
2538        // In `Preserve` flow a soft break is a *visible* line the author means to
2539        // make, so Enter writes a single `\n` and typing continues the same
2540        // paragraph on the next line — the behaviour of an ordinary text editor.
2541        // A second Enter then lands on the blank line above and takes the
2542        // empty-line branch, so double-Enter still promotes to a full paragraph
2543        // break; and Backspace, which deletes a lone `\n` over a soft break,
2544        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2545        // render as an invisible space, so Enter keeps making the paragraph break
2546        // that actually shows.
2547        //
2548        // Only in running prose. A list or a quote has a continuation of its own
2549        // to write, and a `\n` there is not a soft line but a lost container.
2550        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2551        if self.line_flow == LineFlow::Preserve && !in_container {
2552            self.insert_raw("\n");
2553            return;
2554        }
2555        // A heading gets a *paragraph*, never a second heading: Enter at the end
2556        // of a title is how every editor is asked for the body under it, and
2557        // `split_block` would repeat the `#` instead. Whitespace at the split
2558        // point goes with the break rather than opening the new paragraph, which
2559        // is what `split_block` does everywhere else.
2560        if has(Kind::Heading) {
2561            let mut end = self.caret;
2562            while self.source.as_bytes().get(end) == Some(&b' ') {
2563                end += 1;
2564            }
2565            self.splice(self.caret, end, "\n\n", EditKind::Other);
2566            return;
2567        }
2568        self.split_block_here();
2569    }
2570
2571    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2572    /// the caret in the second half.
2573    ///
2574    /// twig reopens whatever the first half was inside of — the bullet with its
2575    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2576    /// reason this replaced the markup leaf used to spell from the line's bytes.
2577    /// It renumbers nothing, though: a new item mid-list is written with its
2578    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2579    /// behind it, folded into the same undo step.
2580    ///
2581    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2582    /// shape still moves the caret down rather than swallowing the keystroke.
2583    fn split_block_here(&mut self) {
2584        // The read-only gate — this door reaches twig without the splice.
2585        if self.read_only {
2586            return;
2587        }
2588        match self.editor.split_block(self.caret) {
2589            Ok(change) => {
2590                self.last_edit_kind = None;
2591                self.refresh();
2592                self.anchor = None;
2593                self.caret = change.new.end;
2594                self.dirty = self.source != self.clean_source;
2595                self.status = None;
2596                self.clamp_caret();
2597                self.record_caret();
2598                // Aimed at the new block's *start*: the caret twig leaves is one
2599                // past the marker it wrote, where there is no list in reach.
2600                self.renumber_at(change.new.start);
2601            }
2602            Err(_) => self.insert_raw("\n\n"),
2603        }
2604    }
2605
2606    /// Whether the item on the marker's line carries no content — the shape
2607    /// double-Enter reads as "I'm done with this list."
2608    fn item_is_empty(&self, line: &ListMarker) -> bool {
2609        let content_start = line.content_start().min(self.source.len());
2610        let line_end = self.source[self.caret..]
2611            .find('\n')
2612            .map(|i| self.caret + i)
2613            .unwrap_or(self.source.len());
2614        self.source[content_start..line_end.max(content_start)]
2615            .trim()
2616            .is_empty()
2617    }
2618
2619    /// Leave the list: replace the empty item's marker with a blank line, so the
2620    /// caret lands in a fresh paragraph below it.
2621    ///
2622    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2623    /// quote), and the caret's new line keeps the `> ` it was already behind —
2624    /// leaving the list without also leaving the quote.
2625    fn exit_list(&mut self, line: &ListMarker) {
2626        let prefix = self.quote_prefix_at(line.marker_start);
2627        let blank = prefix.trim_end();
2628        self.splice(
2629            line.line_start,
2630            self.caret,
2631            &format!("{blank}\n{prefix}"),
2632            EditKind::Other,
2633        );
2634    }
2635
2636    /// What a line continuing the containers at `off` has to open with — the
2637    /// quote markers reproduced, each enclosing item's marker as its width in
2638    /// spaces. Also the column a nested item's marker stands in, which is what
2639    /// makes it Tab's answer.
2640    fn continuation_prefix_at(&mut self, off: usize) -> String {
2641        self.editor
2642            .document()
2643            .and_then(|mut d| d.continuation_prefix(off))
2644            .map(|p| p.text)
2645            .unwrap_or_default()
2646    }
2647
2648    /// The column a *nested list* may open at inside the item at `off` — which
2649    /// is not always where the item's own text continues.
2650    ///
2651    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2652    /// it is markup a rich view hides, and the item's own wrapped text does
2653    /// stand past it. But a nested list may only open at the *list* marker's
2654    /// column, and four columns further in is an indented continuation of the
2655    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2656    /// So the box's own width goes back.
2657    ///
2658    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2659    /// reports the list marker's column apart from the box; `checked` is what
2660    /// says a box is there at all, so only its width is being measured here.
2661    fn nesting_prefix_at(&mut self, off: usize) -> String {
2662        let cont = self.continuation_prefix_at(off);
2663        let Some(item) = self.innermost_list_item(off) else {
2664            return cont;
2665        };
2666        if item.checked.is_none() {
2667            return cont;
2668        }
2669        let box_width = item
2670            .marker_span
2671            .and_then(|m| self.source.get(m))
2672            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2673            .unwrap_or(0);
2674        // The trailing columns are the ones the item's own marker contributed,
2675        // so trimming from the end leaves any quote prefix standing.
2676        cont[..cont.len().saturating_sub(box_width)].to_string()
2677    }
2678
2679    /// Where the line of the item *containing* the item at `off` begins — the
2680    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2681    /// parent contributed. The quote prefix alone for a top-level item, which
2682    /// has no level left to give.
2683    fn outdent_prefix_at(&mut self, off: usize) -> String {
2684        let items: Vec<usize> = self
2685            .editor
2686            .document()
2687            .and_then(|mut d| d.ancestors_at_caret(off))
2688            .map(|c| {
2689                c.into_iter()
2690                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2691                    .map(|m| m.span.start)
2692                    .collect()
2693            })
2694            .unwrap_or_default();
2695        // The second-innermost item is the parent; its own line's indent is the
2696        // target. `list_marker_on_line` gives that line's prefix directly.
2697        let parent = items.len().checked_sub(2).map(|i| items[i]);
2698        match parent.and_then(|p| self.list_marker_on_line(p)) {
2699            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2700            None => self.quote_prefix_at(off),
2701        }
2702    }
2703
2704    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2705    /// inside one, `"> > "` inside two.
2706    ///
2707    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2708    /// the `>` and the space after it are twig's spelling rather than leaf's.
2709    /// The whole line prefix can't answer this: it also carries the indent of
2710    /// whatever the quote holds, which a blank separator line must *not* repeat.
2711    fn quote_prefix_at(&mut self, off: usize) -> String {
2712        let Ok(chain) = self
2713            .editor
2714            .document()
2715            .and_then(|mut d| d.ancestors_at_caret(off))
2716        else {
2717            return String::new();
2718        };
2719        let quotes: Vec<usize> = chain
2720            .iter()
2721            .filter(|m| m.kind == Kind::BlockQuote)
2722            .map(|m| m.node_id as usize)
2723            .collect();
2724        let Ok(nodes) = self.editor.nodes() else {
2725            return String::new();
2726        };
2727        quotes
2728            .iter()
2729            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2730            .filter_map(|s| self.source.get(s))
2731            .collect()
2732    }
2733
2734    /// Whether the item at `off` sits inside another one — the test Backspace
2735    /// uses to choose between outdenting and dropping the marker.
2736    ///
2737    /// Counted from the AST rather than from the line's leading whitespace,
2738    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2739    fn item_is_nested(&mut self, off: usize) -> bool {
2740        self.editor
2741            .document()
2742            .and_then(|mut d| d.ancestors_at_caret(off))
2743            .map(|c| {
2744                c.into_iter()
2745                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2746                    .count()
2747                    > 1
2748            })
2749            .unwrap_or(false)
2750    }
2751
2752    /// The innermost list item containing `probe`, under twig's **caret**
2753    /// containment rule — a block's end is inside it.
2754    ///
2755    /// Half-open containment can't answer this. An empty item's span is exactly
2756    /// its marker, so the caret sitting after `- ` is one past the end and the
2757    /// item it is plainly in tests as out of reach; that is the shape
2758    /// double-Enter has to recognise to leave the list.
2759    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2760        let chain = self
2761            .editor
2762            .document()
2763            .and_then(|mut d| d.ancestors_at_caret(probe))
2764            .ok()?;
2765        let id = chain
2766            .iter()
2767            .rev()
2768            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2769            .node_id as usize;
2770        self.editor.nodes().ok()?.get(id).cloned()
2771    }
2772
2773    /// The list marker opening `off`'s line, per twig — `None` when that line
2774    /// opens no list item.
2775    ///
2776    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2777    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2778    /// and it is `None` on a *continuation* line, which opens nothing. That last
2779    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2780    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2781    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2782    /// parser knows which document it is looking at.
2783    ///
2784    /// The item's own marker is separated out via its
2785    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2786    /// the containers around it contribute and what the item does.
2787    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2788        let off = off.min(self.source.len());
2789        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2790        // The prefix belongs to a list only when an item's marker closes it —
2791        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2792        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2793        let marker = item.marker_span.clone()?;
2794        if marker.end != prefix.end {
2795            return None;
2796        }
2797        Some(ListMarker {
2798            line_start: prefix.start,
2799            marker_start: marker.start,
2800            text: self.source.get(prefix)?.to_string(),
2801        })
2802    }
2803
2804    /// Whether the list item on `line_start`'s line is the **first item** of its
2805    /// list — the one Tab must not nest, because nesting needs a preceding
2806    /// sibling to become the new parent and a first item has none. `false` for a
2807    /// line that isn't a list item, and for an item with a sibling above it (the
2808    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2809    /// the same in a setext underline that opens no list at all.
2810    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2811        let Some(marker) = self.list_marker_on_line(line_start) else {
2812            return false;
2813        };
2814        // Probe just inside the marker, where the item's own node is in reach —
2815        // the marker offset itself can resolve to the enclosing list, not the
2816        // `list_item`, whose span starts at the marker.
2817        let probe = marker.content_start().min(self.source.len());
2818        let Some(item) = self.innermost_list_item(probe) else {
2819            return false;
2820        };
2821        let Ok(nodes) = self.editor.nodes() else {
2822            return false;
2823        };
2824        match item.parent {
2825            // First when the parent list opens with this very item.
2826            Some(pid) => nodes
2827                .get(pid.0 as usize)
2828                .is_some_and(|p| p.first_child == Some(item.id)),
2829            // A parentless item is trivially the first (and only) one.
2830            None => true,
2831        }
2832    }
2833
2834    pub fn backspace(&mut self) {
2835        if let Some((s, e)) = self.selection() {
2836            self.splice(s, e, "", EditKind::Other);
2837            return;
2838        }
2839        // WYSIWYG: Backspace at the very start of a list item's content is a
2840        // structural key, not a character delete — it walks the "un-indent, then
2841        // un-list" ladder every list editor gives that keystroke (outdent a
2842        // nested item, strip a top-level one's marker to a paragraph). In source
2843        // view the `- ` is visible text the user is deleting a byte of, so it
2844        // keeps its literal meaning there, like Enter does.
2845        if self.view != View::Source && self.backspace_list_start() {
2846            return;
2847        }
2848        // WYSIWYG: and the same at the start of a heading's content — the `# `
2849        // there is markup the rich view hides, not text the user typed.
2850        if self.view != View::Source && self.backspace_heading_start() {
2851            return;
2852        }
2853        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2854        // the markup apart under a caret that cannot see it — see
2855        // `delete_around_block_media`.
2856        if self.view != View::Source && self.delete_around_block_media(false) {
2857            return;
2858        }
2859        // WYSIWYG: Backspace at a table's trailing stop steps back into its last
2860        // cell rather than taking the byte behind the caret — the row's closing
2861        // `|`, which the rich view never drew, so the key would have looked like
2862        // it did nothing. The stop before is the last cell's end.
2863        if self.view != View::Source && self.backspace_at_table_end() {
2864            return;
2865        }
2866        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2867        // stop, not a single newline. On a line with no text of its own, the byte
2868        // before the caret is a `\n` that spells part of a block boundary — the gap
2869        // between two blocks, drawn but never a caret home. Removing just it strands
2870        // the caret in that gap and leaves an odd blank line the eye reads as one
2871        // separator but the caret can't land on: the "extra newline" left behind
2872        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2873        // Deleting to the previous stop instead collapses the whole break at once,
2874        // landing the caret at the end of the block above. Two blank lines in a row
2875        // are one stop apart, so this still removes exactly one — the lone-Enter /
2876        // lone-Backspace symmetry the empty-line case is built on is untouched.
2877        if self.view != View::Source
2878            && self.caret > self.caret_floor()
2879            && self.caret_on_blank_line()
2880            && let Some(stop) = self.vmap.stop_before(self.caret)
2881        {
2882            let stop = stop.max(self.caret_floor());
2883            if stop < self.caret {
2884                self.splice(stop, self.caret, "", EditKind::Delete);
2885                return;
2886            }
2887        }
2888        if self.caret > self.caret_floor() {
2889            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
2890            // takes the whole tag — a single-byte step would leave a broken `<br`
2891            // showing in the cell. Rich view only (source view edits the literal).
2892            if self.view != View::Source
2893                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
2894            {
2895                let start = start.max(self.caret_floor());
2896                if start < end {
2897                    self.splice(start, end, "", EditKind::Delete);
2898                    return;
2899                }
2900            }
2901            // Aim the delete at the character the writer can *see* behind the
2902            // caret, never at a delimiter the rich view drew nothing for. Two
2903            // steps, and either can apply: from the far side of a run's closing
2904            // `**` step back into the run (the caret is drawn at the end of its
2905            // word), and at the start of a run's text step out past its opening
2906            // `**` to the character in front of it, leaving the run standing.
2907            // Without them a plain Backspace unspells the phrase it is editing
2908            // and leaves a literal asterisk on screen.
2909            let end = if self.view == View::Source {
2910                self.caret
2911            } else {
2912                let inside = self.step_inside_close_delims(self.caret);
2913                self.skip_leading_open_delims(inside)
2914                    .max(self.caret_floor())
2915            };
2916            // Never delete back across the floor — that would eat hidden
2917            // frontmatter the WYSIWYG caret can't even see.
2918            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
2919            // Take a hidden escape backslash with the char it escapes: the rich
2920            // view draws `\*` as a single `*`, so Backspace over it must delete
2921            // both bytes, never strand the `\` as a lone visible backslash (the
2922            // mirror of the Hidden-mode typing that wrote the escape). Source view
2923            // shows the `\`, so there it is an ordinary character.
2924            if self.view != View::Source
2925                && prev > self.caret_floor()
2926                && self.is_hidden_escape(prev - 1)
2927            {
2928                prev -= 1;
2929            }
2930            if prev < end {
2931                self.splice(prev, end, "", EditKind::Delete);
2932            }
2933        }
2934    }
2935
2936    /// Backspace at a table's trailing stop: move onto the stop before it (the
2937    /// last cell's end) and consume the key. `false` anywhere else. See
2938    /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
2939    /// not one to delete.
2940    fn backspace_at_table_end(&mut self) -> bool {
2941        // The map answers about offsets, so it has to be this revision's — see
2942        // `open_paragraph_at_block_edge`.
2943        self.rebuild_map();
2944        if !self.vmap.table_end_stop(self.caret) {
2945            return false;
2946        }
2947        if let Some(off) = self
2948            .vmap
2949            .stop_before(self.caret)
2950            .filter(|&o| o >= self.caret_floor())
2951        {
2952            self.caret = off;
2953            self.anchor = None;
2954            self.goal_col = None;
2955        }
2956        true
2957    }
2958
2959    /// Whether the caret's own source line holds nothing but whitespace — an
2960    /// empty paragraph, or the blank line a block boundary is spelled with. The
2961    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
2962    /// no text of its own, so the newline before the caret belongs to the gap
2963    /// between blocks rather than to any word the caret is editing.
2964    fn caret_on_blank_line(&self) -> bool {
2965        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2966        let line_end = self.source[self.caret..]
2967            .find('\n')
2968            .map_or(self.source.len(), |i| self.caret + i);
2969        self.source[line_start..line_end].trim().is_empty()
2970    }
2971
2972    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
2973    /// `edge` side — the byte range to delete whole. A table row is one source
2974    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
2975    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
2976    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
2977    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
2978    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
2979    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
2980    /// apart — no ancestor walk needed. Rich view only; source view shows the
2981    /// literal tag and deletes it a byte at a time.
2982    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
2983        let caret = self.caret;
2984        let nodes = self.nodes();
2985        let src = self.source.as_bytes();
2986        nodes
2987            .iter()
2988            .find(|n| {
2989                n.kind == Kind::HardBreak
2990                    && n.span.start < n.span.end
2991                    && src.get(n.span.start) == Some(&b'<')
2992                    && match edge {
2993                        BreakEdge::Backward => n.span.end == caret,
2994                        BreakEdge::Forward => n.span.start == caret,
2995                    }
2996            })
2997            .map(|n| (n.span.start, n.span.end))
2998    }
2999
3000    /// Whether the source byte at `off` is a backslash twig consumed as an escape
3001    /// (hidden in the rich view), as against a literal backslash (drawn). A
3002    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3003    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3004    /// round-trip needed.
3005    fn is_hidden_escape(&self, off: usize) -> bool {
3006        let b = self.source.as_bytes();
3007        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3008    }
3009
3010    /// Backspace's list behaviour: when the caret sits exactly at the start of a
3011    /// list item's content (right after its marker), outdent the item if it's
3012    /// nested, else strip the marker so it becomes a paragraph. Returns whether
3013    /// it acted — `false` leaves Backspace its ordinary character delete.
3014    fn backspace_list_start(&mut self) -> bool {
3015        let Some(marker) = self.list_marker_on_line(self.caret) else {
3016            return false;
3017        };
3018        // Only right after the marker. That the line opens a real item is
3019        // already settled: `list_marker_on_line` answers from the tree.
3020        if self.caret != marker.content_start() {
3021            return false;
3022        }
3023        if self.item_is_nested(marker.marker_start) {
3024            // Nested: give back one level, keeping the marker and carrying the
3025            // caret with it.
3026            self.outdent();
3027        } else {
3028            // Top level: drop the marker, leaving a paragraph, then renumber the
3029            // siblings the removed item was counted among. Only the marker goes —
3030            // a quote prefix in front of it still has a quote to hold up.
3031            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3032            self.renumber_here();
3033        }
3034        true
3035    }
3036
3037    /// Backspace's heading behaviour: with the caret exactly at the start of an
3038    /// ATX heading's content — right after the `#` marker the rich view hides —
3039    /// strip the marker so the line becomes a paragraph. The peer of
3040    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3041    /// reasoning: hidden block markup is structure, so the keystroke over it is
3042    /// structural.
3043    ///
3044    /// Without this the ordinary delete takes the space out of `# Title` and
3045    /// leaves `#Title`, which is no longer a heading at all — the hash the view
3046    /// had been hiding surfaces as literal text the user has to delete a second
3047    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3048    /// other end) goes with the marker for the same reason.
3049    ///
3050    /// Returns whether it acted; `false` leaves Backspace its character delete.
3051    fn backspace_heading_start(&mut self) -> bool {
3052        let caret = self.caret;
3053        // The heading whose content opens exactly at the caret. A bare `#` has no
3054        // content span at all — its content starts (and ends) where the line does.
3055        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3056            let (start, end) = match &n.content_span {
3057                Some(c) => (c.start, c.end),
3058                None => (n.span.end, n.span.end),
3059            };
3060            (n.kind == Kind::Heading && start == caret)
3061                .then(|| (n.span.clone(), end, n.marker_span.clone()))
3062        }) else {
3063            return false;
3064        };
3065        // twig reports the marker's own extent, so there is nothing to walk back
3066        // over and no `#` in this file. A setext heading has no marker — its
3067        // content opens the line — so it falls through to the ordinary delete,
3068        // as does anything else sitting at a content start.
3069        // `m.end == caret` is what excludes a setext heading, whose marker is the
3070        // underline *after* the content rather than a prefix before it.
3071        let Some(marker) = marker.filter(|m| m.end == caret) else {
3072            return false;
3073        };
3074        let start = marker.start;
3075        // A closing `#` sequence is hidden too, so it can't be left behind. Only
3076        // when the tail really is one: trailing spaces alone are nothing to strip.
3077        let tail = &self.source[content_end..span.end];
3078        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
3079            let kept = self.source[caret..content_end].to_string();
3080            self.splice(start, span.end, &kept, EditKind::Other);
3081            // The splice leaves the caret past the text it re-wrote; the caret
3082            // belongs where the content now starts, which is where it already was.
3083            self.caret = start;
3084            self.record_caret();
3085        } else {
3086            self.splice(start, caret, "", EditKind::Other);
3087        }
3088        true
3089    }
3090
3091    pub fn delete_forward(&mut self) {
3092        if let Some((s, e)) = self.selection() {
3093            self.splice(s, e, "", EditKind::Other);
3094        } else if self.caret < self.source.len() {
3095            // The mirror of Backspace's: forward-delete in front of a picture
3096            // would eat the `!` off its markup and leave a link where a photo was.
3097            if self.view != View::Source && self.delete_around_block_media(true) {
3098                return;
3099            }
3100            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3101            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3102            // strands a broken `<br` in the cell.
3103            if self.view != View::Source
3104                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3105            {
3106                self.splice(start, end, "", EditKind::Delete);
3107                return;
3108            }
3109            // The mirror of Backspace's two steps: from in front of a run's
3110            // opening `**` step into it, onto the first letter of its text, and
3111            // at the end of a run's text step out past its closing `**` to the
3112            // character beyond. Either way Delete takes the character it looks
3113            // like it is pointing at, and never a delimiter drawn as nothing.
3114            // The caret then settles back inside the run it was standing in —
3115            // see `settle_inside_close_delims`.
3116            let from = if self.view == View::Source {
3117                self.caret
3118            } else {
3119                let inside = self.step_inside_open_delims(self.caret);
3120                self.skip_trailing_close_delims(inside)
3121            };
3122            let next = next_boundary(&self.source, from);
3123            if from < next {
3124                self.splice(from, next, "", EditKind::Delete);
3125            }
3126        }
3127    }
3128
3129    /// Delete from the caret back to the start of the previous word (⌥⌫ /
3130    /// Ctrl+⌫). Deletes the selection instead when one is active.
3131    pub fn delete_word_back(&mut self) {
3132        if let Some((s, e)) = self.selection() {
3133            self.splice(s, e, "", EditKind::Other);
3134        } else {
3135            // A word back from just past a picture is a word *of its markup*, and
3136            // a word back from in front of one runs through the paragraph break
3137            // into the prose above — dissolving the picture either way. See
3138            // `delete_around_block_media`.
3139            if self.view != View::Source && self.delete_around_block_media(false) {
3140                return;
3141            }
3142            let start = self.word_left_from(self.caret).max(self.caret_floor());
3143            if start < self.caret {
3144                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3145                self.splice(s, e, "", EditKind::Delete);
3146            }
3147        }
3148    }
3149
3150    /// Delete from the caret forward to the end of the next word (⌥⌦ /
3151    /// Ctrl+Del). Deletes the selection instead when one is active.
3152    pub fn delete_word_forward(&mut self) {
3153        if let Some((s, e)) = self.selection() {
3154            self.splice(s, e, "", EditKind::Other);
3155        } else {
3156            // The mirror: a word forward from in front of a picture is its markup.
3157            if self.view != View::Source && self.delete_around_block_media(true) {
3158                return;
3159            }
3160            let end = self.word_right_from(self.caret);
3161            if end > self.caret {
3162                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3163                self.splice(s, e, "", EditKind::Delete);
3164            }
3165        }
3166    }
3167
3168    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3169    /// selection instead when one is active, as every other delete here does.
3170    ///
3171    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3172    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3173    /// stops at the first character and this takes the indentation with it, the
3174    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3175    /// leave an indent behind that nothing can then ask to delete, where a caret
3176    /// left at column 0 is one press of Home away from either.
3177    pub fn delete_to_line_start(&mut self) {
3178        if let Some((s, e)) = self.selection() {
3179            self.splice(s, e, "", EditKind::Other);
3180            return;
3181        }
3182        // Never back across the floor: hidden frontmatter isn't on this line, or
3183        // on any line the WYSIWYG caret can see.
3184        let (start, _) = self.line_span();
3185        let start = start.max(self.caret_floor());
3186        if start < self.caret {
3187            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3188            self.splice(s, e, "", EditKind::Delete);
3189        }
3190    }
3191
3192    /// Kill from the caret to the end of its line (^K). Deletes the selection
3193    /// instead when one is active.
3194    ///
3195    /// At the end of the line it does nothing, rather than pulling the line
3196    /// below up into this one. Joining has no meaning to give it in both views
3197    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3198    /// there is nothing there to delete, while the newline a *source* line ends
3199    /// with is only half of the blank line that separates two paragraphs —
3200    /// deleting one leaves a soft break, which is not the join it looks like.
3201    /// The views agreeing is worth more than emacs' second press, and Delete is
3202    /// already the key that joins.
3203    pub fn delete_to_line_end(&mut self) {
3204        if let Some((s, e)) = self.selection() {
3205            self.splice(s, e, "", EditKind::Other);
3206            return;
3207        }
3208        let (_, end) = self.line_span();
3209        if end > self.caret {
3210            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3211            self.splice(s, e, "", EditKind::Delete);
3212        }
3213    }
3214
3215    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3216    ///
3217    /// A glyph-space range covers what the user can see, which for `**bold**` is
3218    /// the word and never the delimiters around it — so deleting the word on its
3219    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3220    /// word, and the styling was the word's; the two go together. Only the
3221    /// node's delimiters are taken, and those are hidden here anyway, so nothing
3222    /// visible outside the range is lost.
3223    ///
3224    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3225    /// the strong, and only then is the strong empty too.
3226    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3227        if self.view == View::Source {
3228            return (start, end);
3229        }
3230        let nodes = self.nodes();
3231        let (mut s, mut e) = (start, end);
3232        loop {
3233            let mut grew = false;
3234            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3235                let Some(text) = inline_content_span(n, &self.source) else {
3236                    continue;
3237                };
3238                // Some of its text survives, so the node still has a job.
3239                if text.start < s || text.end > e {
3240                    continue;
3241                }
3242                if n.span.start < s || n.span.end > e {
3243                    s = s.min(n.span.start);
3244                    e = e.max(n.span.end);
3245                    grew = true;
3246                }
3247            }
3248            if !grew {
3249                return (s, e);
3250            }
3251        }
3252    }
3253
3254    /// One splice of document text, keeping the **mark-edge rule**: an inline
3255    /// mark's content never begins or ends with whitespace. In Markdown and Djot
3256    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3257    /// is four literal asterisks around a word, and a rich view drawing the
3258    /// document faithfully has no choice but to show them. That is correct
3259    /// rendering of what the file says, and nobody typing a space after a bold
3260    /// word meant to say it.
3261    ///
3262    /// So the space goes *outside* the run instead — `**bold** ` — which is the
3263    /// same document to a reader and a live one to a parser. The caret follows it
3264    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3265    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3266    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3267    ///
3268    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3269    /// through here, so the rule holds however the whitespace arrives at the
3270    /// edge. The repair is decided *after* the plain edit, by asking whether the
3271    /// mark actually died: a code span's backticks aren't whitespace-sensitive
3272    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3273    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3274        let fix = self.mark_edge_fix(start, end, text);
3275        if !self.splice_exact(start, end, text, kind) {
3276            return false;
3277        }
3278        if let Some(fix) = fix {
3279            self.repair_mark_edges(fix);
3280        }
3281        if text.is_empty() && end > start {
3282            self.settle_inside_close_delims();
3283        }
3284        true
3285    }
3286
3287    /// After a delete, take a caret left standing past a run's closing delimiters
3288    /// back inside the run.
3289    ///
3290    /// A delete leaves the caret where the deleted bytes began, and when those
3291    /// bytes were the last thing after a marked phrase — the space the mark-edge
3292    /// rule pushed out of `**bold** `, say — that spot is the far side of the
3293    /// closing `**`. The rich view has nothing to draw there: the delimiters are
3294    /// hidden, so the caret shows at the end of the word either way, and the two
3295    /// offsets are one place on screen with two different meanings. Typing at the
3296    /// outer one lands past the run, so the writer who backspaced a space out of
3297    /// their bold phrase watches the next character come out plain, and the
3298    /// toolbar button go dark, with the caret never appearing to move.
3299    ///
3300    /// The end of the run's text is the caret's home there — a delete that took
3301    /// away everything after a phrase leaves the caret at the end of that phrase,
3302    /// which is inside it — so it settles onto that
3303    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3304    /// walk, through every mark closing at the point): the word stays bold, the
3305    /// button stays lit, and the next character carries on the phrase.
3306    ///
3307    /// Rich view only, and only where a mark really closes at the caret — mid-run
3308    /// or in plain prose no span ends there and the caret stays put. The opening
3309    /// edge is left alone on purpose: a caret in front of a run inherits from the
3310    /// text on its left, which is the plain text outside.
3311    fn settle_inside_close_delims(&mut self) {
3312        if self.view != View::Wysiwyg {
3313            return;
3314        }
3315        let at = self.step_inside_close_delims(self.caret);
3316        if at != self.caret {
3317            self.caret = at;
3318            self.clear_pending();
3319            self.record_caret();
3320        }
3321    }
3322
3323    /// The splice exactly as asked, with no mark-edge repair — for the callers
3324    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3325    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3326    /// the bytes they inserted.
3327    ///
3328    /// One `edit_range` through twig, then re-anchor the caret from the returned
3329    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3330    /// Markdown/Djot) leaves the document untouched and reports.
3331    ///
3332    /// Returns whether the edit landed — for a caller that has offsets of its
3333    /// own to place afterwards, which a rolled-back splice would leave pointing
3334    /// into text that never came to exist.
3335    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3336        // The read-only gate, for every edit at once — see the field.
3337        if self.read_only {
3338            return false;
3339        }
3340        // twig records an undo step for every edit; when this one continues a
3341        // run of the same kind (typing, deleting), tell twig to fold it into the
3342        // step before it so the whole run undoes at once.
3343        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3344        // Hand twig the pre-edit caret before the splice, so the undo step it
3345        // retires carries where the caret was standing.
3346        self.record_caret();
3347        match self.editor.edit_range(start, end, text) {
3348            Ok(change) => {
3349                if coalesce {
3350                    let _ = self.editor.coalesce_last_undo();
3351                }
3352                self.last_edit_kind = Some(kind);
3353                self.refresh();
3354                self.caret = change.new.end;
3355                self.anchor = None;
3356                self.goal_col = None;
3357                self.clear_pending();
3358                self.dirty = self.source != self.clean_source;
3359                self.status = None;
3360                // And the post-edit caret, so a later redo restores it.
3361                self.record_caret();
3362                true
3363            }
3364            // The edit was rolled back, so twig's history did not move and
3365            // neither may ours: pushing here would leave a step with no edit
3366            // under it and shift every later undo onto the wrong caret.
3367            Err(e) => {
3368                self.status = Some(format!("edit: {e}"));
3369                false
3370            }
3371        }
3372    }
3373
3374    /// The re-spelling that would keep the mark-edge rule for the edit
3375    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3376    /// against a delimiter and the plain splice is already right. Computed
3377    /// *before* the edit, while the run's spans and delimiters can still be read
3378    /// off the document; applied afterwards, and only if the mark really died —
3379    /// see [`repair_mark_edges`](Self::repair_mark_edges).
3380    ///
3381    /// Rich view only. Source view is for typing raw markup, where a space put
3382    /// against a `**` is exactly the character it looks like.
3383    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3384        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3385            return None;
3386        }
3387        // Every inline mark standing over the edit, outermost first, with the
3388        // content span that says where its delimiters are.
3389        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3390            .editor
3391            .ancestors_at(start)
3392            .unwrap_or_default()
3393            .into_iter()
3394            .filter_map(|m| {
3395                let kind = inline_kind(&m.kind)?;
3396                let content = m.content_span.clone()?;
3397                Some((kind, m.span.clone(), content))
3398            })
3399            .collect();
3400        // The innermost run whose *content* holds the whole edit: the one whose
3401        // text is being changed, rather than one the edit merely sits under.
3402        let (kind, span, content) = chain
3403            .iter()
3404            .rev()
3405            .find(|(_, _, c)| c.start <= start && end <= c.end)?
3406            .clone();
3407        // What that content becomes. Whitespace at either end of it is what
3408        // would put out the mark.
3409        let body = format!(
3410            "{}{text}{}",
3411            &self.source[content.start..start],
3412            &self.source[end..content.end]
3413        );
3414        let (lead, trail) = if body.trim().is_empty() {
3415            // Nothing but whitespace left: there is no content to mark at all,
3416            // and the delimiters go with it rather than closing on a space.
3417            (body.len(), 0)
3418        } else {
3419            (
3420                body.len() - body.trim_start().len(),
3421                body.len() - body.trim_end().len(),
3422            )
3423        };
3424        // Nothing against a delimiter, and something still between them: the
3425        // plain edit stands. An emptied run is broken just as surely (`**b**`
3426        // with the `b` deleted is the literal `****`) and is re-spelt as the
3427        // nothing it now says.
3428        if lead == 0 && trail == 0 && !body.is_empty() {
3429            return None;
3430        }
3431        // Marks that open or close exactly where this one does — `***both***` is
3432        // two runs sharing an edge — spell their delimiters as one run of bytes,
3433        // so the whitespace has to clear all of them together.
3434        let (mut open_at, mut close_at) = (span.start, span.end);
3435        for _ in 0..chain.len() {
3436            match chain.iter().find(|(_, _, c)| c.start == open_at) {
3437                Some((_, s, _)) => open_at = s.start,
3438                None => break,
3439            }
3440        }
3441        for _ in 0..chain.len() {
3442            match chain.iter().find(|(_, _, c)| c.end == close_at) {
3443                Some((_, s, _)) => close_at = s.end,
3444                None => break,
3445            }
3446        }
3447        let open = &self.source[open_at..content.start];
3448        let close = &self.source[content.end..close_at];
3449        let core = &body[lead..body.len() - trail];
3450        let respelt = if core.is_empty() {
3451            body.clone()
3452        } else {
3453            format!(
3454                "{}{open}{core}{close}{}",
3455                &body[..lead],
3456                &body[body.len() - trail..]
3457            )
3458        };
3459        // The caret sits just past the inserted text within the new content —
3460        // which, when that lands in the whitespace, is now outside the delimiters.
3461        let pos = (start - content.start) + text.len();
3462        let caret = if core.is_empty() || pos <= lead {
3463            open_at + pos
3464        } else if pos >= lead + core.len() {
3465            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3466        } else {
3467            open_at + lead + open.len() + (pos - lead)
3468        };
3469        Some(MarkEdgeFix {
3470            kind,
3471            probe: content.start,
3472            start: open_at,
3473            end: close_at + text.len() - (end - start),
3474            text: respelt,
3475            caret,
3476            // The marks in force here, resolved against any armed sticky delta —
3477            // what the writer is typing in, and so what has to still be true on
3478            // the far side of the delimiter the caret just stepped over.
3479            want: chain
3480                .iter()
3481                .filter(|(_, s, _)| start < s.end)
3482                .map(|(k, _, _)| *k)
3483                .collect::<InlineMarks>()
3484                .xor(self.pending_here()),
3485        })
3486    }
3487
3488    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3489    /// did break the mark. Whether whitespace at a delimiter is fatal is the
3490    /// format's business, not leaf's: `**bold **` is no longer strong, while
3491    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3492    /// don't care either. Asking the parser afterwards settles it for every kind
3493    /// and format at once, and costs a re-spelling only where one is due.
3494    ///
3495    /// The repair rides along with the edit that caused it — one undo step puts
3496    /// back what the writer typed, not a delimiter shuffle they never saw.
3497    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3498        if fix.end > self.source.len() {
3499            return;
3500        }
3501        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3502            return; // still a mark: these delimiters don't mind the whitespace
3503        }
3504        let resumed = self.last_edit_kind;
3505        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3506            return;
3507        }
3508        let _ = self.editor.coalesce_last_undo();
3509        // The keystroke owns the undo step, so the run of typing it belongs to
3510        // keeps coalescing over the repair rather than breaking in two here.
3511        self.last_edit_kind = resumed;
3512        self.caret = fix.caret.min(self.source.len());
3513        self.anchor = None;
3514        self.goal_col = None;
3515        self.rearm(fix.want);
3516        self.clamp_caret();
3517        self.record_caret();
3518    }
3519
3520    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3521    /// writer is typing in, carried across an edit that moved the caret out of
3522    /// the run holding them. Arms nothing when the caret already stands in
3523    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3524    /// a clean delta here (see [`toggle`](Self::toggle)).
3525    fn rearm(&mut self, want: InlineMarks) {
3526        let here: InlineMarks = self
3527            .marks_at(self.caret)
3528            .into_iter()
3529            .map(|(k, _)| k)
3530            .collect();
3531        self.pending_marks = want.xor(here);
3532        self.pending_at = Some(self.caret);
3533    }
3534
3535    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3536    /// which backslash-escapes any character that would otherwise open markup in
3537    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3538    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3539    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3540    /// a collapsed point — a selection is deleted by the caller first, since
3541    /// `insert_literal` inserts rather than replaces.
3542    fn insert_literal_at(
3543        &mut self,
3544        at: usize,
3545        text: &str,
3546        kind: EditKind,
3547        force_coalesce: bool,
3548    ) -> bool {
3549        // The read-only gate: this door goes to twig directly, not through
3550        // `splice_exact`, so it guards itself — see the field.
3551        if self.read_only {
3552            return false;
3553        }
3554        // `force_coalesce` folds this into the immediately preceding edit (the
3555        // selection-delete of an overwrite) so the pair is one undo step; else it
3556        // coalesces only when it continues a run of the same-kind typing.
3557        let coalesce =
3558            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
3559        // The mark-edge rule holds for typed text however it is spelled — see
3560        // `splice`. Only an insert twig passed through unchanged can use it,
3561        // since a fix is measured in the bytes that actually land, and an escape
3562        // adds bytes this couldn't have counted.
3563        let fix = self.mark_edge_fix(at, at, text);
3564        self.record_caret();
3565        match self.editor.insert_literal(at, text) {
3566            Ok(change) => {
3567                if coalesce {
3568                    let _ = self.editor.coalesce_last_undo();
3569                }
3570                self.last_edit_kind = Some(kind);
3571                self.refresh();
3572                self.caret = change.new.end;
3573                self.anchor = None;
3574                self.goal_col = None;
3575                self.clear_pending();
3576                self.dirty = self.source != self.clean_source;
3577                self.status = None;
3578                self.record_caret();
3579                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
3580                    self.repair_mark_edges(fix);
3581                }
3582                true
3583            }
3584            Err(e) => {
3585                self.status = Some(format!("edit: {e}"));
3586                false
3587            }
3588        }
3589    }
3590
3591    /// After a structural list edit (a new item, a nest/unnest), renumber the
3592    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
3593    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
3594    /// renumber as its own edit; fold it into the edit that triggered it so the
3595    /// two undo as one, and only when it actually changed the source (a no-op or
3596    /// a caret outside any ordered list must not coalesce the real edit into the
3597    /// step before it).
3598    fn renumber_here(&mut self) {
3599        self.renumber_at(self.caret);
3600    }
3601
3602    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
3603    /// caret — for an edit that leaves the caret one past the item it just wrote,
3604    /// where twig resolves no list to renumber.
3605    fn renumber_at(&mut self, off: usize) {
3606        // The read-only gate — this door reaches twig without the splice.
3607        if self.read_only {
3608            return;
3609        }
3610        let before = self.source.clone();
3611        if self.editor.renumber_ordered_lists(off).is_err() {
3612            return; // not inside an ordered list — nothing to renumber
3613        }
3614        self.refresh();
3615        if self.source != before {
3616            let _ = self.editor.coalesce_last_undo();
3617            self.dirty = self.source != self.clean_source;
3618            self.clamp_caret();
3619            self.record_caret();
3620        }
3621    }
3622
3623    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
3624    /// sub-item written directly beneath a text line reparses that text as a
3625    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
3626    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
3627    /// bullets can't underline anything, so swap the dash for a `*`: the item
3628    /// stays an empty nested bullet, the parent stays prose, and the source
3629    /// round-trips instead of hiding a heading the user never asked for. Folded
3630    /// into the triggering edit's undo step, the way renumbering is.
3631    ///
3632    /// Gated on the collapse having actually happened (the swapped dash was
3633    /// swallowed into a `heading`), so a real setext heading the author wrote —
3634    /// or a `- x` with content, which can't underline anything — is never
3635    /// touched. This has to live in the *edit*, not the renderer: leaving the
3636    /// hazardous bytes on disk and only painting over them would ship a file
3637    /// every other CommonMark tool reads as a heading.
3638    ///
3639    /// This one keeps its own byte scan, and has to: the hazard is precisely
3640    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
3641    /// which asks twig which lines open an item — reports nothing here. There is
3642    /// no node to ask about. It is also the last Markdown spelling leaf writes on
3643    /// purpose rather than for want of an answer; once twig spells continuations
3644    /// itself, avoiding the trap becomes twig's, and this goes.
3645    ///
3646    /// [`list_marker_on_line`]: Self::list_marker_on_line
3647    fn avoid_setext_collapse(&mut self) {
3648        let caret = self.caret.min(self.source.len());
3649        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3650        let bytes = self.source.as_bytes();
3651        let mut dash = line_start;
3652        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
3653            dash += 1;
3654        }
3655        // A dash bullet is the only marker that doubles as a setext underline.
3656        if bytes.get(dash) != Some(&b'-') {
3657            return;
3658        }
3659        // Only an *empty* item is a bare underline; `- x` carries content and
3660        // can't fold the line above into a heading.
3661        let line_end = self.source[dash..]
3662            .find('\n')
3663            .map_or(self.source.len(), |i| dash + i);
3664        if !self.source[dash + 1..line_end].trim().is_empty() {
3665            return;
3666        }
3667        // The tell: that dash was swallowed into a `heading`. A properly nested
3668        // empty item sits under a `list_item`, with no heading in reach. Probe
3669        // the dash byte itself (well inside the heading), not the caret, whose
3670        // end-of-line offset can fall on the half-open span boundary.
3671        let collapsed = self
3672            .editor
3673            .ancestors_at(dash)
3674            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
3675            .unwrap_or(false);
3676        if !collapsed {
3677            return;
3678        }
3679        let caret = self.caret;
3680        if self.splice(dash, dash + 1, "*", EditKind::Other) {
3681            // Same width, so the caret keeps its column; fold into the edit that
3682            // triggered this so Tab stays one undo step.
3683            let _ = self.editor.coalesce_last_undo();
3684            self.caret = caret.min(self.source.len());
3685            self.clamp_caret();
3686            self.record_caret();
3687        }
3688    }
3689
3690    fn snapshot(&self) -> CaretState {
3691        CaretState {
3692            caret: self.caret,
3693            anchor: self.anchor,
3694        }
3695    }
3696
3697    /// Hand twig the current caret and selection as the blob for the live
3698    /// document state. Called before an edit — so the step twig retires records
3699    /// where the caret was, and undo can restore it — and again once the op has
3700    /// placed the caret, so redo restores where the edit left it.
3701    ///
3702    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
3703    /// caret through its own history, so coalescing falls out for free (folding
3704    /// two twig steps into one drops the intermediate blob, keeping the run's
3705    /// first) and the parallel stacks that had to march in lockstep — and could
3706    /// silently drift out of it — are gone.
3707    fn record_caret(&mut self) {
3708        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
3709    }
3710
3711    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
3712    /// the toggled region selected so a second press cleanly reverses it.
3713    pub fn toggle(&mut self, kind: InlineKind) {
3714        // The read-only gate — this door reaches twig without the splice.
3715        if self.read_only {
3716            return;
3717        }
3718        // Ahead of the no-selection branch below: arming a mark for text not yet
3719        // typed is a promise `insert` cannot keep in a format with no delimiters
3720        // to spell it with. Per *kind*, not per format — Markdown spells five
3721        // of the eight marks (highlight among them, under the `highlight`
3722        // extension leaf parses with), djot all eight, HTML seven.
3723        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
3724            return;
3725        }
3726        let Some((s, e)) = self.selection() else {
3727            // No selection: arm the mark for the next text typed here, the way a
3728            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
3729            // in the flow of typing without ever selecting anything — the delta
3730            // is realised onto the freshly typed text by `insert`. A fresh caret
3731            // position starts the delta over from the marks actually in force.
3732            if self.pending_at != Some(self.caret) {
3733                self.pending_marks = InlineMarks::empty();
3734                self.pending_at = Some(self.caret);
3735            }
3736            self.pending_marks.flip(kind);
3737            self.status = None;
3738            return;
3739        };
3740        // Whitespace at the edge of a selection is not part of what was chosen —
3741        // a double-click takes the space after the word with it — and a mark
3742        // cannot close against one anyway: `**word **` is four literal asterisks
3743        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
3744        let picked = &self.source[s..e];
3745        let (s, e) = (
3746            s + (picked.len() - picked.trim_start().len()),
3747            e - (picked.len() - picked.trim_end().len()),
3748        );
3749        if s >= e {
3750            self.status = Some(format!("{kind:?}: nothing selected to mark"));
3751            return;
3752        }
3753        // Styling a selection is a one-shot act, not a sticky mode.
3754        self.clear_pending();
3755        self.record_caret();
3756        match self.editor.toggle_inline(s, e, kind) {
3757            Ok(change) => {
3758                self.last_edit_kind = None; // structural edit is its own undo step
3759                self.refresh();
3760                self.anchor = Some(change.new.start);
3761                self.caret = change.new.end;
3762                self.dirty = self.source != self.clean_source;
3763                self.status = None;
3764                self.record_caret();
3765            }
3766            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
3767        }
3768    }
3769
3770    /// Whether the caret stands in a highlight — what a frontend asks to enable
3771    /// or disable its highlight-colour controls, the way
3772    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
3773    ///
3774    /// A fact about the *caret*, and the other half of
3775    /// [`Capabilities::mark_color`], which is the fact about the format. A
3776    /// frontend needs both: djot spells a highlight and no colour for it, so a
3777    /// caret standing in `{=word=}` answers `true` here and still has no palette
3778    /// to offer.
3779    ///
3780    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
3781    /// the palette appears exactly where the Highlight button is lit — with one
3782    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
3783    /// into lights the button and answers `false` here, because there is no node
3784    /// to colour until the text exists.
3785    pub fn caret_in_mark(&mut self) -> bool {
3786        self.mark_offset().is_some()
3787    }
3788
3789    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
3790    /// standing in the highlight the gesture means — or `None` when neither end
3791    /// of what is selected is in one.
3792    ///
3793    /// The caret first, and the selection's *start* after it, because of what
3794    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
3795    /// whole, with the caret at its far edge, one past the closing `==` and so
3796    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
3797    /// and colour it — the two presses a coloured highlight is made of — would
3798    /// otherwise refuse on the second, having just written the highlight the
3799    /// author is pointing at.
3800    fn mark_offset(&mut self) -> Option<usize> {
3801        let in_mark = |d: &mut Self, off: usize| {
3802            d.marks_at(off)
3803                .into_iter()
3804                .any(|(k, _)| k == InlineKind::Mark)
3805                .then_some(off)
3806        };
3807        let caret = self.caret.min(self.source.len());
3808        in_mark(self, caret).or_else(|| {
3809            let start = self.selection()?.0;
3810            in_mark(self, start)
3811        })
3812    }
3813
3814    /// The colour of the highlight at the caret — `None` both when the caret is
3815    /// in no highlight and when the highlight it is in names no colour, which
3816    /// are the same answer to "which swatch is lit".
3817    ///
3818    /// The innermost mark, by span, for the same reason
3819    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
3820    /// what the caret is *in* is the deepest node containing it. A `data-color`
3821    /// naming a colour this build has no variant for reads as `None` — the
3822    /// renderer already draws that as a plain highlight rather than guessing,
3823    /// and the toolbar agrees with the renderer.
3824    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
3825        let at = self.mark_offset()?;
3826        self.mark_color_at(at)
3827    }
3828
3829    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
3830    /// the innermost `mark` covering it, and the colour it names.
3831    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
3832        self.nodes()
3833            .into_iter()
3834            .filter(|n| n.kind == Kind::Mark)
3835            .filter(|n| n.span.start <= off && off < n.span.end)
3836            .min_by_key(|n| n.span.end - n.span.start)
3837            .and_then(|n| MarkColor::from_attrs(&n.attrs))
3838    }
3839
3840    /// Colour the highlight at the caret, or clear its colour with `None` — the
3841    /// palette behind a toolbar's Highlight button.
3842    ///
3843    /// Markdown only, and the one gesture whose availability is a fact about the
3844    /// *parse extensions* rather than about the format alone: the colour is
3845    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
3846    /// records as the mark's `data-color`, and only an editor parsing with
3847    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
3848    /// document) reads it back that way. Djot spells the highlight and no colour
3849    /// for it, so this refuses there — see [`Capabilities::mark_color`].
3850    ///
3851    /// **A colour is a property of a highlight that already exists.** There is
3852    /// no "highlight this in red" here, because that is two splices and would be
3853    /// two undo steps under one press; a frontend that wants it calls
3854    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
3855    /// order the two buttons already sit in. With no highlight at the caret this
3856    /// says so in the status line and writes nothing.
3857    ///
3858    /// The caret keeps its place in the *text*: the splice is entirely in the
3859    /// prefix between the opening `==` and the first word, so an offset past it
3860    /// rides the emoji's width, and one standing on the prefix itself lands
3861    /// where the prefix now ends.
3862    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
3863        // The read-only gate — this door reaches twig without the splice.
3864        if self.read_only {
3865            return;
3866        }
3867        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
3868            return;
3869        }
3870        let Some(at) = self.mark_offset() else {
3871            self.status = Some("highlight colour: no highlight at the caret".into());
3872            return;
3873        };
3874        // Clearing a colour a highlight hasn't got is twig's one *successful*
3875        // no-op, and the `Change` it hands back then describes whatever edit came
3876        // before it — a stale span that would drag the caret somewhere it never
3877        // was. Answer it here, where the question is cheap, rather than trusting
3878        // a change that isn't one.
3879        if color.is_none() && self.mark_color_at(at).is_none() {
3880            self.status = None;
3881            return;
3882        }
3883        self.record_caret();
3884        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
3885            Ok(change) => {
3886                // Re-anchored from the offsets as they were, *before* `refresh`
3887                // sees the new bytes: the caret it clamps is one standing inside
3888                // a prefix that didn't exist a moment ago, and walking it back to
3889                // a char boundary of the emoji loses the place this is restoring.
3890                let caret = reanchor(self.caret, &change);
3891                let anchor = self.anchor.map(|a| reanchor(a, &change));
3892                self.last_edit_kind = None; // structural edit is its own undo step
3893                self.refresh();
3894                self.caret = caret;
3895                self.anchor = anchor;
3896                self.dirty = self.source != self.clean_source;
3897                self.status = None;
3898                self.clamp_caret();
3899                self.record_caret();
3900            }
3901            Err(e) => self.status = Some(format!("highlight colour: {e}")),
3902        }
3903    }
3904
3905    /// One press of a colour swatch: colour the highlight at the caret, or —
3906    /// over a selection that isn't highlighted yet — highlight it and colour it,
3907    /// as **one** undo step.
3908    ///
3909    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
3910    /// one splice; this is the compound every toolbar actually presses, and it
3911    /// lives here rather than in each frontend because the rule it encodes —
3912    /// what a swatch means when there is no highlight under it yet — is one
3913    /// answer, not one per frontend. The two splices are folded into a single
3914    /// history step, so the press that made a red highlight is taken back by a
3915    /// single undo rather than leaving an uncoloured one behind.
3916    ///
3917    /// `None` clears the colour, and over an unhighlighted selection means
3918    /// simply "highlight this" — the same thing the Highlight button does.
3919    /// A bare caret in no highlight is left alone with a status line, because
3920    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
3921    /// colour cannot be armed with it.
3922    pub fn highlight(&mut self, color: Option<MarkColor>) {
3923        if self.caret_in_mark() || self.selection().is_none() {
3924            self.set_mark_color(color);
3925            return;
3926        }
3927        self.toggle(InlineKind::Mark);
3928        // The format may not spell a highlight at all (`toggle` said so), and
3929        // there is nothing to colour if it doesn't.
3930        if self.status.is_some() {
3931            return;
3932        }
3933        let before = self.revision;
3934        self.set_mark_color(color);
3935        // Only fold when the colour really spliced. `highlight(None)` over a
3936        // fresh highlight is a no-op by design, and coalescing there would eat
3937        // the *previous* edit into the toggle instead.
3938        if self.revision != before {
3939            let _ = self.editor.coalesce_last_undo();
3940        }
3941    }
3942
3943    // ── the presentation vocabulary ─────────────────────────────────────────
3944    //
3945    // Six gestures and five queries over twig's two attribute ops. Each gesture
3946    // edits **one key and keeps the rest**: it reads the node's attributes,
3947    // removes its own key (and, for alignment, its own tokens out of `class`),
3948    // adds the new value or nothing, and passes the list back whole — twig's
3949    // contract is replace-not-merge, so the read is the caller's job. A
3950    // paragraph that came in as `class="lead center" id="intro"
3951    // data-line-height="1.5"` and is right-aligned goes out as `class="lead
3952    // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
3953    // touched, which is what lets a document from elsewhere pass through the
3954    // editor unharmed.
3955    //
3956    // Clearing is the same gesture with `None`: the key goes, and an empty list
3957    // at the end unwraps the span or the Markdown div, which twig does.
3958
3959    /// Set — or with `None` clear — the alignment of the block the caret is in.
3960    ///
3961    /// A block property, so the gesture is `set_block_attrs` on the caret's
3962    /// block **whatever is selected**: a line is a block's, and "centre this"
3963    /// with three words selected means the paragraph, not the words. The
3964    /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
3965    /// same `class` are kept.
3966    ///
3967    /// In Markdown the attributes live on a `<div>` around the block — twig has
3968    /// no paragraph attribute syntax to write — and this reads them back off
3969    /// that div when the block is its sole child, so a second press rewrites
3970    /// the div rather than nesting a second one.
3971    pub fn set_alignment(&mut self, align: Option<Align>) {
3972        let attrs = self.block_attrs_at_caret();
3973        if align.is_none()
3974            && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
3975        {
3976            return;
3977        }
3978        let attrs = with_class_token(
3979            &attrs,
3980            |t| Align::from_token(t).is_some(),
3981            align.map(Align::name),
3982        );
3983        self.write_block_attrs("alignment", attrs);
3984    }
3985
3986    /// Set — or with `None` clear — the line spacing of the block the caret is
3987    /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
3988    /// the key: [`LineSpacing`] under `data-line-height`.
3989    pub fn set_line_spacing(&mut self, spacing: Option<LineSpacing>) {
3990        let attrs = self.block_attrs_at_caret();
3991        if spacing.is_none()
3992            && self.refuse_clear_from_div("line spacing", &attrs, |a| {
3993                LineSpacing::from_attrs(a).is_some()
3994            })
3995        {
3996            return;
3997        }
3998        let attrs = with_attr(&attrs, "data-line-height", spacing.map(LineSpacing::name));
3999        self.write_block_attrs("line spacing", attrs);
4000    }
4001
4002    /// Set — or with `None` clear — the size of the selected run, or of the
4003    /// caret's whole block when nothing is selected.
4004    ///
4005    /// Size, face and colour are the *run's*, and the block's when no run is
4006    /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4007    /// the range in an attributed span or re-styles the span it already lies in
4008    /// (never nesting a second, and unwrapping it when the last key goes). With
4009    /// no selection it is `set_block_attrs` on the caret's block, so that "make
4010    /// this paragraph larger" is a click with the caret in it rather than a
4011    /// select-all first.
4012    ///
4013    /// The walker reads the key at both levels with the nearer winning, so a
4014    /// span's `data-size` inside a block carrying its own applies to the span.
4015    pub fn set_font_size(&mut self, size: Option<SizeStep>) {
4016        self.set_run_attr("size", "data-size", size.map(SizeStep::name));
4017    }
4018
4019    /// Set — or with `None` clear — the face of the selected run, or of the
4020    /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4021    /// [`FontFamily`] under `data-font`.
4022    pub fn set_font_family(&mut self, font: Option<FontFamily>) {
4023        self.set_run_attr("font", "data-font", font.map(FontFamily::name));
4024    }
4025
4026    /// Set — or with `None` clear — the *text* colour of the selected run, or of
4027    /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
4028    /// with [`MarkColor`] under `data-color`.
4029    ///
4030    /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
4031    /// writes, and a different thing: that one colours a highlight's
4032    /// *background* and rides the `mark` node twig owns the spelling of, this
4033    /// one colours the letters and rides an attributed span. The two never
4034    /// collide, because a `mark` is a `mark` and a span is a span — and they
4035    /// share a vocabulary on purpose, so that a frontend with a red for a
4036    /// highlight has a red for text and both are *that* red.
4037    pub fn set_text_color(&mut self, color: Option<MarkColor>) {
4038        self.set_run_attr("text colour", "data-color", color.map(MarkColor::name));
4039    }
4040
4041    /// Insert a page break at the caret — `::page-break`, a leaf directive with
4042    /// no label and no attributes, which twig spells in every format that names
4043    /// a leaf container (Markdown under the `directives` extension
4044    /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
4045    /// page-break` fence).
4046    ///
4047    /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
4048    /// places a rule, and for the same reason: a directive is a block, so twig
4049    /// alone has nowhere to put one mid-paragraph and lands it after the
4050    /// caret's whole block. A bare paragraph is therefore parted at the caret
4051    /// first and the break aimed at the *first* half. See that method for the
4052    /// whole of the rule, including why a code block, a list item, a table and
4053    /// a setext heading are left unsplit.
4054    ///
4055    /// The frontends that paginate read the row's
4056    /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
4057    /// the ones that do not draw the `⧉ page-break` placeholder every leaf
4058    /// directive gets.
4059    pub fn insert_page_break(&mut self) {
4060        if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
4061            return;
4062        }
4063        self.caret = self.skip_trailing_close_delims(self.caret);
4064        // A selection is replaced by the break, as a rule replaces one.
4065        if let Some((s, e)) = self.selection() {
4066            self.splice(s, e, "", EditKind::Other);
4067        }
4068        self.anchor = None;
4069        self.record_caret();
4070        let at = self.caret;
4071        if self.caret_parts_bare_paragraph() {
4072            // A failure here is not fatal: the break still lands after the
4073            // block, which is what this call was trying to improve on.
4074            let _ = self.editor.split_block(at);
4075        }
4076        match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
4077            Ok(change) => {
4078                self.last_edit_kind = None;
4079                self.refresh();
4080                self.anchor = None;
4081                self.caret = change.new.end;
4082                self.dirty = self.source != self.clean_source;
4083                self.status = None;
4084                self.clamp_caret();
4085                self.record_caret();
4086            }
4087            Err(e) => self.status = Some(format!("page break: {e}")),
4088        }
4089    }
4090
4091    /// The alignment in force at the caret, or `None` for the theme's default —
4092    /// which swatch of an alignment control is lit.
4093    ///
4094    /// Read off the nearest node that names one: the block the caret is in, and
4095    /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
4096    /// shape, one property along.
4097    pub fn alignment_at_caret(&mut self) -> Option<Align> {
4098        self.presentation_chain()
4099            .iter()
4100            .find_map(|attrs| Align::from_attrs(attrs))
4101    }
4102
4103    /// The line spacing in force at the caret, or `None` for the theme's own.
4104    /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
4105    pub fn line_spacing_at_caret(&mut self) -> Option<LineSpacing> {
4106        self.presentation_chain()
4107            .iter()
4108            .find_map(|attrs| LineSpacing::from_attrs(attrs))
4109    }
4110
4111    /// The size in force at the caret, or `None` for the theme's own — the
4112    /// entry a size menu shows ticked.
4113    ///
4114    /// Run-level, so the chain starts one node deeper: the attributed span the
4115    /// caret stands in, then its block, then the `div`s around it. The nearest
4116    /// wins, which is the rule the walker draws by.
4117    pub fn font_size_at_caret(&mut self) -> Option<SizeStep> {
4118        self.presentation_chain()
4119            .iter()
4120            .find_map(|attrs| SizeStep::from_attrs(attrs))
4121    }
4122
4123    /// The face in force at the caret, or `None` for the theme's body face.
4124    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
4125    pub fn font_family_at_caret(&mut self) -> Option<FontFamily> {
4126        self.presentation_chain()
4127            .iter()
4128            .find_map(|attrs| FontFamily::from_attrs(attrs))
4129    }
4130
4131    /// The *text* colour in force at the caret, or `None` for the theme's.
4132    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
4133    /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
4134    /// highlight's background off a `mark`, and a `mark` is never in this chain.
4135    pub fn text_color_at_caret(&mut self) -> Option<MarkColor> {
4136        self.presentation_chain()
4137            .iter()
4138            .find_map(|attrs| MarkColor::from_attrs(attrs))
4139    }
4140
4141    /// The selection-or-caret half of the three run-level gestures: a span over
4142    /// a real selection, the caret's block over none.
4143    fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
4144        match self.selection() {
4145            Some((start, end)) => {
4146                let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
4147                self.write_run_attrs(what, start, end, attrs);
4148            }
4149            None => {
4150                let own = self.block_attrs_at_caret();
4151                if value.is_none()
4152                    && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
4153                {
4154                    return;
4155                }
4156                let attrs = with_attr(&own, key, value);
4157                self.write_block_attrs(what, attrs);
4158            }
4159        }
4160    }
4161
4162    /// A clear this gesture cannot carry out, said out loud instead of written:
4163    /// the node it rewrites — the caret's block, or the `<div>` around it that
4164    /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
4165    /// — does not name the property at all, and a `div` further out does.
4166    ///
4167    /// Handing twig the block's attributes with the key already absent changes
4168    /// no byte, and the query goes on answering `Some` off the div: the menu
4169    /// entry the author pressed stays unticked, and nothing says why. Twig's
4170    /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
4171    /// write on a node it is not rewriting — the honest answer is the status
4172    /// line, in the voice the other refusals use.
4173    ///
4174    /// `names` is the property's own reading of an attribute list, because
4175    /// alignment lives in a `class` token rather than a key of its own. Spans
4176    /// are skipped: one inside the block is not what a *block* gesture writes
4177    /// either, but neither is it "the div around the block", and the run-level
4178    /// gestures reach it through a selection.
4179    fn refuse_clear_from_div(
4180        &mut self,
4181        what: &str,
4182        own: &Attrs,
4183        names: impl Fn(&Attrs) -> bool,
4184    ) -> bool {
4185        if names(own) {
4186            return false;
4187        }
4188        let caret = self.caret.min(self.source.len());
4189        if !self
4190            .attr_chain_at(caret)
4191            .iter()
4192            .any(|(span, attrs)| !span && names(attrs))
4193        {
4194            return false;
4195        }
4196        self.status = Some(format!("{what}: set on the div around the block"));
4197        true
4198    }
4199
4200    /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
4201    /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
4202    /// has.
4203    ///
4204    /// **The caret keeps its place in the text, not its byte offset.** How a
4205    /// format spells a block's attributes is markup written *around* the block
4206    /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
4207    /// it in Markdown, a longer opening tag in HTML — and every one of those
4208    /// grows or shrinks above the author's own bytes. Where twig's change
4209    /// rewrites the block whole (Markdown's div is spliced as one region, block
4210    /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
4211    /// and parks the caret at the end of the splice, past the closing `</div>`:
4212    /// the caret is then in no block at all, so a second press of the same menu
4213    /// answers "no block at the caret" and the toolbar's queries read nothing.
4214    /// [`reanchor_in_block`] is what carries it across instead — the block's
4215    /// content span before and after, which is the one thing the respelling
4216    /// leaves alone.
4217    ///
4218    /// Read *before* the splice and applied *after* `refresh`, because both
4219    /// halves of that mapping are facts about a tree twig is between: the
4220    /// block's old bytes are gone once the edit lands, and its new ones are not
4221    /// in `self.source` until the refresh puts them there.
4222    fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
4223        if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
4224            return;
4225        }
4226        // A blank line has no block to carry an attribute, and twig answers
4227        // `NotFound` there — say so in leaf's own words instead.
4228        let Some(at) = self.block_offset_for_caret() else {
4229            self.status = Some(format!("{what}: no block at the caret"));
4230            return;
4231        };
4232        self.record_caret();
4233        let pairs = attr_pairs(&attrs);
4234        let was = self.block_content_at(at);
4235        let text = was.clone().map(|s| self.source[s].to_string());
4236        match self.editor.set_block_attrs(at, &pairs) {
4237            Ok(change) => {
4238                let (caret, anchor) = (self.caret, self.anchor);
4239                self.last_edit_kind = None; // structural edit is its own undo step
4240                self.refresh();
4241                let now = self.block_content_in(&change.new, text.as_deref());
4242                // A block the two halves cannot both name — a code block, a
4243                // caret in a list's marker — takes the plain arithmetic, which
4244                // is what it had before.
4245                let block = was.as_ref().zip(now.as_ref());
4246                self.caret = reanchor_in_block(caret, &change, block);
4247                self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
4248                self.dirty = self.source != self.clean_source;
4249                self.status = None;
4250                self.clamp_caret();
4251                self.record_caret();
4252            }
4253            Err(e) => self.status = Some(format!("{what}: {e}")),
4254        }
4255    }
4256
4257    /// The content span of the innermost paragraph or heading covering `off` —
4258    /// the author's own bytes, without the `# ` or the `<p>` that spells the
4259    /// block around them.
4260    ///
4261    /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
4262    /// reads, so that what a gesture re-anchors by is the block it wrote to.
4263    fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
4264        self.nodes()
4265            .into_iter()
4266            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4267            .filter(|n| n.span.start <= off && off <= n.span.end)
4268            .min_by_key(|n| n.span.end - n.span.start)
4269            .map(|n| n.content_span.unwrap_or(n.span))
4270    }
4271
4272    /// [`block_content_at`](Self::block_content_at)'s other half: the content
4273    /// span of the block `region` holds now, found by the bytes it held before.
4274    ///
4275    /// Matched on the text rather than taken as the first block in the region,
4276    /// because a rewritten region is markup and all — `<div class="center">`
4277    /// carries words of its own — and because the block this gesture moved is
4278    /// the one whose content the respelling did not touch. `None` where the
4279    /// region holds no block at all, which is djot's every case: the `{…}` line
4280    /// is spliced above the block and the block itself never moves through the
4281    /// change at all, only past it.
4282    fn block_content_in(
4283        &mut self,
4284        region: &Range<usize>,
4285        text: Option<&str>,
4286    ) -> Option<Range<usize>> {
4287        let text = text?;
4288        let spans: Vec<Range<usize>> = self
4289            .nodes()
4290            .into_iter()
4291            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4292            .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
4293            .map(|n| n.content_span.unwrap_or(n.span))
4294            .collect();
4295        spans
4296            .into_iter()
4297            .find(|s| self.source.get(s.clone()) == Some(text))
4298    }
4299
4300    /// Hand `attrs` to twig as the attribute set of the span over `[start,
4301    /// end)` — wrapping one, or re-styling the one the range already lies in,
4302    /// or unwrapping it when `attrs` is empty.
4303    ///
4304    /// What the splice leaves selected is the span's **content** — the author's
4305    /// words — and not the whole of `change.new`, which is markup and all:
4306    /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
4307    /// selection reaching past the node's own span lies in no span at all, so a
4308    /// second press of the menu would nest a fresh one instead of re-styling
4309    /// the one just written.
4310    fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
4311        if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
4312            return;
4313        }
4314        self.record_caret();
4315        let pairs = attr_pairs(&attrs);
4316        match self.editor.wrap_range_attrs(start, end, &pairs) {
4317            Ok(change) => {
4318                self.last_edit_kind = None;
4319                self.refresh();
4320                let content = self.span_content_in(&change.new);
4321                self.anchor = Some(content.start);
4322                self.caret = content.end;
4323                self.dirty = self.source != self.clean_source;
4324                self.status = None;
4325                self.clamp_caret();
4326                self.record_caret();
4327            }
4328            Err(e) => self.status = Some(format!("{what}: {e}")),
4329        }
4330    }
4331
4332    /// The content range of the attributed span `spliced` now holds — the
4333    /// outermost one inside it, since that is the one just written — or
4334    /// `spliced` itself where the splice left no span, which is what an unwrap
4335    /// leaves behind.
4336    fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
4337        self.nodes()
4338            .into_iter()
4339            .filter(wysiwyg::is_run_span)
4340            .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
4341            .max_by_key(|n| n.span.end - n.span.start)
4342            .and_then(|n| n.content_span)
4343            .unwrap_or_else(|| spliced.clone())
4344    }
4345
4346    /// The attribute set `set_block_attrs` is about to **replace** at the caret
4347    /// — which is the block's own, except in Markdown, where twig writes a
4348    /// block's attributes onto a `<div>` around it and rewrites that div when
4349    /// the block is its sole child. Reading the paragraph there would hand back
4350    /// an empty list and quietly drop everything the div said.
4351    ///
4352    /// Empty when the caret is in no block at all, which is the same list a
4353    /// block carrying no attributes gives — and the right one either way, since
4354    /// the gesture then refuses on its own.
4355    fn block_attrs_at_caret(&mut self) -> Attrs {
4356        let Some(off) = self.block_offset_for_caret() else {
4357            return Vec::new();
4358        };
4359        let nodes = self.nodes();
4360        let Some(block) = nodes
4361            .iter()
4362            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4363            .filter(|n| n.span.start <= off && off <= n.span.end)
4364            .min_by_key(|n| n.span.end - n.span.start)
4365        else {
4366            return Vec::new();
4367        };
4368        if self.format == Format::Markdown
4369            && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
4370            && wysiwyg::element_tag(parent) == Some("div")
4371            && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
4372        {
4373            return parent.attrs.clone();
4374        }
4375        block.attrs.clone()
4376    }
4377
4378    /// The attribute set `wrap_range_attrs` is about to **replace** over
4379    /// `[start, end)` — the innermost attributed span the range lies inside,
4380    /// which twig re-styles rather than nesting a second one in. Empty when the
4381    /// range lies in no span, where the gesture mints a fresh one.
4382    fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
4383        self.nodes()
4384            .into_iter()
4385            .filter(wysiwyg::is_run_span)
4386            .filter(|n| n.span.start <= start && end <= n.span.end)
4387            .min_by_key(|n| n.span.end - n.span.start)
4388            .map(|n| n.attrs)
4389            .unwrap_or_default()
4390    }
4391
4392    /// The attribute lists that bear on a presentation query, **nearest first**:
4393    /// the attributed spans the caret stands in (innermost first), then its
4394    /// block, then the `div`s around it. A `find_map` down this is the whole of
4395    /// each query, and the order is the rule the walker draws by.
4396    ///
4397    /// Read at the caret, and at the selection's *start* when the caret stands
4398    /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
4399    /// caret one past the span it just wrote — `toggle`'s convention — so
4400    /// asking the menu which entry that press just ticked must not answer
4401    /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
4402    fn presentation_chain(&mut self) -> Vec<Attrs> {
4403        let caret = self.caret.min(self.source.len());
4404        let mut chain = self.attr_chain_at(caret);
4405        if !chain.iter().any(|(span, _)| *span)
4406            && let Some((start, _)) = self.selection()
4407        {
4408            let alt = self.attr_chain_at(start);
4409            if alt.iter().any(|(span, _)| *span) {
4410                chain = alt;
4411            }
4412        }
4413        chain.into_iter().map(|(_, attrs)| attrs).collect()
4414    }
4415
4416    /// [`presentation_chain`](Self::presentation_chain) at one offset — every
4417    /// node bearing the vocabulary that covers it, innermost first, each paired
4418    /// with whether it is an attributed span (which is what tells the caller
4419    /// its run-level answer came from a run).
4420    ///
4421    /// Sorted by span length, which *is* the nesting order: a span lies inside
4422    /// its block and a block inside its div, so shortest-first is
4423    /// nearest-first without a second tree walk.
4424    fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
4425        let off = off.min(self.source.len());
4426        let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
4427        for n in self.nodes() {
4428            let span = wysiwyg::is_run_span(&n);
4429            let block = matches!(n.kind, Kind::Para | Kind::Heading);
4430            let div = wysiwyg::element_tag(&n) == Some("div");
4431            if !(span || block || div) {
4432                continue;
4433            }
4434            // A span is half-open, the way a mark is: the offset one past it is
4435            // the text after it. A block and a div claim their end too, so a
4436            // caret resting at the end of a line still reads its paragraph.
4437            let inside = if span {
4438                n.span.start <= off && off < n.span.end
4439            } else {
4440                n.span.start <= off && off <= n.span.end
4441            };
4442            if !inside {
4443                continue;
4444            }
4445            hits.push((n.span.end - n.span.start, span, n.attrs));
4446        }
4447        hits.sort_by_key(|(len, _, _)| *len);
4448        hits.into_iter()
4449            .map(|(_, span, attrs)| (span, attrs))
4450            .collect()
4451    }
4452
4453    /// Convert the block at the caret to a heading level or paragraph.
4454    pub fn set_block(&mut self, kind: BlockKind) {
4455        // The read-only gate — this door reaches twig without the splice.
4456        if self.read_only {
4457            return;
4458        }
4459        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
4460            return;
4461        }
4462        self.record_caret();
4463        // A blank line has no node to convert, and twig opens a block there
4464        // rather than declining — so the caret's own offset is the right thing
4465        // to hand it when `block_offset_for_caret` finds nothing.
4466        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
4467        match self.editor.set_block(offset, kind) {
4468            Ok(change) => {
4469                self.last_edit_kind = None;
4470                self.refresh();
4471                // Opening a block on a blank line writes a marker the caret
4472                // belongs *after*; converting an existing one moves nothing.
4473                self.caret = self.caret.max(change.new.end);
4474                self.clamp_caret();
4475                self.anchor = None;
4476                self.dirty = self.source != self.clean_source;
4477                self.status = None;
4478                self.record_caret();
4479            }
4480            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4481        }
4482    }
4483
4484    /// Whether `off` is inside a text block (paragraph, heading, code block…).
4485    fn has_block_at(&mut self, off: usize) -> bool {
4486        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
4487            chain
4488                .iter()
4489                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
4490        })
4491    }
4492
4493    /// The offset to hand twig's `set_block`: the caret when it is already inside
4494    /// a block, otherwise nudged onto the previous character (a caret at a line
4495    /// end sits at the doc level, outside the block). `None` when the caret is on
4496    /// a blank line — a new paragraph with no block node to convert.
4497    fn block_offset_for_caret(&mut self) -> Option<usize> {
4498        let caret = self.caret.min(self.source.len());
4499        if self.has_block_at(caret) {
4500            return Some(caret);
4501        }
4502        // Nudge to the previous character — but never across a newline: that would
4503        // target the previous block, and a blank line genuinely has no block.
4504        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
4505            && ch != '\n'
4506            && self.has_block_at(i)
4507        {
4508            return Some(i);
4509        }
4510        None
4511    }
4512
4513    /// The heading level of the text block at the caret, or `None` when that
4514    /// block is not a heading.
4515    pub fn current_heading_level(&mut self) -> Option<u32> {
4516        let caret = self.caret;
4517        self.nodes()
4518            .into_iter()
4519            .filter(|n| n.kind == Kind::Heading)
4520            .find(|n| n.span.start <= caret && caret <= n.span.end)
4521            .and_then(|n| n.level)
4522    }
4523
4524    /// The inline marks in force at the caret (or over the selection) — what a
4525    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
4526    /// inline counterpart. Cheap enough to call every frame: one twig
4527    /// `ancestors_at` query per caret (two with a selection), each walking root
4528    /// → deepest node at one offset. It never snapshots the tree the way
4529    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
4530    /// the only allocation is twig's own small ancestor `Vec`.
4531    ///
4532    /// **A selection reports a mark only when the mark covers *all* of it.**
4533    /// That's what every real toolbar means by an active button — Bold lit over
4534    /// a half-bold selection would claim a press turns bold *off*, when
4535    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
4536    /// Whole-coverage is asked as "is the same mark node standing over both the
4537    /// first and the last character?": inline nodes are contiguous, so one node
4538    /// covering both ends covers every byte between them. Two touching runs
4539    /// (`**a****b**`) are two nodes, and correctly light nothing.
4540    ///
4541    /// At a bare caret a mark is active when the caret stands inside the mark's
4542    /// span — `span.start <= caret < span.end`, delimiters included, which is
4543    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
4544    /// opening `*` (2) through the last byte of the closing `**` (9) are all
4545    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
4546    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
4547    /// typing would actually land inside the marked run. The offset one past the
4548    /// mark (10) is the text after it and reports nothing, at the end of the
4549    /// buffer exactly as in the middle.
4550    pub fn active_inline_marks(&mut self) -> InlineMarks {
4551        let Some((start, end)) = self.selection() else {
4552            // The marks actually in force at the caret, flipped by any armed
4553            // sticky delta — so `⌘b` at a bare caret lights the Bold button
4554            // immediately, before a single character is typed.
4555            let base: InlineMarks = self
4556                .marks_at(self.caret)
4557                .into_iter()
4558                .map(|(k, _)| k)
4559                .collect();
4560            return base.xor(self.pending_here());
4561        };
4562        // The selection's *last character*, not its exclusive end: `end` is the
4563        // offset one past the selection, which for a selection ending exactly at
4564        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
4565        // entirely bold, but offset 10 is the space after).
4566        let last = prev_boundary(&self.source, end);
4567        let head = self.marks_at(start);
4568        let tail = self.marks_at(last);
4569        head.into_iter()
4570            .filter(|m| tail.contains(m))
4571            .map(|(k, _)| k)
4572            .collect()
4573    }
4574
4575    /// The inline marks whose span covers `off`, each with the id of the node
4576    /// carrying it — the id is what lets a selection tell one mark node from
4577    /// another of the same kind.
4578    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
4579        let off = off.min(self.source.len());
4580        self.editor
4581            .ancestors_at(off)
4582            .unwrap_or_default()
4583            .into_iter()
4584            // `span.end` is the offset one *past* the mark, so it isn't in it.
4585            // twig already resolves a boundary to whatever starts there — in
4586            // `**bold** x` offset 8 is the following text, not the strong — but
4587            // when nothing follows, the tie has nobody to break for and the
4588            // chain still ends at the mark. That would make the answer at the
4589            // last offset of the document depend on whether the file happens to
4590            // end in a newline; the rule is `span.start <= off < span.end`, and
4591            // it's the same rule at the end of a buffer as in the middle.
4592            .filter(|m| off < m.span.end)
4593            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
4594            .collect()
4595    }
4596
4597    /// Toggle a heading at the caret: if the block is already this heading level,
4598    /// revert it to a paragraph; otherwise convert it to this heading level.
4599    /// This gives the heading commands the same toggle feel as bold/italic/code —
4600    /// re-applying a heading a line already has turns it back into body text.
4601    pub fn toggle_heading(&mut self, level: u32) {
4602        if self.current_heading_level() == Some(level) {
4603            self.set_block(BlockKind::Paragraph);
4604        } else {
4605            self.set_block(BlockKind::Heading(level));
4606        }
4607    }
4608
4609    /// Toggle a block quote around the selection, or around the block at the
4610    /// caret — the toolbar's Quote button.
4611    pub fn toggle_blockquote(&mut self) {
4612        self.toggle_container(BlockContainerKind::BlockQuote);
4613    }
4614
4615    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
4616    /// over the block at the caret — one op with the kind as a flag, the way
4617    /// `toggle_heading` takes its level, so a frontend needs no twig type to
4618    /// name the two buttons.
4619    ///
4620    /// Pressing the *other* list's button while in a list converts in place
4621    /// rather than nesting, so the pair reads as one three-state control
4622    /// (bulleted / numbered / neither) rather than two independent wrappers.
4623    pub fn toggle_list(&mut self, ordered: bool) {
4624        self.toggle_container(if ordered {
4625            BlockContainerKind::OrderedList
4626        } else {
4627            BlockContainerKind::BulletList
4628        });
4629    }
4630
4631    // ── Task list items ──────────────────────────────────────────────────────
4632    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
4633    // inline content of the item's first paragraph rather than part of its
4634    // marker, so adding or removing one must leave the item's continuation
4635    // indentation alone, and an item inside a quote is found past the quote
4636    // markers. leaf names the gesture and the offset; the spelling is twig's.
4637
4638    /// Whether the list item at the caret carries a checkbox, and which way it
4639    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
4640    /// item or no item at all. What a toolbar reads to light its checkbox button.
4641    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
4642        self.task_checked_at(self.caret)
4643    }
4644
4645    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
4646    /// offset — what a frontend asks before deciding a click landed on a box.
4647    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
4648        self.innermost_list_item(offset.min(self.source.len()))?
4649            .checked
4650    }
4651
4652    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
4653    /// A no-op with a reported reason when the caret is in no task item — minting
4654    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
4655    pub fn toggle_task_checked(&mut self) {
4656        self.toggle_task_at(self.caret);
4657    }
4658
4659    /// Tick or untick the task item covering `offset` — what a *click* on a
4660    /// rendered checkbox is. Separate from the caret form because a click carries
4661    /// its own offset and must not first move the caret there: ticking a box
4662    /// three paragraphs away should not take the cursor with it.
4663    pub fn toggle_task_at(&mut self, offset: usize) {
4664        // The read-only gate — this door reaches twig without the splice.
4665        if self.read_only {
4666            return;
4667        }
4668        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
4669            return;
4670        }
4671        let offset = offset.min(self.source.len());
4672        self.record_caret();
4673        match self.editor.toggle_task_checked(offset) {
4674            Ok(_) => self.after_task_edit(),
4675            Err(e) => self.status = Some(format!("task: {e}")),
4676        }
4677    }
4678
4679    /// Give the list item at the caret a checkbox, or take its checkbox away —
4680    /// the gesture that converts between a plain bullet and a task. A new box
4681    /// arrives unticked.
4682    pub fn toggle_task_item(&mut self) {
4683        // The read-only gate — this door reaches twig without the splice.
4684        if self.read_only {
4685            return;
4686        }
4687        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
4688            return;
4689        }
4690        let caret = self.caret.min(self.source.len());
4691        self.record_caret();
4692        match self.editor.toggle_task_item(caret) {
4693            Ok(_) => self.after_task_edit(),
4694            Err(e) => self.status = Some(format!("task: {e}")),
4695        }
4696    }
4697
4698    /// Settle after a task gesture. The caret rides its old byte offset and is
4699    /// clamped back in: a box is three or four bytes on the item's first line, so
4700    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
4701    /// real stop either way.
4702    fn after_task_edit(&mut self) {
4703        self.last_edit_kind = None;
4704        self.refresh();
4705        self.anchor = None;
4706        self.dirty = self.source != self.clean_source;
4707        self.status = None;
4708        self.clamp_caret();
4709        self.record_caret();
4710    }
4711
4712    // ── Tables ───────────────────────────────────────────────────────────────
4713    // A table is a grid, and twig edits it as one — add/remove/move a row or
4714    // column, set a column's alignment — re-spelling the whole table in a single
4715    // splice. Every gesture is anchored at the caret's cell. leaf just names the
4716    // gesture and re-reads the result; the whole table's numbering, borders, and
4717    // delimiter are twig's to keep straight.
4718
4719    /// Whether the caret is inside a table — what a frontend asks to enable or
4720    /// disable its table controls.
4721    ///
4722    /// An HTML `<table>` still answers `true`: the caret really is in a table,
4723    /// and the reason the grid controls stay dark there is
4724    /// [`Capabilities::table`], which is a fact about the document's format
4725    /// rather than about the caret. A frontend needs both.
4726    pub fn caret_in_table(&mut self) -> bool {
4727        let caret = self.caret.min(self.source.len());
4728        self.editor
4729            .ancestors_at(caret)
4730            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
4731            .unwrap_or(false)
4732    }
4733
4734    /// One grid op, guarded and settled — the shared body of the seven below.
4735    ///
4736    /// The guard is why this exists rather than seven copies of the same three
4737    /// lines, and it is the one guard leaf cannot delegate to twig. The table
4738    /// editor is the gesture family that consults no `Syntax` table (it spells a
4739    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
4740    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
4741    /// grid as a *pipe table* and reports success, swapping the element out for
4742    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
4743    /// downstream could tell that from a successful edit — the splice is real,
4744    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
4745    /// stopping at the door rather than detecting after the fact. See
4746    /// [`spells_pipe_tables`].
4747    fn table_op(
4748        &mut self,
4749        what: &str,
4750        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
4751    ) {
4752        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
4753            return;
4754        }
4755        self.record_caret();
4756        let at = self.caret;
4757        let r = op(&mut self.editor, at);
4758        self.apply_table(r, what);
4759    }
4760
4761    /// Insert an empty row below (`below`) or above the caret's row.
4762    pub fn table_insert_row(&mut self, below: bool) {
4763        self.table_op("table row", |e, at| e.table_insert_row(at, below));
4764    }
4765
4766    /// Delete the caret's row (not the header, not the last body row).
4767    pub fn table_delete_row(&mut self) {
4768        self.table_op("table row", |e, at| e.table_delete_row(at));
4769    }
4770
4771    /// Insert an empty column right (`right`) or left of the caret's column.
4772    pub fn table_insert_column(&mut self, right: bool) {
4773        self.table_op("table column", |e, at| e.table_insert_column(at, right));
4774    }
4775
4776    /// Delete the caret's column (unless it is the only one).
4777    pub fn table_delete_column(&mut self) {
4778        self.table_op("table column", |e, at| e.table_delete_column(at));
4779    }
4780
4781    /// Set the caret's column to `alignment`.
4782    pub fn table_set_alignment(&mut self, alignment: Alignment) {
4783        self.table_op("table alignment", |e, at| {
4784            e.table_set_alignment(at, alignment)
4785        });
4786    }
4787
4788    /// Move the caret's row one place down (`down`) or up, within the body rows.
4789    pub fn table_move_row(&mut self, down: bool) {
4790        self.table_op("table row", |e, at| e.table_move_row(at, down));
4791    }
4792
4793    /// Move the caret's column one place right (`right`) or left.
4794    pub fn table_move_column(&mut self, right: bool) {
4795        self.table_op("table column", |e, at| e.table_move_column(at, right));
4796    }
4797
4798    /// Settle the caret and document flags after a table op (or report its
4799    /// error). twig re-spells the whole table, so the caret rides its old byte
4800    /// offset and is clamped back into the rebuilt bytes — near enough to where
4801    /// it was, since the op preserves the cells' content and order around it.
4802    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
4803        match result {
4804            Ok(()) => {
4805                self.last_edit_kind = None;
4806                self.refresh();
4807                self.anchor = None;
4808                self.clamp_caret();
4809                self.dirty = self.source != self.clean_source;
4810                self.status = None;
4811                self.record_caret();
4812            }
4813            Err(e) => self.status = Some(format!("{what}: {e}")),
4814        }
4815    }
4816
4817    /// One `toggle_block_container` over the block-level target.
4818    ///
4819    /// leaf says *where*; twig decides everything else — which blocks the range
4820    /// covers, whether that means wrapping, unwrapping, nesting or converting,
4821    /// and how this document's format spells the prefix. The rule that a
4822    /// container only comes off when the range covers every block it holds is
4823    /// what the re-anchoring below is built around.
4824    fn toggle_container(&mut self, kind: BlockContainerKind) {
4825        // The read-only gate — this door reaches twig without the splice.
4826        if self.read_only {
4827            return;
4828        }
4829        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
4830            return;
4831        }
4832        let selected = self.selection();
4833        // A blank line holds no block, and twig opens an *empty* container on one
4834        // — since 3.2.0; it used to decline the range with `NotFound`, which is
4835        // why this used to lend it a scratch paragraph to wrap. Worth knowing
4836        // here because the line-for-line caret mapping below cannot describe it:
4837        // opening one under a paragraph writes the blank line the format needs
4838        // above the marker too, so the rewritten region has a line the old one
4839        // didn't, and "the same line, the same distance from its end" lands on
4840        // that new blank instead of in the container.
4841        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
4842        // Without a selection the target is the caret's own block, resolved the
4843        // way `set_block` resolves it — a caret at a line end sits at the doc
4844        // level and has to be nudged back onto the block it looks like it's in.
4845        // An empty range is enough: twig widens to the whole lines it touches.
4846        let (start, end) = match selected {
4847            Some(range) => range,
4848            None => {
4849                let off = self.block_offset_for_caret().unwrap_or(self.caret);
4850                (off, off)
4851            }
4852        };
4853        self.record_caret();
4854        match self.editor.toggle_block_container(start, end, kind) {
4855            Ok(change) => {
4856                // Read the caret's place out of the *pre-edit* source, before
4857                // `refresh` swaps that source out from under it.
4858                let place = (selected.is_none() && !opened_empty)
4859                    .then(|| self.caret_line_tail(&change.old));
4860                self.last_edit_kind = None; // structural edit is its own undo step
4861                self.refresh();
4862                match place {
4863                    // Both land the caret at the far end of what twig wrote, and
4864                    // differ only in what they leave selected.
4865                    //
4866                    // From a selection: select what the container now holds, the
4867                    // way `toggle` keeps its marked region selected — and for a
4868                    // stronger reason than symmetry: a container comes *off* only
4869                    // a range covering every block it holds, so a selection left
4870                    // on its old bytes (now short by a prefix per line) would nest
4871                    // on the second press instead of reversing the first.
4872                    //
4873                    // From a blank line: nothing to select, and the end of the
4874                    // region is exactly past the bare `> ` / `- ` twig wrote —
4875                    // the caret standing inside the container that was asked for.
4876                    None => {
4877                        self.anchor = (!opened_empty).then_some(change.new.start);
4878                        self.caret = change.new.end;
4879                    }
4880                    Some(place) => {
4881                        self.anchor = None;
4882                        self.caret = self.line_tail_offset(&change.new, place);
4883                    }
4884                }
4885                self.dirty = self.source != self.clean_source;
4886                self.status = None;
4887                self.clamp_caret();
4888                self.record_caret();
4889            }
4890            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4891        }
4892    }
4893
4894    /// The caret's place inside the region a container toggle is rewriting, in
4895    /// the only terms the rewrite preserves: which of the region's lines it sits
4896    /// on, and how many bytes of that line lie ahead of it.
4897    ///
4898    /// A container's markup goes in at column 0 and never touches what follows
4899    /// on the line, so that pair survives the edit exactly where a byte offset
4900    /// does not — a caret left on its old offset slides back by one prefix per
4901    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
4902    /// `> ` it just asked for.
4903    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
4904        let caret = self.caret.clamp(old.start, old.end);
4905        let line = self.source[old.start..caret].matches('\n').count();
4906        let end = self.source[caret..old.end]
4907            .find('\n')
4908            .map_or(old.end, |i| caret + i);
4909        (line, end - caret)
4910    }
4911
4912    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
4913    /// region: the offset `tail` bytes back from the end of the region's `line`.
4914    ///
4915    /// Both walks are clamped rather than trusted, because the one op that does
4916    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
4917    /// the items back apart with blank lines between them — and a caret landing
4918    /// on the nearest line of the right item beats one landing out of the region
4919    /// entirely.
4920    fn line_tail_offset(
4921        &self,
4922        new: &std::ops::Range<usize>,
4923        (line, tail): (usize, usize),
4924    ) -> usize {
4925        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
4926        let mut start = 0;
4927        for _ in 0..line {
4928            match region[start..].find('\n') {
4929                Some(i) => start += i + 1,
4930                None => break,
4931            }
4932        }
4933        let end = region[start..]
4934            .find('\n')
4935            .map_or(region.len(), |i| start + i);
4936        new.start + end.saturating_sub(tail).max(start)
4937    }
4938
4939    /// Link the selection to `destination` — the toolbar's Link button. With no
4940    /// selection it acts at the caret, which re-points a link the caret is
4941    /// already standing in (twig replaces an existing link's destination and
4942    /// keeps its text) and otherwise spells a link that has no text of its own:
4943    /// an autolink (`<https://x.dev>`) where the destination is one, and
4944    /// `[destination](destination)` where it isn't.
4945    ///
4946    /// `destination` reaches twig raw. Escaping it is format knowledge and the
4947    /// two formats genuinely disagree — Markdown ends a destination at the first
4948    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
4949    /// itself — so the side holding the document is the side that gets to spell
4950    /// it. A destination twig can't carry at all (one with a newline) comes back
4951    /// as an error rather than a quietly rewritten URL.
4952    pub fn insert_link(&mut self, destination: &str) {
4953        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
4954            return;
4955        }
4956        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
4957        self.record_caret();
4958        match self.editor.insert_link(start, end, destination) {
4959            Ok(change) => {
4960                self.last_edit_kind = None;
4961                self.refresh();
4962                match self.link_text_span(change.new.start) {
4963                    // A link with text of its own: select it, so typing replaces
4964                    // a `[dest](dest)`'s stand-in label and a second press
4965                    // re-points what the first one linked.
4966                    Some(text) => {
4967                        self.anchor = (text.start != text.end).then_some(text.start);
4968                        self.caret = text.end;
4969                    }
4970                    // An autolink is finished the moment it's written — its text
4971                    // *is* the URL. Leaving it selected would aim the next press
4972                    // at the one shape twig still wraps instead of re-points.
4973                    None => {
4974                        self.anchor = None;
4975                        self.caret = change.new.end;
4976                    }
4977                }
4978                self.dirty = self.source != self.clean_source;
4979                self.status = None;
4980                self.clamp_caret();
4981                self.record_caret();
4982            }
4983            Err(e) => self.status = Some(format!("link: {e}")),
4984        }
4985    }
4986
4987    /// Insert a block-level image at the caret: `![alt](destination)`. Any
4988    /// selection becomes the alt text (so "select a caption, insert image" labels
4989    /// it); with no selection, `alt` is used — empty for none. The caret lands
4990    /// just past the inserted image.
4991    ///
4992    /// Both halves go through twig (`insert_literal` for the alt text,
4993    /// `insert_image` for the image), so neither is spelled here. That used to be a
4994    /// `format!`, and it was wrong the first time an app inserted a real filename:
4995    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
4996    /// not an image at all — and the fix is per-format, since moving into the
4997    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
4998    pub fn insert_image(&mut self, destination: &str, alt: &str) {
4999        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
5000            return;
5001        }
5002        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5003        self.record_caret();
5004        // With no selection and an explicit `alt`, the alt text has to exist in the
5005        // document before it can be the image's — and it is raw caller input, so
5006        // it goes in through `insert_literal`, which escapes it for the format
5007        // rather than letting a `]` in someone's caption close the image early.
5008        let (start, end) = if start == end && !alt.is_empty() {
5009            match self.editor.insert_literal(start, alt) {
5010                Ok(change) => (change.new.start, change.new.end),
5011                Err(e) => {
5012                    self.status = Some(format!("image: {e}"));
5013                    return;
5014                }
5015            }
5016        } else {
5017            (start, end)
5018        };
5019        match self.editor.insert_image(start, end, destination) {
5020            Ok(change) => {
5021                self.last_edit_kind = None;
5022                self.refresh();
5023                // Just past the image, nothing selected — where a caret belongs
5024                // after inserting one.
5025                self.anchor = None;
5026                self.caret = change.new.end;
5027                self.dirty = self.source != self.clean_source;
5028                self.status = None;
5029                self.clamp_caret();
5030                self.record_caret();
5031            }
5032            Err(e) => self.status = Some(format!("image: {e}")),
5033        }
5034    }
5035
5036    /// Insert a block-level image, video, or audio at the caret. The image case
5037    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
5038    /// HTML elements, which is the only spelling Markdown and Djot have for them:
5039    ///
5040    /// ```text
5041    /// <video src="clip.mp4" controls>alt</video>
5042    /// <audio src="take.mp3" controls>alt</audio>
5043    /// ```
5044    ///
5045    /// HTML rather than a `::video{…}` directive deliberately. A directive means
5046    /// something only to an app that knows the vocabulary, so the document would
5047    /// read as literal punctuation everywhere else; `<video>` is what every other
5048    /// renderer already understands, and what leaf's own reader picks back up
5049    /// through `html_elements` promotion (see [`parse_extensions`]).
5050    ///
5051    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
5052    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
5053    /// `html_elements`. Before that only the multi-line form parsed as a block at
5054    /// all, and this wrote three lines to work around it.
5055    ///
5056    /// `controls` is always written: a player with no transport is a still frame
5057    /// the reader can't do anything with. Any selection becomes the element's
5058    /// fallback text, exactly as it becomes an image's alt.
5059    ///
5060    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
5061    /// applies, and bites harder here: a `"` in `destination` closes the
5062    /// attribute. A frontend taking these from a file picker is fine; one taking
5063    /// them from free text should keep them tame.
5064    ///
5065    /// [`MediaInfo`]: crate::MediaInfo
5066    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
5067        if kind == MediaKind::Image {
5068            return self.insert_image(destination, alt);
5069        }
5070        // Gated on the *image* gesture, not on one of its own — there isn't one,
5071        // since the bytes below are spelled here rather than by twig, and an HTML
5072        // document would in fact parse them. The button is one control with three
5073        // kinds behind it, and two of them working in a format where the third
5074        // cannot is a worse surface than three that agree — especially as
5075        // `insert_image` is the kind anyone reaches for first.
5076        if self.refuse_unsupported("media", Gesture::InsertImage) {
5077            return;
5078        }
5079        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5080        let alt_text = self
5081            .selected_text()
5082            .map(str::to_string)
5083            .unwrap_or_else(|| alt.to_string());
5084        let tag = match kind {
5085            MediaKind::Audio => "audio",
5086            _ => "video",
5087        };
5088        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
5089        self.edit(start, end, &markup);
5090    }
5091
5092    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
5093    /// button. Spelling and placement are both twig's; leaf used to write `---`
5094    /// itself, which was the Markdown spelling in a djot document too.
5095    ///
5096    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
5097    /// mid-paragraph and lands it after the caret's whole block. To get a rule
5098    /// *at* the caret — the paragraph parted in two around it, which is what a
5099    /// rule button is understood to do — the paragraph is first divided with
5100    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
5101    /// the offset `split_block` returns puts the rule after the *second* half
5102    /// instead, which is a rule in the right document and the wrong place.
5103    ///
5104    /// Only a plain paragraph is split, and only where there is something to
5105    /// part: at the paragraph's end the split has no second half to mint and
5106    /// would write the separator anyway — a blank line and the empty slot Enter
5107    /// leaves for the next paragraph, which the rule then lands above and
5108    /// nothing fills — so there the rule goes straight after the paragraph,
5109    /// which is where the split-and-aim was sending it regardless. At the
5110    /// paragraph's *start* the split is kept, though it parts nothing either:
5111    /// `|para` becomes `\npara` with the caret on the new blank line, and a
5112    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
5113    /// "before the paragraph" is said through a gesture that only knows
5114    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
5115    /// else the rule simply lands after the block, which is both twig's own
5116    /// answer and the better one: splitting a fenced code block would leave two
5117    /// fences with a rule between them, and splitting a list item would mint an
5118    /// item nobody asked for on the way to a rule that lands after the list
5119    /// regardless. A table and a setext heading refuse the split outright, so
5120    /// they take the same path by themselves.
5121    pub fn insert_thematic_break(&mut self) {
5122        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
5123        {
5124            return;
5125        }
5126        self.caret = self.skip_trailing_close_delims(self.caret);
5127        // A selection is replaced by the rule, so collapse it first and let the
5128        // split-and-rule below run from the caret it leaves behind.
5129        if let Some((s, e)) = self.selection() {
5130            self.splice(s, e, "", EditKind::Other);
5131        }
5132        self.anchor = None;
5133        self.record_caret();
5134        let at = self.caret;
5135        if self.caret_parts_bare_paragraph() {
5136            // A failure here is not fatal: the rule still lands after the block,
5137            // which is exactly what this call was trying to improve on.
5138            let _ = self.editor.split_block(at);
5139        }
5140        match self.editor.insert_thematic_break(at) {
5141            Ok(change) => {
5142                self.last_edit_kind = None;
5143                self.refresh();
5144                self.anchor = None;
5145                self.caret = change.new.end;
5146                self.dirty = self.source != self.clean_source;
5147                self.status = None;
5148                self.clamp_caret();
5149                self.record_caret();
5150            }
5151            Err(e) => self.status = Some(format!("thematic break: {e}")),
5152        }
5153    }
5154
5155    /// Insert a fresh table at the caret — the toolbar's Table button. One
5156    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
5157    /// the document's own dialect and placed the way its thematic break is:
5158    /// after the caret's block, blank-separated. A bare paragraph is parted
5159    /// around the caret first, exactly as
5160    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
5161    /// table lands *at* the caret rather than after everything the caret's
5162    /// paragraph says.
5163    ///
5164    /// The caret ends in the first header cell, selected the way Tab selects
5165    /// a cell — the natural next act is to type the heading, and Tab then
5166    /// walks the grid. That cell is read back from the rebuilt table map
5167    /// rather than computed from the splice, because twig's blank line and
5168    /// quote prefix put the first bar at an offset only the reparse knows.
5169    ///
5170    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
5171    /// rows or columns is twig's refusal (a header with nothing under it is
5172    /// what its row delete refuses to leave), reported through `status`.
5173    pub fn insert_table(&mut self, rows: usize, cols: usize) {
5174        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
5175            return;
5176        }
5177        self.caret = self.skip_trailing_close_delims(self.caret);
5178        if let Some((s, e)) = self.selection() {
5179            self.splice(s, e, "", EditKind::Other);
5180        }
5181        self.anchor = None;
5182        self.record_caret();
5183        let at = self.caret;
5184        if self.caret_parts_bare_paragraph() {
5185            let _ = self.editor.split_block(at);
5186        }
5187        match self.editor.insert_table(at, rows, cols) {
5188            Ok(change) => {
5189                self.last_edit_kind = None;
5190                self.refresh();
5191                self.anchor = None;
5192                self.caret = change.new.end;
5193                self.dirty = self.source != self.clean_source;
5194                self.status = None;
5195                self.clamp_caret();
5196                // Into the first header cell of the table just written: the
5197                // first table whose grid begins inside the splice.
5198                self.rebuild_map();
5199                let first_cell = self
5200                    .vmap
5201                    .tables
5202                    .iter()
5203                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
5204                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
5205                    .map(|cell| (cell.start, cell.end));
5206                if let Some((start, end)) = first_cell {
5207                    self.select_cell(start, end);
5208                }
5209                self.record_caret();
5210            }
5211            Err(e) => self.status = Some(format!("table: {e}")),
5212        }
5213    }
5214
5215    /// Whether the caret sits in a paragraph and nothing else — no list item, no
5216    /// quote, no fence, no table — with paragraph text still ahead of it. The
5217    /// one shape where parting the block around the caret is unambiguously what
5218    /// a rule button means; see
5219    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
5220    /// container is left to take the rule after itself.
5221    ///
5222    /// The "text ahead" half is what keeps `split_block` from running at the
5223    /// one edge where its output composes badly. At a paragraph's end twig
5224    /// cannot mint the empty second half (no format spells an empty
5225    /// paragraph), so it writes only the separator — a blank line and the
5226    /// slot Enter leaves for the paragraph to come — and a block then aimed at
5227    /// the first half lands above a slot that nothing fills: `para\n` with the
5228    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
5229    /// as nothing ahead, since the split would shed it as the second half's
5230    /// leading indent and leave the same slot. Which end of the newline a
5231    /// paragraph's span stops at differs between the formats (Markdown before
5232    /// it, djot after), which is why this reads the remaining bytes rather
5233    /// than comparing offsets. The paragraph's
5234    /// start is deliberately not the same case — see
5235    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
5236    /// split is kept.
5237    fn caret_parts_bare_paragraph(&mut self) -> bool {
5238        let caret = self.caret.min(self.source.len());
5239        let Ok(chain) = self.editor.ancestors_at(caret) else {
5240            return false;
5241        };
5242        let mut para_end = None;
5243        for m in chain {
5244            match m.kind {
5245                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
5246                Kind::ListItem
5247                | Kind::TaskListItem
5248                | Kind::BlockQuote
5249                | Kind::CodeBlock
5250                | Kind::Table => return false,
5251                _ => {}
5252            }
5253        }
5254        match para_end {
5255            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
5256            _ => false,
5257        }
5258    }
5259
5260    /// The destination of the link under the caret — what a Link prompt shows so
5261    /// ⌘K on an existing link edits its URL instead of asking for it again.
5262    /// `None` when the caret stands in no link.
5263    ///
5264    /// An autolink carries no separate destination: its text *is* the URL, so
5265    /// that's what comes back for one.
5266    pub fn link_destination_at_caret(&mut self) -> Option<String> {
5267        self.link_destination_at(self.caret)
5268    }
5269
5270    /// The destination of the link at `off`.
5271    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
5272    /// the caret isn't.
5273    ///
5274    /// The offset form exists for the same reason
5275    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
5276    /// the document somewhere else — a footnote's text in a popover, say — has
5277    /// rows and runs but no caret in them, and still needs to know which of those
5278    /// runs a reader can follow.
5279    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
5280        self.nodes()
5281            .into_iter()
5282            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
5283            .filter(|n| n.span.start <= off && off < n.span.end)
5284            .max_by_key(|n| n.span.start)
5285            .and_then(|n| n.destination.or(n.text))
5286    }
5287
5288    /// Where the locator `id` lands in this document — the `#v2` half of a
5289    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
5290    /// answers to it.
5291    ///
5292    /// The other end of a link, and the reason this exists: without it a
5293    /// destination has only file granularity, so following a citation into a
5294    /// chapter drops the reader at the top of it to hunt for the verse. Which is
5295    /// also why it is a *document* query rather than a caret one — the document
5296    /// being asked is usually not the one the reader is in.
5297    ///
5298    /// Three readings, tried in order, because the same `#some-heading` is
5299    /// written three ways across the formats leaf opens:
5300    ///
5301    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
5302    ///    the auto-ids djot mints for its headings. The only exact answer, so it
5303    ///    goes first — a document that says `{#v1}` has settled the question.
5304    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
5305    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
5306    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
5307    ///    authored anywhere land on a djot heading.
5308    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
5309    ///    none and `{#custom}` is literal text in a Markdown heading — so for
5310    ///    the format most vaults are written in, the heading's own words are the
5311    ///    only thing a fragment can name. This is the rule every Markdown
5312    ///    renderer already follows, which is what makes `#a-heading` mean in
5313    ///    diaryx what it means on the web.
5314    ///
5315    /// Ties go to the earliest match, then to the widest: a duplicated id is the
5316    /// document's mistake and the first one is the answer every anchor
5317    /// implementation gives, while preferring the wider span picks the section
5318    /// over the heading that opens it — more for a peek to show, same place to
5319    /// land.
5320    pub fn locate(&mut self, id: &str) -> Option<Landing> {
5321        let id = id.trim();
5322        if id.is_empty() {
5323            return None;
5324        }
5325        let nodes = self.nodes();
5326
5327        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
5328        // is picking, among nodes that start together, the one that ends last.
5329        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
5330            matches
5331                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
5332                .map(|n| Landing {
5333                    start: n.span.start,
5334                    end: n.span.end,
5335                })
5336        };
5337
5338        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
5339            return Some(landing);
5340        }
5341        let want = slug(id);
5342        if want.is_empty() {
5343            return None;
5344        }
5345        if let Some(landing) = pick(
5346            &mut nodes
5347                .iter()
5348                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
5349        ) {
5350            return Some(landing);
5351        }
5352
5353        // A heading by its words. Its span is one line, so the end comes from
5354        // where the *section* it opens gives out — the next heading that is not
5355        // under it, or the end of the document. A Markdown heading has no
5356        // section node to ask (twig only builds those for djot), and a peek that
5357        // showed the heading alone would answer "what does that say" with the
5358        // title of the thing it says.
5359        let heading = nodes
5360            .iter()
5361            .filter(|n| n.kind == Kind::Heading)
5362            .filter(|n| {
5363                n.content_span
5364                    .clone()
5365                    .and_then(|s| self.source.get(s))
5366                    .is_some_and(|text| slug(text) == want)
5367            })
5368            .min_by_key(|n| n.span.start)?;
5369        let level = heading.level.unwrap_or(u32::MAX);
5370        let end = nodes
5371            .iter()
5372            .filter(|n| n.kind == Kind::Heading)
5373            .filter(|n| n.span.start > heading.span.start)
5374            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
5375            .map(|n| n.span.start)
5376            .min()
5377            .unwrap_or(self.source.len());
5378        Some(Landing {
5379            start: heading.span.start,
5380            end,
5381        })
5382    }
5383
5384    /// Write a footnote at the caret — the toolbar's Footnote button, and the
5385    /// one gesture in the footnote story that *authors* rather than follows.
5386    ///
5387    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
5388    /// the `[^1]:` definition at the end of the document. Half a footnote is not
5389    /// a footnote — a bare reference with nothing defining it renders as literal
5390    /// brackets — so a single button that wrote only the reference would leave
5391    /// the author to hand-spell the other half in a document that had just
5392    /// stopped showing them what the first half meant. One edit also means one
5393    /// undo takes both back.
5394    ///
5395    /// The definition's body is left empty and **the caret lands in it**, which
5396    /// is the whole point of pressing the button: nobody wants a reference to a
5397    /// note they have not written yet. Getting back to where they were writing
5398    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
5399    /// the same return leg a reader following a reference already uses, so the
5400    /// author is left standing on the near end of a round trip that works.
5401    ///
5402    /// A selection collapses to its *end* rather than being replaced: a
5403    /// reference annotates the words before it, so "select the claim, add a
5404    /// footnote" should mark that claim, not consume it.
5405    pub fn insert_footnote(&mut self) {
5406        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
5407            return;
5408        }
5409        let at = self.selection().map_or(self.caret, |(_, end)| end);
5410        self.anchor = None;
5411        self.caret = at;
5412        self.record_caret();
5413        let label = self.next_footnote_label();
5414        match self.editor.insert_footnote(at, &label) {
5415            Ok(change) => {
5416                self.last_edit_kind = None;
5417                self.refresh();
5418                self.anchor = None;
5419                // `change.new` runs from the reference to the end of the
5420                // document, so its start is the `[^1]` just written and
5421                // `footnote_at` resolves it to the note the same way a reader's
5422                // tap does — and to the note's *body*, which is already a caret
5423                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
5424                // and has none), so this needs no snap on top. The fallback is
5425                // the reference's own offset: a format that spelled the pair some
5426                // way leaf can't read back should still leave the caret on the
5427                // edit rather than at the far end of a document it just grew.
5428                self.caret = self
5429                    .footnote_at(change.new.start)
5430                    .and_then(|note| note.offset)
5431                    .unwrap_or(change.new.start);
5432                self.dirty = self.source != self.clean_source;
5433                self.status = None;
5434                self.clamp_caret();
5435                self.record_caret();
5436            }
5437            Err(e) => self.status = Some(format!("footnote: {e}")),
5438        }
5439    }
5440
5441    /// The label to give a footnote the author has not named: the lowest counting
5442    /// number no footnote in the document is already wearing.
5443    ///
5444    /// twig takes the label rather than minting one, because it holds no opinion
5445    /// about what a document's footnotes should be called — and it is right not
5446    /// to. Numbering them is what every author of a numbered note expects, and
5447    /// re-using a taken number would silently point the new reference at somebody
5448    /// else's note (twig reuses an existing definition rather than appending a
5449    /// second one, which is the right rule for citing a note twice on purpose and
5450    /// exactly the wrong accident to have by default).
5451    ///
5452    /// *References* are counted alongside definitions, not just definitions: a
5453    /// document carrying a dangling `[^2]` has a 2 that means something to
5454    /// whoever wrote it, and minting a definition for it here would answer a
5455    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
5456    /// count entirely — they take no number, so they block none.
5457    fn next_footnote_label(&mut self) -> String {
5458        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
5459            .into_iter()
5460            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
5461            .filter_map(|label| label.parse().ok())
5462            .collect();
5463        taken.extend(
5464            self.nodes()
5465                .into_iter()
5466                .filter(|n| n.kind == Kind::FootnoteReference)
5467                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
5468                .filter_map(|label| label.parse::<u32>().ok()),
5469        );
5470        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
5471    }
5472
5473    /// The footnote reference under the caret, resolved to the note it names.
5474    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
5475    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
5476        self.footnote_at(self.caret)
5477    }
5478
5479    /// The footnote reference at `off`, resolved to the note it names — what a
5480    /// frontend shows when a reader activates a `[^1]`.
5481    ///
5482    /// A reference is not a link node, so
5483    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
5484    /// (and should not) answer for one: a link names a destination to leave for,
5485    /// a reference names a note that is already in this document. Following one
5486    /// is a move within the page, which is why this hands back an `offset`
5487    /// rather than something to open.
5488    ///
5489    /// Offset-based rather than caret-only because the gesture that wants this
5490    /// most is the one that must not move the caret: a pointer hovering a `[1]`
5491    /// asks what note it names without disturbing where the reader was typing.
5492    /// The caret is just the offset a click already placed —
5493    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
5494    ///
5495    /// `None` when `off` stands in no reference. A reference whose note the
5496    /// document never defines is *not* `None` — it answers with the label it
5497    /// looked for and no text, which is what lets a frontend say so instead of
5498    /// silently doing nothing.
5499    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
5500        // Innermost-wins by latest start, the rule its link sibling uses.
5501        let span = self
5502            .nodes()
5503            .into_iter()
5504            .filter(|n| n.kind == Kind::FootnoteReference)
5505            .filter(|n| n.span.start <= off && off < n.span.end)
5506            .max_by_key(|n| n.span.start)?
5507            .span;
5508        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
5509
5510        // The note itself. Definitions are roots beside `doc` rather than
5511        // children of it, so they're asked for directly — see
5512        // `wysiwyg::footnote_definitions`.
5513        let note = wysiwyg::footnote_definitions(&mut self.editor)
5514            .into_iter()
5515            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
5516        let Some(note) = note else {
5517            return Some(FootnoteRef {
5518                label,
5519                text: None,
5520                offset: None,
5521                end: None,
5522            });
5523        };
5524        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
5525        Some(FootnoteRef {
5526            label,
5527            text: body
5528                .clone()
5529                .and_then(|b| self.source.get(b))
5530                .map(str::to_string),
5531            // The body's start, not the definition's — see `FootnoteRef::offset`.
5532            offset: body.clone().map(|b| b.start),
5533            end: body.map(|b| b.end),
5534        })
5535    }
5536
5537    /// The footnote *definition* the caret stands in, and where the reference
5538    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
5539    /// at the caret's offset.
5540    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
5541        self.footnote_definition_at(self.caret)
5542    }
5543
5544    /// The footnote definition spanning `off`, and where the reference that
5545    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
5546    ///
5547    /// The mirror image, deliberately: the same gesture that takes a reader from
5548    /// `[1]` down to the note takes them from the note back up to `[1]`, so
5549    /// following a footnote is a round trip rather than a fall. It needs no
5550    /// memory of how the reader arrived — the document says where the reference
5551    /// is — which is what makes it work for a reader who scrolled to the notes
5552    /// themselves, and what keeps it right after an edit moves either end.
5553    ///
5554    /// `None` when `off` stands in no definition. A definition nothing cites is
5555    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
5556    /// its label and no offset, so a frontend can say "nothing refers to this"
5557    /// rather than offer a jump that goes nowhere.
5558    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
5559        // Definitions are roots beside `doc`, so `nodes()` — which walks the
5560        // document body — never reports one. They're asked for directly, the way
5561        // `footnote_at` asks for the note it resolves to.
5562        //
5563        // Closed at the end, unlike the half-open test its neighbours use. A
5564        // definition's span stops at its last content byte — the newline ending
5565        // the line is outside it — so `span.end` is the caret stop at the end of
5566        // the note's own row, not the first byte of anything after. Excluding it
5567        // meant the one caret an author is guaranteed to have, the one left
5568        // sitting at the end of the note they just typed, was in no definition at
5569        // all: writing a note and then asking to go back to its reference
5570        // answered nothing. Two definitions in a row still can't both match —
5571        // there is a blank line between them — and `max_by_key` decides anyway.
5572        let note = wysiwyg::footnote_definitions(&mut self.editor)
5573            .into_iter()
5574            .filter(|m| m.span.start <= off && off <= m.span.end)
5575            .max_by_key(|m| m.span.start)?;
5576        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
5577
5578        // The earliest reference carrying this label. `min` rather than a `find`,
5579        // because `nodes()` reports a flattened walk whose order is twig's
5580        // business, not document order. Bound first: the walk needs `&mut self`
5581        // and reading the labels back out needs `&self.source`.
5582        let nodes = self.nodes();
5583        let offset = nodes
5584            .into_iter()
5585            .filter(|n| n.kind == Kind::FootnoteReference)
5586            .filter(|n| {
5587                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
5588            })
5589            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
5590            .map(|n| n.span.start + 2)
5591            .min();
5592        Some(FootnoteDef { label, offset })
5593    }
5594
5595    /// The destination of the image under the caret — what an image prompt shows
5596    /// so editing an existing image starts from its current URL instead of blank,
5597    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
5598    /// `None` when the caret stands in no image. A caret resting just after a
5599    /// block image (its trailing stop) is still "in" it — the half-open span test
5600    /// excludes that offset, which is the intended precision: past the image is
5601    /// past it.
5602    pub fn image_destination_at_caret(&mut self) -> Option<String> {
5603        let off = self.caret;
5604        self.nodes()
5605            .into_iter()
5606            .filter(|n| n.kind == Kind::Image)
5607            .filter(|n| n.span.start <= off && off < n.span.end)
5608            .max_by_key(|n| n.span.start)
5609            .and_then(|n| n.destination)
5610    }
5611
5612    /// The language of the fenced code block the caret stands in — what a
5613    /// language prompt shows so editing it starts from the current value rather
5614    /// than blank. `None` when the caret is in no code block, or in one whose
5615    /// fence carries no language (or an indented block, which has no fence).
5616    pub fn code_language_at_caret(&mut self) -> Option<String> {
5617        let start = self.code_block_start_at_caret()?;
5618        wysiwyg::code_language(&self.source, start)
5619    }
5620
5621    /// Whether the caret stands in a fenced code block — the one a language
5622    /// prompt could edit. A frontend gates its "set language" affordance on this
5623    /// (an indented block, which can't carry a language, reports `false`).
5624    pub fn caret_in_fenced_code(&mut self) -> bool {
5625        self.code_block_start_at_caret()
5626            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
5627    }
5628
5629    /// Set (or clear, with `""`) the language of the fenced code block the caret
5630    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
5631    /// block, and a reported error for a language the format's fence cannot
5632    /// carry.
5633    ///
5634    /// twig rewrites the info string, so the fence's own width — measured
5635    /// against a body neither side touches — is kept, and a language holding a
5636    /// space, a line end or the fence character is refused rather than written
5637    /// out to reparse as something else. Leaf used to splice over the info span
5638    /// itself and `trim()` the input, which handled the one bad case it had
5639    /// thought of.
5640    pub fn set_code_language(&mut self, lang: &str) {
5641        // The read-only gate — this door reaches twig without the splice.
5642        if self.read_only {
5643            return;
5644        }
5645        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
5646            return;
5647        }
5648        if self.code_block_start_at_caret().is_none() {
5649            return;
5650        }
5651        let lang = lang.trim();
5652        // `None` clears the info string; `Some("")` asks for an empty one. Both
5653        // write a bare fence, and the prompt's empty value means "clear".
5654        let want = (!lang.is_empty()).then_some(lang);
5655        self.record_caret();
5656        match self.editor.set_code_language(self.caret, want) {
5657            Ok(_) => {
5658                self.last_edit_kind = None;
5659                self.refresh();
5660                self.anchor = None;
5661                self.dirty = self.source != self.clean_source;
5662                self.status = None;
5663                self.clamp_caret();
5664                self.record_caret();
5665            }
5666            Err(e) => self.status = Some(format!("code language: {e}")),
5667        }
5668    }
5669
5670    /// The `span.start` of the code block covering the caret — the anchor
5671    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
5672    /// in none.
5673    fn code_block_start_at_caret(&mut self) -> Option<usize> {
5674        let off = self.caret;
5675        self.nodes()
5676            .into_iter()
5677            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
5678            .max_by_key(|n| n.span.start)
5679            .map(|n| n.span.start)
5680    }
5681
5682    /// The source range of the text inside the link covering `off` — what sits
5683    /// between its `[` and `]`. `None` when twig reports no link there.
5684    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
5685        self.nodes()
5686            .into_iter()
5687            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
5688            // the other's `span.start`; the link that starts latest at or before
5689            // `off` is the one `off` is actually in.
5690            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
5691            .max_by_key(|n| n.span.start)
5692            .and_then(|n| n.content_span)
5693    }
5694
5695    // ── undo / redo ───────────────────────────────────────────────────────────
5696    // twig owns the history of *bytes* (it owns the buffer) and now carries the
5697    // caret through it too: `record_caret` stashes each state's caret in twig's
5698    // opaque per-step blob, and undo/redo hand it back with the source they
5699    // restore. So leaf keeps no history of its own — no parallel stacks to march
5700    // in lockstep and silently drift out of it.
5701
5702    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
5703    /// where they were when that step began.
5704    pub fn undo(&mut self) {
5705        if self.read_only {
5706            return;
5707        }
5708        let (undone, redoable) = (self.undo_steps, self.redo_steps);
5709        match self.editor.undo() {
5710            Ok(Some(change)) => {
5711                self.after_history(change);
5712                // `refresh` counted the restore as an edit; it was a step back.
5713                self.undo_steps = undone.saturating_sub(1);
5714                self.redo_steps = redoable + 1;
5715            }
5716            Ok(None) => {
5717                self.undo_steps = 0;
5718                self.status = Some("nothing to undo".into());
5719            }
5720            Err(e) => self.status = Some(format!("undo: {e}")),
5721        }
5722    }
5723
5724    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
5725    /// selection back where that step originally left them.
5726    pub fn redo(&mut self) {
5727        if self.read_only {
5728            return;
5729        }
5730        let (undone, redoable) = (self.undo_steps, self.redo_steps);
5731        match self.editor.redo() {
5732            Ok(Some(change)) => {
5733                self.after_history(change);
5734                // `refresh` counted the restore as an edit; it was a step forward.
5735                self.undo_steps = undone + 1;
5736                self.redo_steps = redoable.saturating_sub(1);
5737            }
5738            Ok(None) => {
5739                self.redo_steps = 0;
5740                self.status = Some("nothing to redo".into());
5741            }
5742            Err(e) => self.status = Some(format!("redo: {e}")),
5743        }
5744    }
5745
5746    /// Refresh the cached source and put the caret back where the step being
5747    /// undone/redone had it, clearing any active run.
5748    ///
5749    /// The caret comes from twig's blob for the restored state (what
5750    /// `record_caret` stored). `change` is only the fallback for a state with no
5751    /// blob — a caret at the end of the restored text, which is where this always
5752    /// landed before the blobs were kept. It is the edit site, not where the user
5753    /// was standing, so it's a floor and not the behaviour: undoing should hand
5754    /// back the document *and* the place you were working, which for an edit made
5755    /// anywhere but under the caret are two different places.
5756    fn after_history(&mut self, change: Change) {
5757        self.refresh();
5758        match self
5759            .editor
5760            .caret_blob()
5761            .ok()
5762            .and_then(|b| CaretState::from_blob(&b))
5763        {
5764            Some(state) => {
5765                self.caret = state.caret.min(self.source.len());
5766                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
5767            }
5768            None => {
5769                self.caret = change.new.end.min(self.source.len());
5770                self.anchor = None;
5771            }
5772        }
5773        self.goal_col = None;
5774        self.last_edit_kind = None;
5775        self.dirty = self.source != self.clean_source;
5776        self.status = None;
5777        self.clamp_caret();
5778    }
5779
5780    // ── the file ──────────────────────────────────────────────────────────────
5781
5782    #[cfg(feature = "fs")]
5783    pub fn save(&mut self) {
5784        if self.is_untitled() {
5785            // No path to write and no name to invent: ⌘S on an untitled document
5786            // is a Save As, and only a frontend has a picker to ask with. Say so
5787            // rather than failing at the filesystem with an empty path.
5788            self.status = Some("untitled — save as…".into());
5789            return;
5790        }
5791        let path = self.path.clone();
5792        if self.write(&path) {
5793            self.mark_saved();
5794        }
5795    }
5796
5797    /// Save As: write the document to `path` and *move* it there — `self.path`
5798    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
5799    /// what Save As means; a copy would leave the user editing a document whose
5800    /// name is no longer where their keystrokes go.
5801    ///
5802    /// The move only happens if the bytes actually landed. A failed write leaves
5803    /// the path, `dirty`, and the disk watermark exactly as they were, with the
5804    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
5805    /// must never come away believing it was saved.
5806    ///
5807    /// An existing `path` is overwritten, and the caller is the one that knows
5808    /// whether to ask first: a Save As picker has already run that prompt, and a
5809    /// second confirmation from down here would be the same question twice.
5810    ///
5811    /// `format` does **not** follow the new extension. The buffer is parsed as
5812    /// the format it was opened with, and re-reading it as another one is a
5813    /// conversion — a different, lossy operation that would throw away the undo
5814    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
5815    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
5816    /// until it's reopened.
5817    #[cfg(feature = "fs")]
5818    pub fn save_as(&mut self, path: PathBuf) {
5819        if !self.write(&path) {
5820            return;
5821        }
5822        self.path = path;
5823        self.mark_saved();
5824    }
5825
5826    /// Put `source` on disk at `path`, reporting whether it got there. The one
5827    /// place leaf writes a document, so a save and a Save As can't disagree
5828    /// about what a failure looks like.
5829    #[cfg(feature = "fs")]
5830    fn write(&mut self, path: &Path) -> bool {
5831        match std::fs::write(path, self.source.as_bytes()) {
5832            Ok(()) => true,
5833            Err(e) => {
5834                self.status = Some(format!("save failed: {e}"));
5835                false
5836            }
5837        }
5838    }
5839
5840    /// Re-base the document's saved watermark to the current bytes: clears
5841    /// `dirty`, records `source` as the new clean state (so undoing back to here
5842    /// clears the flag again), and re-stamps the on-disk hash.
5843    ///
5844    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
5845    /// the hook a **filesystem-free host** calls itself once it has persisted
5846    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
5847    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
5848    /// are already where that host wants them, and this just tells the model they
5849    /// are safe.
5850    pub fn mark_saved(&mut self) {
5851        self.clean_source = self.source.clone();
5852        self.dirty = false;
5853        // The bytes on disk are now ours, so this is the new watermark: without
5854        // re-stamping it, every save would report its own work as an external
5855        // change forever after.
5856        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
5857        self.status = Some(format!("saved {}", self.file_name()));
5858    }
5859
5860    /// What the file looks like now against the bytes leaf last read or wrote.
5861    ///
5862    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
5863    /// so this is a filesystem round-trip, not a per-frame question — ask it
5864    /// when a window regains focus, on a timer, or before a save.
5865    ///
5866    /// This *only* reports the file. Whether the document also has unsaved edits
5867    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
5868    /// [`DiskState::Changed`] means a save overwrites someone's work and a
5869    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
5870    /// it has no way to ask — so it hands a frontend both halves and lets it put
5871    /// the question to the person who can answer it.
5872    #[cfg(feature = "fs")]
5873    pub fn disk_state(&self) -> DiskState {
5874        let Some(want) = self.disk_hash else {
5875            return DiskState::Untitled;
5876        };
5877        match std::fs::read(&self.path) {
5878            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
5879            Ok(_) => DiskState::Changed,
5880            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
5881            Err(_) => DiskState::Unreadable,
5882        }
5883    }
5884
5885    /// Re-read the file and replace the document with what's there — the other
5886    /// answer to a [`DiskState::Changed`].
5887    ///
5888    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
5889    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
5890    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
5891    /// document shouldn't have to argue with a guard.
5892    ///
5893    /// **The undo history survives, and the reload is one step in it.** The
5894    /// whole buffer is spliced with the file's bytes through the same door every
5895    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
5896    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
5897    /// swapped the document out from under a reader gives them back what they
5898    /// were looking at, marked dirty, and ^Z again carries on into whatever they
5899    /// had done before it. This used to build a fresh parse and drop the stack,
5900    /// on the reasoning that twig's history belongs to the buffer and these are
5901    /// different bytes; that is true of *rebasing* a step onto them and not of
5902    /// recording the swap itself as one, which is all this is. A splice twig
5903    /// won't take falls back to the fresh parse, and only that path still costs
5904    /// the history.
5905    ///
5906    /// The caret keeps its byte offset, clamped to the new length; the selection
5907    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
5908    /// file changed, so it can't know where the caret "still" is. Clamping keeps
5909    /// it where the user left it in the common case (a change further down the
5910    /// file, or none in the text they're sitting in), and never puts it
5911    /// somewhere invalid. A selection has two such offsets and no such excuse —
5912    /// silently reinterpreting one over changed bytes would arm the *next*
5913    /// keystroke to delete something the user never selected.
5914    ///
5915    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
5916    /// document alone with a status.
5917    #[cfg(feature = "fs")]
5918    pub fn reload(&mut self) {
5919        if self.is_untitled() {
5920            self.status = Some("no file to reload".into());
5921            return;
5922        }
5923        let bytes = match std::fs::read(&self.path) {
5924            Ok(b) => b,
5925            Err(e) => {
5926                self.status = Some(format!("reload failed: {e}"));
5927                return;
5928            }
5929        };
5930        let Ok(source) = String::from_utf8(bytes) else {
5931            self.status = Some("reload failed: file is not UTF-8".into());
5932            return;
5933        };
5934        // Already these bytes — someone saved a file back unchanged, or leaf's
5935        // own write is being read back. Re-baseline against it and stop: a
5936        // splice of the text onto itself would put an undo step on the stack for
5937        // something nobody did.
5938        if source == self.source {
5939            self.disk_hash = Some(hash_bytes(source.as_bytes()));
5940            self.clean_source = source;
5941            self.dirty = false;
5942            self.status = Some(format!("reloaded {}", self.file_name()));
5943            return;
5944        }
5945        let caret = self.caret;
5946        // The pre-reload caret, so undoing the swap puts it back where the
5947        // reader was standing — the same bracketing `splice_exact` does.
5948        self.record_caret();
5949        if self
5950            .editor
5951            .edit_range(0, self.source.len(), &source)
5952            .is_ok()
5953        {
5954            self.refresh();
5955        } else {
5956            // twig wouldn't take the splice. Start over from the bytes, which is
5957            // what this always did, and is the one path that still costs the
5958            // history — `format` is the format this document *is*, not what the
5959            // (unchanged) name now says, see `save_as`.
5960            match new_editor(source.as_bytes(), self.format) {
5961                Ok(editor) => {
5962                    self.editor = editor;
5963                    self.source = source.clone();
5964                    // Not going through `refresh`, so the revision has to move
5965                    // here or every frontend keeps painting the old file from
5966                    // cache.
5967                    self.revision += 1;
5968                }
5969                Err(e) => {
5970                    self.status = Some(format!("reload failed: {e}"));
5971                    return;
5972                }
5973            }
5974        }
5975        self.disk_hash = Some(hash_bytes(source.as_bytes()));
5976        self.clean_source = self.source.clone();
5977        self.caret = caret.min(self.source.len());
5978        self.anchor = None;
5979        self.goal_col = None;
5980        self.last_edit_kind = None;
5981        self.dirty = false;
5982        self.status = Some(format!("reloaded {}", self.file_name()));
5983        self.clamp_caret();
5984        // And the post-reload caret, so a redo restores it.
5985        self.record_caret();
5986    }
5987
5988    /// Re-read the source from twig after it has changed the document. The one
5989    /// funnel every edit, undo, and redo comes through — so it's where the
5990    /// revision moves, and anything cached against the text dies here.
5991    fn refresh(&mut self) {
5992        if let Ok(s) = self.editor.source_str() {
5993            self.source = s;
5994        }
5995        self.revision += 1;
5996        // An edit is a step onto the history and the end of anything undone;
5997        // `undo`/`redo` come through here too and correct this after.
5998        self.undo_steps += 1;
5999        self.redo_steps = 0;
6000        self.clamp_caret();
6001    }
6002
6003    /// Whether [`undo`](Self::undo) has a step to take back — for a native
6004    /// Edit menu to enable its item by. See the note on `undo_steps` for what
6005    /// "has" means here.
6006    pub fn can_undo(&self) -> bool {
6007        !self.read_only && self.undo_steps > 0
6008    }
6009
6010    /// Whether [`redo`](Self::redo) has an undone step to restore.
6011    pub fn can_redo(&self) -> bool {
6012        !self.read_only && self.redo_steps > 0
6013    }
6014
6015    // ── caret movement ─────────────────────────────────────────────────────────
6016    // `extend` grows the selection (Shift+motion): it pins the anchor on the
6017    // first extended step and moves only the caret; an un-extended motion drops
6018    // the selection.
6019
6020    /// Place the caret at byte `offset` (clamped to a char boundary), extending
6021    /// the selection when `extend` is set. The public form of `move_to`, for a
6022    /// frontend that hit-tests pixels straight to a source offset.
6023    pub fn place_caret(&mut self, offset: usize, extend: bool) {
6024        self.goal_col = None;
6025        let before = self.caret;
6026        // A pixel hit-test can land between the visible caret stops — in the
6027        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
6028        // Snap to the nearest real stop so the caret can't come to rest where it
6029        // would draw in one place and type in another. The `(row, col)` click
6030        // path (`click`) already snaps this way through `offset_of_pos`; the
6031        // source view reaches every byte, so it snaps to nothing.
6032        let target = match self.view {
6033            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
6034            // The source view reaches every byte, so there is no stop to snap
6035            // to — but "every byte" still means every *character* boundary. A
6036            // caret resting inside a multi-byte character draws nowhere real
6037            // and panics the next time anything slices there.
6038            View::Source => self.char_boundary_at_or_before(offset),
6039        };
6040        self.move_to(target, extend);
6041        self.clamp_caret();
6042        self.debug_assert_on_a_stop(before);
6043    }
6044
6045    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
6046    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
6047    /// grab the metadata) while the source view still selects the literal whole.
6048    pub fn select_all(&mut self) {
6049        self.anchor = Some(self.caret_floor());
6050        self.caret = self.source.len();
6051        self.goal_col = None;
6052        self.last_edit_kind = None;
6053        self.status = None;
6054    }
6055
6056    /// Select the word (or whitespace / punctuation run) at `offset` — the
6057    /// double-click gesture. Anchors on the run's start with the caret at its
6058    /// end so a following Shift-motion extends from the far edge.
6059    pub fn select_word_at(&mut self, offset: usize) {
6060        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
6061        self.anchor = Some(s);
6062        self.caret = e;
6063        self.goal_col = None;
6064        self.last_edit_kind = None;
6065        self.status = None;
6066        self.clamp_caret();
6067    }
6068
6069    /// Select the whole enclosing text block (paragraph, heading, list item's
6070    /// text…) at `offset` — the triple-click gesture. Reads the range straight
6071    /// from the AST (twig's `content_span`), so it selects the entire *logical*
6072    /// paragraph even when that paragraph soft-wraps across several visual rows —
6073    /// where a visual-row-based select breaks down, because one source offset at
6074    /// a wrap boundary belongs to two rows at once.
6075    pub fn select_block_at(&mut self, offset: usize) {
6076        let off = offset.min(self.source.len());
6077        let range = self
6078            .editor
6079            .ancestors_at(off)
6080            .ok()
6081            .and_then(|chain| {
6082                // Ancestors run root → deepest; the deepest node that is neither
6083                // an inline span nor a multi-block container is the text block
6084                // the caret sits in (a paragraph, a heading, a code block…).
6085                chain
6086                    .into_iter()
6087                    .rev()
6088                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6089                    .map(|m| m.content_span.unwrap_or(m.span))
6090            })
6091            .unwrap_or_else(|| source_line_range(&self.source, off));
6092        self.anchor = Some(range.start.min(self.source.len()));
6093        self.caret = range.end.min(self.source.len());
6094        self.goal_col = None;
6095        self.last_edit_kind = None;
6096        self.status = None;
6097        self.clamp_caret();
6098    }
6099
6100    /// Select the exact source range `[start, end)` — anchor at `start`, caret
6101    /// at `end` — without snapping either end to a visible caret stop.
6102    ///
6103    /// The one caret verb that takes a range it was *handed* rather than one it
6104    /// worked out, for a host that already knows the bytes it means: a search
6105    /// hit, an annotation's footprint, a quote re-anchored through
6106    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
6107    /// wrong tool for that, and not by a little — it snaps to the nearest
6108    /// *visible* stop, and where a range butts up against a hidden delimiter
6109    /// the nearest stop is the one before it, so selecting the "needle" of
6110    /// `**needle**` comes back with "needl" and an edit against it strands the
6111    /// "e".
6112    ///
6113    /// What `place_caret` does that is bookkeeping rather than snapping still
6114    /// happens here, because a host handing in a range is not asking to opt out
6115    /// of the invariants:
6116    ///
6117    /// - both ends are clamped into the document and up to
6118    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
6119    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
6120    ///   types into the metadata;
6121    /// - both land on character boundaries, so nothing slices a `é` in half;
6122    /// - the sticky vertical goal column is dropped, and any armed inline mark
6123    ///   disarmed, since a range from outside inherits neither.
6124    ///
6125    /// An empty range is a caret rather than a selection —
6126    /// [`selection`](Self::selection) reports `None` for it, as it does for any
6127    /// anchor that has met the caret.
6128    pub fn select_range(&mut self, start: usize, end: usize) {
6129        let floor = self.caret_floor();
6130        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
6131        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
6132        self.anchor = Some(anchor);
6133        self.caret = caret;
6134        self.goal_col = None;
6135        self.status = None;
6136        self.last_edit_kind = None;
6137        self.clear_pending();
6138    }
6139
6140    /// `offset` itself if it is a character boundary, else the boundary before
6141    /// it. An offset that isn't one draws nowhere real and panics the next time
6142    /// anything slices there.
6143    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
6144        let mut o = offset.min(self.source.len());
6145        while o > 0 && !self.source.is_char_boundary(o) {
6146            o -= 1;
6147        }
6148        o
6149    }
6150
6151    /// The lowest source offset the caret may occupy in the active view. In
6152    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
6153    /// the first rendered offset; the source view reaches everything, so it's 0.
6154    fn caret_floor(&self) -> usize {
6155        match self.view {
6156            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
6157            View::Source => 0,
6158        }
6159    }
6160
6161    /// Land in a table cell with its whole content selected — the anchor at the
6162    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
6163    /// like tabbing into a form field: the text comes up selected, so typing
6164    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
6165    /// end`) collapses to a plain caret home (an empty selection is no selection).
6166    fn select_cell(&mut self, start: usize, end: usize) {
6167        self.select_range(start, end);
6168    }
6169
6170    fn move_to(&mut self, offset: usize, extend: bool) {
6171        if extend {
6172            if self.anchor.is_none() {
6173                self.anchor = Some(self.caret);
6174            }
6175        } else {
6176            self.anchor = None;
6177        }
6178        self.caret = offset.min(self.source.len()).max(self.caret_floor());
6179        self.status = None;
6180        // A caret move ends the current typing/deletion run, so the next edit
6181        // starts a fresh undo group rather than coalescing across the gap.
6182        self.last_edit_kind = None;
6183        // Moving away disarms any sticky mark — "start bold" applies only where
6184        // it was asked for, not wherever the caret next lands.
6185        self.clear_pending();
6186    }
6187
6188    // In the source view, motion walks source bytes / source lines. In the
6189    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
6190    // what steps the caret cleanly over hidden delimiters.
6191
6192    pub fn move_left(&mut self, extend: bool) {
6193        self.goal_col = None;
6194        if !extend && let Some((s, _e)) = self.selection() {
6195            self.move_to(s, false);
6196            return;
6197        }
6198        let target = match self.view {
6199            View::Source => {
6200                if self.caret > 0 {
6201                    prev_boundary(&self.source, self.caret)
6202                } else {
6203                    0
6204                }
6205            }
6206            // Walks caret *stops*, not columns: decoration (a table border, a
6207            // cell's padding) is stepped over in one press, and a hidden
6208            // delimiter never holds the caret up — though the end of a mark's
6209            // content is a stop of its own (`VisualMap::mark_ends`), so
6210            // leaving `**bold**` from past its `**` is a press onto the end of
6211            // the bold and another onto the `d`.
6212            View::Wysiwyg => self
6213                .vmap
6214                .caret_stop_before(self.caret)
6215                .unwrap_or(self.caret),
6216        };
6217        let before = self.caret;
6218        self.move_to(target, extend);
6219        self.debug_assert_on_a_stop(before);
6220    }
6221
6222    pub fn move_right(&mut self, extend: bool) {
6223        self.goal_col = None;
6224        if !extend && let Some((_s, e)) = self.selection() {
6225            self.move_to(e, false);
6226            return;
6227        }
6228        let target = match self.view {
6229            View::Source => {
6230                if self.caret < self.source.len() {
6231                    next_boundary(&self.source, self.caret)
6232                } else {
6233                    self.caret
6234                }
6235            }
6236            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
6237        };
6238        let before = self.caret;
6239        self.move_to(target, extend);
6240        self.debug_assert_on_a_stop(before);
6241    }
6242
6243    /// Move to the start of the previous word (⌥← / Ctrl+←).
6244    pub fn move_word_left(&mut self, extend: bool) {
6245        self.goal_col = None;
6246        let before = self.caret;
6247        let target = self.word_left_from(self.caret);
6248        self.move_to(target, extend);
6249        self.debug_assert_on_a_stop(before);
6250    }
6251
6252    /// Move to the end of the next word (⌥→ / Ctrl+→).
6253    pub fn move_word_right(&mut self, extend: bool) {
6254        self.goal_col = None;
6255        let before = self.caret;
6256        let target = self.word_right_from(self.caret);
6257        self.move_to(target, extend);
6258        self.debug_assert_on_a_stop(before);
6259    }
6260
6261    // Word boundaries are found in the space the *view* is in. The source view
6262    // walks the source, because there the source is what's rendered. WYSIWYG
6263    // walks the rendered text instead: `**` is invisible to the user, so it has
6264    // to be invisible to word motion too — a caret parked inside one draws in
6265    // the column after `bold` and types two bytes earlier, and a word-delete
6266    // that stops there shreds the markup into `a ** c`.
6267
6268    /// The word boundary to the left of `off` in the active view's space.
6269    fn word_left_from(&self, off: usize) -> usize {
6270        match self.view {
6271            View::Source => prev_word(&self.source, off),
6272            View::Wysiwyg => self.glyph_word_left(off),
6273        }
6274    }
6275
6276    /// The word boundary to the right of `off` in the active view's space.
6277    fn word_right_from(&self, off: usize) -> usize {
6278        match self.view {
6279            View::Source => next_word(&self.source, off),
6280            View::Wysiwyg => self.glyph_word_right(off),
6281        }
6282    }
6283
6284    /// The character class of the glyph drawn at stop `off`.
6285    ///
6286    /// Read from the source, because a stop points at the source byte its glyph
6287    /// came from — the source *is* where the rendered character is written. What
6288    /// makes the walk glyph space rather than source space is that it only ever
6289    /// visits stops, and the hidden bytes between them have none.
6290    fn class_at(&self, off: usize) -> Class {
6291        self.source
6292            .get(off..)
6293            .and_then(|s| s.chars().next())
6294            .map_or(Class::Space, classify)
6295    }
6296
6297    /// [`next_word`] in glyph space: skip any leading separators, then consume
6298    /// the following word run, with the stop table standing in for the source's
6299    /// characters.
6300    fn glyph_word_right(&self, from: usize) -> usize {
6301        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
6302            return from;
6303        };
6304        let mut in_word = false;
6305        loop {
6306            match self.class_at(off) {
6307                Class::Word => in_word = true,
6308                _ if in_word => return off,
6309                _ => {}
6310            }
6311            match self.vmap.stop_after(off) {
6312                Some(next) => off = next,
6313                None => return off,
6314            }
6315        }
6316    }
6317
6318    /// [`prev_word`] in glyph space: skip separators walking left, then consume
6319    /// the preceding word run.
6320    fn glyph_word_left(&self, from: usize) -> usize {
6321        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
6322            return from;
6323        };
6324        let mut in_word = false;
6325        while let Some(prev) = self.vmap.stop_before(off) {
6326            match self.class_at(prev) {
6327                Class::Word => in_word = true,
6328                _ if in_word => return off,
6329                _ => {}
6330            }
6331            off = prev;
6332        }
6333        off
6334    }
6335
6336    /// After a motion that walks the visual map, the caret must be *on* the map.
6337    /// A stop is the only offset where the caret draws and edits in the same
6338    /// place, and it's the invariant both a caret parked inside an emoji and one
6339    /// parked inside a `**` were quietly breaking.
6340    ///
6341    /// Only when the caret actually moved: a walk with nowhere to go leaves it
6342    /// where it was, which is wherever the floor or a frontend put it rather
6343    /// than somewhere this motion chose.
6344    fn debug_assert_on_a_stop(&self, before: usize) {
6345        debug_assert!(
6346            self.view != View::Wysiwyg
6347                || self.vmap.num_rows() == 0
6348                || self.caret == before
6349                || self.vmap.is_stop(self.caret),
6350            "motion left the caret at {}, which is not a caret stop: it would draw in \
6351             one place and type in another",
6352            self.caret
6353        );
6354    }
6355
6356    // Up and Down run off the ends of the document rather than stopping dead at
6357    // them: Up from the first row lands at the document's start, Down from the
6358    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
6359    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
6360    // reaching the end of the text is what a reader means by it.
6361    //
6362    // The views used to disagree here by accident rather than by decision: the
6363    // source view fell into the edge behaviour through `row_col_to_offset`
6364    // clamping an out-of-range row to the end of the string, while WYSIWYG had
6365    // no row below to walk to and did nothing at all. They share the rule now,
6366    // each in its own space — the source view reaches every byte, WYSIWYG only
6367    // the offsets it draws.
6368
6369    pub fn move_up(&mut self, extend: bool) {
6370        let (row, col) = self.caret_pos();
6371        let goal = self.goal_col.unwrap_or(col);
6372        let target = match self.view {
6373            View::Source => match row.checked_sub(1) {
6374                Some(r) => row_col_to_offset(&self.source, r, goal),
6375                None => self.reachable_start(),
6376            },
6377            // A table's border rules are drawn but hold no caret, so Up steps
6378            // over them to the row that does.
6379            View::Wysiwyg => match self.vmap.navigable_above(row) {
6380                Some(r) => self.row_target(r, goal),
6381                None => self.reachable_start(),
6382            },
6383        };
6384        self.step_vertical(target, goal, extend);
6385    }
6386
6387    pub fn move_down(&mut self, extend: bool) {
6388        let (row, col) = self.caret_pos();
6389        let goal = self.goal_col.unwrap_or(col);
6390        let target = match self.view {
6391            View::Source => match self.source_row_below(row) {
6392                Some(r) => row_col_to_offset(&self.source, r, goal),
6393                None => self.reachable_end(),
6394            },
6395            View::Wysiwyg => match self.vmap.navigable_below(row) {
6396                Some(r) => self.row_target(r, goal),
6397                None => self.reachable_end(),
6398            },
6399        };
6400        self.step_vertical(target, goal, extend);
6401    }
6402
6403    /// Land a vertical motion at `target`, latching the `goal` column it aimed
6404    /// with so the rest of the run keeps aiming there.
6405    ///
6406    /// A motion with nowhere to go changes *nothing*, the goal column included:
6407    /// the latch used to run before the early return at the top of the document,
6408    /// so an Up that did nothing still armed a column, and the next Down aimed
6409    /// at one the caret had never been in.
6410    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
6411        let before = self.caret;
6412        if target == before {
6413            return;
6414        }
6415        self.goal_col = Some(goal);
6416        self.move_to(target, extend);
6417        self.debug_assert_on_a_stop(before);
6418    }
6419
6420    /// The source line below `row`, or `None` when `row` is the last one. Lines
6421    /// are counted by newline, so a trailing one leaves a real, empty last line
6422    /// for the caret to sit on — the document ends below it, not on it.
6423    fn source_row_below(&self, row: usize) -> Option<usize> {
6424        let last = self.source.bytes().filter(|&b| b == b'\n').count();
6425        (row < last).then_some(row + 1)
6426    }
6427
6428    /// Where a vertical motion aiming at the `goal` column lands on visual row
6429    /// `r`: the column clamped to the row, mapped to its offset, then held
6430    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
6431    /// column belongs to the row below, and a gutter's column 0 points at the
6432    /// block rather than at this row.
6433    fn row_target(&self, r: usize, goal: usize) -> usize {
6434        let (start, end) = self.row_bounds(r);
6435        self.vmap
6436            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
6437            .clamp(start, end)
6438    }
6439
6440    /// The first and last offsets the caret can reach in the active view.
6441    ///
6442    /// Not the same span in both: the source view shows every byte, so it can
6443    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
6444    /// sits below the first stop, and a document's trailing newline is drawn
6445    /// nowhere and so sits past the last.
6446    fn reachable_start(&self) -> usize {
6447        match self.view {
6448            View::Source => 0,
6449            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
6450        }
6451    }
6452
6453    fn reachable_end(&self) -> usize {
6454        match self.view {
6455            View::Source => self.source.len(),
6456            View::Wysiwyg => self
6457                .vmap
6458                .stop_at_or_before(self.source.len())
6459                .unwrap_or(self.caret),
6460        }
6461    }
6462
6463    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
6464    /// including the space a soft wrap ate off its end, which is drawn on this
6465    /// row however much the offset past it belongs to the next one.
6466    fn row_span(&self, r: usize) -> (usize, usize) {
6467        let start = self
6468            .vmap
6469            .row_start(r)
6470            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
6471        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
6472        (start.min(end), end)
6473    }
6474
6475    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
6476    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
6477    /// this row's last position is the one before it — the offset before the
6478    /// space the wrap ate, where the caret draws just past the row's last word
6479    /// and types there too.
6480    ///
6481    /// Aiming at the shared offset instead is what stalled End: it is the row's
6482    /// last *column*, so End pressed on the row reached it and then read back as
6483    /// the row below's start, where a second press ran on to that row's end and
6484    /// the next to the one after — End walking down the paragraph a row a press.
6485    fn row_bounds(&self, r: usize) -> (usize, usize) {
6486        let (start, end) = self.row_span(r);
6487        let wraps = self
6488            .vmap
6489            .navigable_below(r)
6490            .and_then(|b| self.vmap.row_start(b))
6491            .is_some_and(|off| off == end);
6492        match wraps {
6493            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
6494            false => (start, end),
6495        }
6496    }
6497
6498    /// The `[start, end]` of the line Home and End aim at: the visual row in
6499    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
6500    ///
6501    /// A soft-wrapped row is a line here, because it is one to the eye and the
6502    /// eye is what these keys are aimed by — a reader pressing End means the end
6503    /// of the line they can see. (`select_block_at` wants the opposite and reads
6504    /// the AST for it: a triple-click grabs the whole paragraph, however many
6505    /// rows it folds into.)
6506    fn line_bounds(&self) -> (usize, usize) {
6507        let (row, _) = self.caret_pos();
6508        match self.view {
6509            View::Source => {
6510                let start = line_start(&self.source, row);
6511                (start, line_end_from(&self.source, start))
6512            }
6513            View::Wysiwyg => self.row_bounds(row),
6514        }
6515    }
6516
6517    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
6518    /// *drawn* — what a kill takes.
6519    ///
6520    /// The two part only at a soft wrap, over the space the wrap ate: the caret
6521    /// can't stand after it (that offset opens the row below, and End stopping
6522    /// there would walk), but it is on this row, and a kill that spared it would
6523    /// leave a double space behind where the row's text had been. Deleting it
6524    /// joins nothing — a wrap is drawn, not written.
6525    fn line_span(&self) -> (usize, usize) {
6526        let (row, _) = self.caret_pos();
6527        match self.view {
6528            View::Source => self.line_bounds(),
6529            View::Wysiwyg => self.row_span(row),
6530        }
6531    }
6532
6533    /// The first offset in `[start, end]` holding something other than
6534    /// whitespace, or `end` when the line holds nothing else — where Home aims.
6535    ///
6536    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
6537    /// so a hidden delimiter is never taken for the line's first character (nor
6538    /// landed on), and the source view steps the source it is showing.
6539    fn first_non_space(&self, start: usize, end: usize) -> usize {
6540        let mut off = start;
6541        while off < end {
6542            if self.class_at(off) != Class::Space {
6543                return off;
6544            }
6545            off = match self.view {
6546                View::Source => next_boundary(&self.source, off),
6547                View::Wysiwyg => match self.vmap.stop_after(off) {
6548                    Some(next) => next,
6549                    None => return end,
6550                },
6551            };
6552        }
6553        end
6554    }
6555
6556    /// Home: to the first character on the line, or to column 0 when the caret
6557    /// is already on it — the two-press toggle every editor spells this way.
6558    /// The indentation is somewhere the caret has to be able to reach and almost
6559    /// never where a reader is headed, so it costs the second press.
6560    pub fn move_home(&mut self, extend: bool) {
6561        self.goal_col = None;
6562        let (start, end) = self.line_bounds();
6563        let text = self.first_non_space(start, end);
6564        let target = if self.caret == text { start } else { text };
6565        let before = self.caret;
6566        self.move_to(target, extend);
6567        self.debug_assert_on_a_stop(before);
6568    }
6569
6570    /// End: to the end of the line.
6571    pub fn move_end(&mut self, extend: bool) {
6572        self.goal_col = None;
6573        let (_, end) = self.line_bounds();
6574        let before = self.caret;
6575        self.move_to(end, extend);
6576        self.debug_assert_on_a_stop(before);
6577    }
6578
6579    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
6580    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
6581    /// when the caret isn't in a table, or is already in the last/first cell — the
6582    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
6583    /// meaning everywhere else.
6584    pub fn cell_hop(&mut self, forward: bool) -> bool {
6585        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
6586            return false;
6587        };
6588        // Flatten to document (row-major) order and step one cell either way.
6589        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
6590        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
6591        let next = if forward {
6592            i.checked_add(1)
6593        } else {
6594            i.checked_sub(1)
6595        };
6596        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
6597            return false; // at the table's edge; leave Tab to the frontend
6598        };
6599        self.select_cell(start, end);
6600        true
6601    }
6602
6603    /// Move the caret to the cell directly above (`down == false`) or below in
6604    /// the same column, landing with the cell's whole content selected (see
6605    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
6606    /// when the caret isn't in a table), so the frontend can fall through — the
6607    /// vertical counterpart of [`Self::cell_hop`].
6608    ///
6609    /// A ragged row that is short a column clamps to its last cell, so Down never
6610    /// falls out of the table over a gap the row above happened to have.
6611    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
6612        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
6613            return false;
6614        };
6615        let target = match down {
6616            true => r + 1,
6617            false if r == 0 => return false,
6618            false => r - 1,
6619        };
6620        let Some(row) = grid.get(target) else {
6621            return false;
6622        };
6623        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
6624            return false;
6625        };
6626        self.select_cell(start, end);
6627        true
6628    }
6629
6630    /// The table containing `off` as a row-major grid of `(start, end)` cell
6631    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
6632    /// isn't in a table. Read straight off the visual map's laid-out grid, so
6633    /// every cell (an empty one included, whose derived home twig gives no
6634    /// `content_span` for) is present and in the order Tab walks them.
6635    // Grid, row, column — three returns that only ever travel together, and a
6636    // named type for the pair of them would be read at one call site.
6637    #[allow(clippy::type_complexity)]
6638    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
6639        for t in &self.vmap.tables {
6640            let mut pos = None;
6641            let grid: Vec<Vec<(usize, usize)>> = t
6642                .grid
6643                .iter()
6644                .enumerate()
6645                .map(|(r, row)| {
6646                    row.cells
6647                        .iter()
6648                        .enumerate()
6649                        .map(|(c, cell)| {
6650                            if pos.is_none() && off >= cell.start && off <= cell.end {
6651                                pos = Some((r, c));
6652                            }
6653                            (cell.start, cell.end)
6654                        })
6655                        .collect()
6656                })
6657                .collect();
6658            if let Some((r, c)) = pos {
6659                return Some((grid, r, c));
6660            }
6661        }
6662        None
6663    }
6664
6665    // ── table key policy ──────────────────────────────────────────────────────
6666    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
6667    // as one policy every frontend shares, rather than each re-deriving it. Each
6668    // reports whether it acted *as a table key*; a `false` hands the key back to
6669    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
6670    // everywhere else.
6671
6672    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
6673    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
6674    /// back and simply stays put at the very first cell. `false` when the caret
6675    /// isn't in a table.
6676    pub fn cell_tab(&mut self, forward: bool) -> bool {
6677        if !self.caret_in_table() {
6678            return false;
6679        }
6680        if self.cell_hop(forward) {
6681            return true;
6682        }
6683        // Off the last cell: grow the table by a row and step into its first
6684        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
6685        if forward {
6686            self.append_row_and_enter(0);
6687        }
6688        true
6689    }
6690
6691    /// Return inside a table: drop to the cell below in the same column,
6692    /// appending a new row when the caret is already in the last one. `false`
6693    /// when the caret isn't in a table, so the frontend inserts a newline.
6694    pub fn cell_return(&mut self) -> bool {
6695        if !self.caret_in_table() {
6696            return false;
6697        }
6698        if self.cell_move_vertical(true) {
6699            return true;
6700        }
6701        // Already on the last row: grow one below and drop into the same column.
6702        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
6703        self.append_row_and_enter(col);
6704        true
6705    }
6706
6707    /// Append a row below the caret's (last) row and land in `col` of it. The
6708    /// caret is in the last row, so twig's "insert below" makes the fresh row the
6709    /// table's new last — but twig re-spells the whole table, moving every byte,
6710    /// so the destination is read back from the rebuilt grid by the table's
6711    /// position (stable across a row insert), not from the pre-edit caret.
6712    fn append_row_and_enter(&mut self, col: usize) {
6713        let table = self.caret_table_index();
6714        self.table_insert_row(true);
6715        self.rebuild_map();
6716        let Some((start, end)) = table
6717            .and_then(|ti| self.vmap.tables.get(ti))
6718            .and_then(|t| t.grid.last())
6719            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
6720            .map(|cell| (cell.start, cell.end))
6721        else {
6722            return;
6723        };
6724        self.select_cell(start, end);
6725    }
6726
6727    /// The index, among the document's tables, of the one the caret sits in —
6728    /// `None` when it's in none. Used to re-find a table after an edit re-spells
6729    /// it (a row insert leaves the table order unchanged).
6730    fn caret_table_index(&self) -> Option<usize> {
6731        let off = self.caret;
6732        self.vmap.tables.iter().position(|t| {
6733            t.grid
6734                .iter()
6735                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
6736        })
6737    }
6738
6739    /// Shift+Return inside a table: insert a hard line break *within* the current
6740    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
6741    /// table, so the frontend inserts an ordinary line break.
6742    ///
6743    /// A table row is a single source line, so the newline-spelled hard break
6744    /// can't live in a cell. twig spells the in-cell break the format's way
6745    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
6746    /// break round-trips as structure the renderer reads back as a line — not the
6747    /// opaque raw HTML the old raw-splice left behind.
6748    ///
6749    /// Djot has no idiomatic in-cell break, so twig refuses it
6750    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
6751    /// would render as the literal text `<br>`. The gesture is still *consumed*
6752    /// there — returning `false` would let the frontend insert a real newline,
6753    /// which splits the one-line row — it just leaves the cell unchanged and says
6754    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
6755    ///
6756    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
6757    /// have to be read together: djot is not the only `false`, and naming it in
6758    /// the message was already a guess that HTML — which spells the break as its
6759    /// own `<br>` — would have made wrong.
6760    pub fn cell_line_break(&mut self) -> bool {
6761        if self.read_only || !self.caret_in_table() {
6762            return false;
6763        }
6764        self.record_caret();
6765        match self.editor.insert_line_break(self.caret) {
6766            Ok(change) => {
6767                self.last_edit_kind = None;
6768                self.refresh();
6769                self.caret = change.new.end;
6770                self.anchor = None;
6771                self.goal_col = None;
6772                self.clamp_caret();
6773                self.dirty = self.source != self.clean_source;
6774                self.status = None;
6775                self.record_caret();
6776            }
6777            Err(twig::Error::UnsupportedFormat) => {
6778                self.status = Some(format!(
6779                    "in-cell line breaks aren't supported in {}",
6780                    self.format_name()
6781                ));
6782            }
6783            Err(_) => {}
6784        }
6785        true
6786    }
6787
6788    /// Rebuild the visual map at the width the last build used. A structural edit
6789    /// bumps the revision and swaps the source in, but leaves the *map* stale;
6790    /// when a single gesture edits and then moves over the result (Tab appending
6791    /// a row, then stepping into it), the move needs the map to already show the
6792    /// edit rather than waiting for the frontend's next frame.
6793    fn rebuild_map(&mut self) {
6794        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
6795        self.build_map(wrap);
6796    }
6797
6798    /// Move the caret to the very start of the document (⌘↑ on macOS,
6799    /// Ctrl+Home on Windows/Linux).
6800    pub fn move_doc_start(&mut self, extend: bool) {
6801        self.goal_col = None;
6802        self.move_to(0, extend);
6803    }
6804
6805    /// Move the caret to the very end of the document (⌘↓ on macOS,
6806    /// Ctrl+End on Windows/Linux).
6807    pub fn move_doc_end(&mut self, extend: bool) {
6808        self.goal_col = None;
6809        let end = self.source.len();
6810        self.move_to(end, extend);
6811    }
6812
6813    /// Point the caret at the body cell `(row, col)` the mouse landed on —
6814    /// `col` being a cell of the terminal grid, which is what a display column
6815    /// is. A click on the far cell of a wide character lands at that
6816    /// character's start; the mapping's own doc-comments carry the rule.
6817    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
6818        self.goal_col = None;
6819        let target = match self.view {
6820            View::Source => row_col_to_offset(&self.source, row, col),
6821            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
6822        };
6823        let before = self.caret;
6824        self.move_to(target, extend);
6825        self.debug_assert_on_a_stop(before);
6826    }
6827
6828    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
6829    /// screen if it has moved since the last frame, and never scroll past the
6830    /// last of `rows`.
6831    ///
6832    /// Only if it has *moved* — that's the whole point. Revealing the caret on
6833    /// every frame ties the viewport to it, and a scroll wheel that fights the
6834    /// caret for the viewport loses: the view snaps back the instant it tries to
6835    /// pass the caret's row, so the document can't be scrolled beyond what's
6836    /// already on screen. A caret move is the frontend's cue to follow; a scroll
6837    /// with the caret sitting still is the reader's cue to leave it alone.
6838    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
6839        if self.drawn_caret != Some(self.caret) {
6840            if caret_row < self.scroll {
6841                self.scroll = caret_row;
6842            } else if height > 0 && caret_row >= self.scroll + height {
6843                self.scroll = caret_row + 1 - height;
6844            }
6845            self.drawn_caret = Some(self.caret);
6846        }
6847        self.scroll = self.scroll.min(rows.saturating_sub(1));
6848    }
6849
6850    /// The caret's screen position `(row, col)` in the active view's grid, with
6851    /// `col` a display column: the cell to draw the caret in, which on a line of
6852    /// `你好` or emoji is not the count of characters before it.
6853    pub fn caret_pos(&self) -> (usize, usize) {
6854        match self.view {
6855            View::Source => offset_to_row_col(&self.source, self.caret),
6856            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
6857        }
6858    }
6859
6860    fn clamp_caret(&mut self) {
6861        if self.caret > self.source.len() {
6862            self.caret = self.source.len();
6863        }
6864        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
6865        // any selection anchor) to the first rendered offset.
6866        let floor = self.caret_floor();
6867        if self.caret < floor {
6868            self.caret = floor;
6869        }
6870        if let Some(a) = self.anchor
6871            && a < floor
6872        {
6873            self.anchor = Some(floor);
6874        }
6875        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
6876            self.caret -= 1;
6877        }
6878    }
6879}
6880
6881// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
6882
6883// Left/right motion and backspace/delete step by *grapheme cluster*, not
6884// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
6885// marks moves and deletes as the single character a user sees. Grapheme
6886// boundaries are a superset of char boundaries, so the caret stays valid for twig.
6887
6888/// How an insert of `text` groups for undo: a single typed character folds into
6889/// the run of typing around it, while a newline or a multi-character insert is a
6890/// step of its own.
6891fn typed_edit_kind(text: &str) -> EditKind {
6892    if text.chars().take(2).count() == 1 && text != "\n" {
6893        EditKind::Insert
6894    } else {
6895        EditKind::Other
6896    }
6897}
6898
6899fn prev_boundary(s: &str, i: usize) -> usize {
6900    let mut cursor = GraphemeCursor::new(i, s.len(), true);
6901    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
6902}
6903
6904fn next_boundary(s: &str, i: usize) -> usize {
6905    let mut cursor = GraphemeCursor::new(i, s.len(), true);
6906    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
6907}
6908
6909// ── word boundaries ──────────────────────────────────────────────────────────
6910// The shared primitive behind word-wise motion, word deletion, and
6911// double-click-to-select-a-word. A "word" is a maximal run of one character
6912// class; whitespace and punctuation are their own classes, so motion skips
6913// cleanly between them the way native text fields do.
6914
6915#[derive(PartialEq, Eq, Clone, Copy)]
6916enum Class {
6917    Word,
6918    Space,
6919    Other,
6920}
6921
6922/// The source range of an inline node's own visible text — the part of it a
6923/// WYSIWYG caret can reach, as against the delimiters that only spell it.
6924/// `None` for a node with no interior to empty (a `str`, a break).
6925///
6926/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
6927/// one delimiter in from the span — the same place the renderer maps it to. A
6928/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
6929/// against the source rather than trusted: a range guessed wrong here is text
6930/// deleted wrong.
6931fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
6932    if let Some(span) = n.content_span.clone() {
6933        return Some(span);
6934    }
6935    match n.kind.as_str() {
6936        "verbatim" | "inline_math" => {
6937            let text = n.text.as_ref()?;
6938            let start = n.span.start + 1;
6939            let range = start..start + text.len();
6940            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
6941        }
6942        _ => None,
6943    }
6944}
6945
6946/// The `id` a node declares, or `None` for one that declares none — the
6947/// attribute djot writes for a `{#v1}` and mints for a heading.
6948///
6949/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
6950/// names nothing, so it reads as absent rather than as the empty string.
6951fn declared_id(n: &FlatNode) -> Option<&str> {
6952    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
6953}
6954
6955/// A heading's words reduced to the form a link fragment spells them in:
6956/// lowercase, runs of anything else collapsed to a single `-`, with none left
6957/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
6958///
6959/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
6960/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
6961/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
6962/// any other language is still a heading someone will link to. Underscores
6963/// survive for the same reason they do on the web: they are word characters
6964/// wherever identifiers are written.
6965fn slug(text: &str) -> String {
6966    let mut out = String::new();
6967    let mut pending = false;
6968    for c in text.chars() {
6969        if c.is_alphanumeric() || c == '_' {
6970            if pending && !out.is_empty() {
6971                out.push('-');
6972            }
6973            pending = false;
6974            out.extend(c.to_lowercase());
6975        } else {
6976            pending = true;
6977        }
6978    }
6979    out
6980}
6981
6982fn is_block_container(kind: &Kind) -> bool {
6983    matches!(
6984        kind,
6985        Kind::Doc
6986            | Kind::Section
6987            | Kind::BlockQuote
6988            | Kind::BulletList
6989            | Kind::OrderedList
6990            | Kind::TaskList
6991            | Kind::ListItem
6992            | Kind::TaskListItem
6993            // Every `container` — a directive in any of its three forms, or a
6994            // promoted HTML element. A *text* directive is really inline, so
6995            // claiming it here is a small overreach, and the deliberate one this
6996            // function's kind-only peer `is_inline_kind` documents: the pair is
6997            // consulted together, and answering "block container" for something
6998            // inline is what keeps an ancestor walk from stopping short of the
6999            // paragraph that actually holds it.
7000            | Kind::Container
7001    )
7002}
7003
7004/// The `[start, end)` byte range of the source line containing `off` (newline
7005/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
7006/// line between paragraphs).
7007fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
7008    let off = off.min(s.len());
7009    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
7010    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
7011    start..end
7012}
7013
7014/// How many leading bytes an outdent takes off `line`: a whole indent level
7015/// where the line has one, and whatever it has where it has less.
7016///
7017/// A leading tab counts as a level on its own. It's indentation some other
7018/// editor wrote, and one tab is one level everywhere it came from — measuring it
7019/// in spaces it doesn't contain would leave it untouchable.
7020fn outdent_width(line: &str, unit: usize) -> usize {
7021    if line.starts_with('\t') {
7022        return 1;
7023    }
7024    line.bytes().take(unit).take_while(|b| *b == b' ').count()
7025}
7026
7027/// A list marker found at the head of a line, together with everything before it
7028/// that a sibling line has to repeat.
7029///
7030/// The three offsets differ only inside a block quote, where `>   - b` opens with
7031/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
7032/// `line_start == marker_start`, and `text` is the plain `"  - "`.
7033#[derive(Clone, Debug)]
7034struct ListMarker {
7035    /// The line's first byte.
7036    line_start: usize,
7037    /// Where the marker proper begins, past any quote prefix. The offset to hand
7038    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
7039    marker_start: usize,
7040    /// `line_start` through the marker's trailing space — quote prefix, indent
7041    /// and bullet together, which is what the next item's line opens with.
7042    text: String,
7043}
7044
7045impl ListMarker {
7046    /// Where the item's content starts — one past the marker's trailing space.
7047    fn content_start(&self) -> usize {
7048        self.line_start + self.text.len()
7049    }
7050}
7051
7052fn classify(c: char) -> Class {
7053    if c == '_' || c.is_alphanumeric() {
7054        Class::Word
7055    } else if c.is_whitespace() {
7056        Class::Space
7057    } else {
7058        Class::Other
7059    }
7060}
7061
7062/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
7063/// skip any leading separators, then consume the following word run.
7064fn next_word(s: &str, i: usize) -> usize {
7065    let mut off = i;
7066    let mut in_word = false;
7067    for c in s[i..].chars() {
7068        if classify(c) == Class::Word {
7069            in_word = true;
7070        } else if in_word {
7071            break;
7072        }
7073        off += c.len_utf8();
7074    }
7075    off
7076}
7077
7078/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
7079/// skip separators walking left, then consume the preceding word run.
7080fn prev_word(s: &str, i: usize) -> usize {
7081    let mut off = i;
7082    let mut in_word = false;
7083    for c in s[..i].chars().rev() {
7084        if classify(c) == Class::Word {
7085            in_word = true;
7086        } else if in_word {
7087            break;
7088        }
7089        off -= c.len_utf8();
7090    }
7091    off
7092}
7093
7094/// The `[start, end)` run of same-class characters surrounding `off` — the
7095/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
7096/// the run ending there is used.
7097fn word_range_at(s: &str, off: usize) -> (usize, usize) {
7098    if s.is_empty() {
7099        return (0, 0);
7100    }
7101    let off = off.min(s.len());
7102    let reference = if off < s.len() {
7103        s[off..].chars().next()
7104    } else {
7105        s[..off].chars().next_back()
7106    };
7107    let Some(rc) = reference else {
7108        return (off, off);
7109    };
7110    let class = classify(rc);
7111
7112    let mut start = off;
7113    for c in s[..start].chars().rev() {
7114        if classify(c) == class {
7115            start -= c.len_utf8();
7116        } else {
7117            break;
7118        }
7119    }
7120    let mut end = off;
7121    for c in s[end..].chars() {
7122        if classify(c) == class {
7123            end += c.len_utf8();
7124        } else {
7125            break;
7126        }
7127    }
7128    (start, end)
7129}
7130
7131/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
7132/// the line's start — terminal cells, not characters, so the column names the
7133/// cell the caret is drawn in even on a line of `你好` or emoji.
7134fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
7135    let off = off.min(s.len());
7136    let mut row = 0;
7137    let mut line_start = 0;
7138    for (i, &b) in s.as_bytes().iter().enumerate() {
7139        if i >= off {
7140            break;
7141        }
7142        if b == b'\n' {
7143            row += 1;
7144            line_start = i + 1;
7145        }
7146    }
7147    (row, wysiwyg::text_width(&s[line_start..off]))
7148}
7149
7150/// The byte offset at display column `col` of `row` (clamped to that line's
7151/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
7152///
7153/// A column landing *inside* a character — the second cell of `你`, or any cell
7154/// but the first of an emoji — resolves to that character's start, which is the
7155/// column the caret would have been drawn at to begin with. So both cells of a
7156/// wide character mean the character, and every offset survives the round trip
7157/// out to a column and back. The walk steps by grapheme cluster for the same
7158/// reason the caret does: a cluster is the character, and the cells belong to it
7159/// rather than to the codepoints spelling it.
7160fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
7161    let start = line_start(s, row);
7162    let end = line_end_from(s, start);
7163    let mut off = start;
7164    let mut at = 0; // the display column `off` sits at
7165    while off < end {
7166        let next = next_boundary(s, off).min(end);
7167        let cells = wysiwyg::text_width(&s[off..next]);
7168        if at + cells > col {
7169            break; // `col` is one of this cluster's own cells
7170        }
7171        at += cells;
7172        off = next;
7173    }
7174    off
7175}
7176
7177fn line_start(s: &str, row: usize) -> usize {
7178    if row == 0 {
7179        return 0;
7180    }
7181    let mut r = 0;
7182    for (i, &b) in s.as_bytes().iter().enumerate() {
7183        if b == b'\n' {
7184            r += 1;
7185            if r == row {
7186                return i + 1;
7187            }
7188        }
7189    }
7190    s.len()
7191}
7192
7193fn line_end_from(s: &str, start: usize) -> usize {
7194    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
7195}
7196
7197/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
7198/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
7199/// twig applies writing the mark out, so the toolbar can light the same button
7200/// that made the node.
7201///
7202/// `None` for every other kind, including the inline nodes that aren't marks at
7203/// all (`str`, `link`, `image`, the math and break kinds): they're things a
7204/// caret stands in, not formatting a button toggles.
7205fn inline_kind(kind: &Kind) -> Option<InlineKind> {
7206    Some(match kind {
7207        Kind::Strong => InlineKind::Strong,
7208        Kind::Emph => InlineKind::Emph,
7209        Kind::Verbatim => InlineKind::Verbatim,
7210        Kind::Mark => InlineKind::Mark,
7211        Kind::Superscript => InlineKind::Superscript,
7212        Kind::Subscript => InlineKind::Subscript,
7213        Kind::Insert => InlineKind::Insert,
7214        Kind::Delete => InlineKind::Delete,
7215        _ => return None,
7216    })
7217}
7218
7219/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
7220/// a highlight's opening `==`.
7221///
7222/// Two enums for one closed vocabulary, and the duplication is the boundary
7223/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
7224/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
7225/// the editor writes. Spelled as a match rather than routed through the two
7226/// crates' name strings so that a colour added on either side is a compile
7227/// error here, where the pairing is decided, rather than a runtime `None` that
7228/// would read as "clear the colour".
7229fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
7230    match color {
7231        MarkColor::Red => twig::MarkColor::Red,
7232        MarkColor::Orange => twig::MarkColor::Orange,
7233        MarkColor::Yellow => twig::MarkColor::Yellow,
7234        MarkColor::Green => twig::MarkColor::Green,
7235        MarkColor::Blue => twig::MarkColor::Blue,
7236        MarkColor::Purple => twig::MarkColor::Purple,
7237        MarkColor::Brown => twig::MarkColor::Brown,
7238    }
7239}
7240
7241/// Where an offset lands after a splice it didn't make — twig's own rule, from
7242/// [`Change`]: shift anything at or past the replaced range's end by the length
7243/// the replacement gained or lost, and leave anything before it alone.
7244///
7245/// An offset *inside* the replaced range has no text of its own to ride any
7246/// more, and lands at the end of what replaced it: for
7247/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
7248/// the prefix is cleared, which then sits where the highlighted text begins.
7249/// One node's attribute list, twig's own `(key, value)` pairs owned — what
7250/// every presentation gesture reads, edits one key of, and passes back whole.
7251type Attrs = Vec<(String, Option<String>)>;
7252
7253/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
7254/// writes it and the walker draws it, and a frontend that paginates matches a
7255/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
7256/// stated once.
7257pub const PAGE_BREAK: &str = "page-break";
7258
7259/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
7260/// every other attribute kept in its place — the read-edit-write half of twig's
7261/// replace-not-merge contract for a `data-` key.
7262///
7263/// **A key that is already there is rewritten where it stands**, and only a key
7264/// the node did not have goes on the end. That is what makes the proposal's
7265/// worked example true: `class="lead center" id="intro"
7266/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
7267/// data-line-height="1.5"` — the same document with one token changed, and a
7268/// one-line diff. Removing the key and pushing it back would reorder the
7269/// author's attributes on every press, so a document that passed through the
7270/// editor came out shuffled even where nothing about it had changed.
7271///
7272/// A duplicate key — which no format leaf opens can spell, but twig reports
7273/// verbatim — collapses onto the first of its copies, since twig is handed one
7274/// value for one key either way.
7275fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
7276    let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
7277    let mut written = false;
7278    for (k, v) in attrs {
7279        if k != key {
7280            out.push((k.clone(), v.clone()));
7281            continue;
7282        }
7283        if let Some(new) = value.filter(|_| !written) {
7284            out.push((k.clone(), Some(new.to_string())));
7285            written = true;
7286        }
7287    }
7288    if let Some(new) = value.filter(|_| !written) {
7289        out.push((key.to_string(), Some(new.to_string())));
7290    }
7291    out
7292}
7293
7294/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
7295/// `token` added, with the rest of the list kept in order.
7296///
7297/// `class` is a space-separated token list, and leaf owns three of the tokens in
7298/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
7299/// goes out as `class="lead right"`; one whose last owned token goes and which
7300/// carried nothing else loses the key, so a block that has lost its whole
7301/// vocabulary is spelled bare again. `class` itself keeps its place among the
7302/// attributes, because [`with_attr`] does the writing.
7303fn with_class_token(
7304    attrs: &[(String, Option<String>)],
7305    mine: impl Fn(&str) -> bool,
7306    token: Option<&str>,
7307) -> Attrs {
7308    let kept: Vec<&str> = attrs
7309        .iter()
7310        .find(|(k, _)| k == "class")
7311        .and_then(|(_, v)| v.as_deref())
7312        .unwrap_or_default()
7313        .split_whitespace()
7314        .filter(|t| !mine(t))
7315        .collect();
7316    let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
7317    with_attr(
7318        attrs,
7319        "class",
7320        (!class.is_empty()).then_some(class.as_str()),
7321    )
7322}
7323
7324/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
7325///
7326/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
7327/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
7328/// (djot has no bare attribute, so no format reads one back everywhere), and
7329/// `hidden=""` is the same document where `hidden` is; dropping it instead
7330/// would lose what the author wrote, which is the one thing these gestures
7331/// promise not to do.
7332fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
7333    attrs
7334        .iter()
7335        .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
7336        .collect()
7337}
7338
7339fn reanchor(off: usize, change: &Change) -> usize {
7340    if off < change.old.start {
7341        return off;
7342    }
7343    if off < change.old.end {
7344        return change.new.end;
7345    }
7346    (off + change.new.end).saturating_sub(change.old.end)
7347}
7348
7349/// [`reanchor`] for an edit that respells the markup *around* a block and
7350/// leaves the block's own bytes alone — which is every attribute gesture.
7351///
7352/// `block` is that block's content span before and after the splice, so an
7353/// offset standing in the text keeps its distance from the text's start and how
7354/// many bytes twig wrote above it never enters the arithmetic. That is the whole
7355/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
7356/// on the same div lengthens the attribute line, clearing the last one takes the
7357/// div away entirely, and both are the same sum. `None` where the splice named
7358/// no block at either end, which is every djot case — the `{…}` line is written
7359/// above the block, and the block itself only shifts past it.
7360///
7361/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
7362/// shifted by its delta after it, and at the splice's end for an offset that
7363/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
7364/// text to keep.
7365fn reanchor_in_block(
7366    off: usize,
7367    change: &Change,
7368    block: Option<(&Range<usize>, &Range<usize>)>,
7369) -> usize {
7370    if let Some((was, now)) = block
7371        && was.start <= off
7372        && off <= was.end
7373    {
7374        return now.start + (off - was.start).min(now.end - now.start);
7375    }
7376    reanchor(off, change)
7377}
7378
7379/// A watermark for a file's contents (see `Doc::disk_hash`).
7380///
7381/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
7382/// watermark is compared only against one taken by the same process moments
7383/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
7384/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
7385fn hash_bytes(bytes: &[u8]) -> u64 {
7386    use std::hash::{Hash, Hasher};
7387    let mut h = std::collections::hash_map::DefaultHasher::new();
7388    bytes.hash(&mut h);
7389    h.finish()
7390}
7391
7392#[cfg(feature = "fs")]
7393fn detect_format(path: &Path) -> Result<Format> {
7394    let ext = path
7395        .extension()
7396        .and_then(|e| e.to_str())
7397        .unwrap_or("")
7398        .to_ascii_lowercase();
7399    Ok(match ext.as_str() {
7400        "dj" | "djot" => Format::Djot,
7401        "md" | "markdown" => Format::Markdown,
7402        "xml" => Format::Xml,
7403        "html" | "htm" => Format::Html,
7404        other => return Err(anyhow!("unknown document extension: .{other}")),
7405    })
7406}
7407
7408#[cfg(test)]
7409mod tests {
7410    use super::*;
7411
7412    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
7413    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
7414    /// without one is a view no user is ever in.
7415    fn doc_in(view: View, name: &str, body: &str) -> Doc {
7416        // The fixture name doubles as the temp file's, so two tests picking the
7417        // same one raced under the parallel runner and read each other's body —
7418        // a green suite proving the wrong thing. The counter makes that
7419        // unreachable rather than asking every future caller to notice.
7420        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7421        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7422        let mut p = std::env::temp_dir();
7423        p.push(format!("leaf_test_{name}_{seq}.md"));
7424        std::fs::write(&p, body).unwrap();
7425        let mut d = Doc::open(p).unwrap();
7426        d.view = view;
7427        if view == View::Wysiwyg {
7428            d.build_visual(80);
7429        }
7430        d
7431    }
7432
7433    // Source-view document for the source-behaviour tests. `Doc::open` now
7434    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
7435    // `wysiwyg_doc` builds the rich-text variant on top of this.
7436    fn doc_with(name: &str, body: &str) -> Doc {
7437        doc_in(View::Source, name, body)
7438    }
7439
7440    /// Every visual row's drawn text — what the reader actually sees, which is
7441    /// the only thing the reveal preference is supposed to change.
7442    fn drawn_rows(d: &Doc) -> Vec<String> {
7443        d.vmap
7444            .rows
7445            .iter()
7446            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7447            .collect()
7448    }
7449
7450    /// Put the caret at the first byte of `needle` and rebuild, so the row under
7451    /// it becomes the revealed line.
7452    fn caret_at(d: &mut Doc, needle: &str) {
7453        d.caret = d.source.find(needle).expect("needle in source");
7454        d.build_visual(80);
7455    }
7456
7457    #[test]
7458    fn blockquote_after_a_list_is_not_bulleted() {
7459        // twig nests a following top-level block quote under the `bullet_list`
7460        // (a direct child, not a `list_item`). The map must render it de-nested —
7461        // `│ quote`, never `• │ quote` — with a blank separator, like any block
7462        // that follows a list. Regression for the "combined list + blockquote" bug.
7463        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
7464        d.build_visual(80);
7465        let rows: Vec<String> = d
7466            .vmap
7467            .rows
7468            .iter()
7469            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7470            .collect();
7471        assert!(
7472            rows.iter().any(|r| r == "│ quote"),
7473            "block quote should render on its own gutter, got rows: {rows:?}"
7474        );
7475        assert!(
7476            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
7477            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
7478        );
7479    }
7480
7481    // ── the map is built at most once per (revision, wrap) ───────────────────
7482    //
7483    // A frontend repaints for reasons that have nothing to do with the text — a
7484    // blinking caret, a scroll — and rebuilding the map is O(document). These
7485    // pin *that the cache fires*, which a passing suite can't tell you: a cache
7486    // that never hits is invisible to every other test in this file.
7487    //
7488    // The probe is to wreck the built map and ask for it again. A rebuild
7489    // repairs it; a cache hit hands the wreckage straight back. Nothing else
7490    // can distinguish the two from outside.
7491
7492    #[test]
7493    fn a_rebuild_with_nothing_changed_reuses_the_map() {
7494        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
7495        d.build_visual(80);
7496        assert!(!d.vmap.rows.is_empty());
7497        d.vmap.rows.clear(); // wreck it
7498        d.build_visual(80);
7499        assert!(
7500            d.vmap.rows.is_empty(),
7501            "the map was rebuilt though nothing changed — the cache never fired"
7502        );
7503    }
7504
7505    #[test]
7506    fn an_edit_rebuilds_the_map() {
7507        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
7508        d.build_visual(80);
7509        let before = d.revision();
7510        d.vmap.rows.clear();
7511        d.insert("x");
7512        d.build_visual(80);
7513        assert!(d.revision() > before, "an edit must move the revision");
7514        assert!(
7515            !d.vmap.rows.is_empty(),
7516            "an edited document must not paint from a stale map"
7517        );
7518    }
7519
7520    #[test]
7521    fn a_width_change_rebuilds_the_map() {
7522        // The map is a function of the wrap width too, so a resize is a miss
7523        // even though the text is untouched.
7524        let mut d = doc_in(
7525            View::Wysiwyg,
7526            "cache_width",
7527            "one two three four five six\n",
7528        );
7529        d.build_visual(80);
7530        d.vmap.rows.clear();
7531        d.build_visual(12);
7532        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
7533        // And the unwrapped map is its own key, not the same as any width.
7534        d.vmap.rows.clear();
7535        d.build_visual_unwrapped();
7536        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
7537    }
7538
7539    #[test]
7540    fn a_motion_does_not_rebuild_the_map() {
7541        // The whole point: moving the caret changes nothing the map is built
7542        // from. If a motion bumped the revision, every arrow key would cost a
7543        // full rebuild and the cache would be worthless.
7544        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
7545        d.build_visual(80);
7546        let rev = d.revision();
7547        d.move_right(false);
7548        d.move_right(true);
7549        d.move_down(false);
7550        assert_eq!(d.revision(), rev, "a motion must not move the revision");
7551        d.vmap.rows.clear();
7552        d.build_visual(80);
7553        assert!(
7554            d.vmap.rows.is_empty(),
7555            "a motion should not rebuild the map"
7556        );
7557    }
7558
7559    #[test]
7560    fn saving_does_not_rebuild_the_map() {
7561        // Saving changes `dirty`, not the text.
7562        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
7563        d.insert("x");
7564        d.build_visual(80);
7565        let rev = d.revision();
7566        d.save();
7567        assert_eq!(d.revision(), rev, "a save must not move the revision");
7568        assert!(!d.dirty, "the save should have cleaned the document");
7569    }
7570
7571    #[test]
7572    fn a_reload_rebuilds_the_map() {
7573        // Reload replaces the text without going through `refresh`, so it has to
7574        // move the revision itself — else the editor paints the old file.
7575        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
7576        d.build_visual(80);
7577        let rev = d.revision();
7578        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
7579        d.reload();
7580        assert!(d.revision() > rev, "a reload must move the revision");
7581        d.build_visual(80);
7582        let text: String = d
7583            .vmap
7584            .rows
7585            .iter()
7586            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
7587            .collect();
7588        assert!(
7589            text.contains("wholly new"),
7590            "the reloaded text should be on screen, got {text:?}"
7591        );
7592    }
7593
7594    // ── golden-case harness ──────────────────────────────────────────────────
7595    // The pattern the whole parity suite can reuse: write a fixture with the
7596    // caret marked by `|`, run one action, and compare the rendered result —
7597    // also caret-marked — against the expected string. One readable line per
7598    // behavior, and it exercises the exact `Doc` ops both frontends call.
7599
7600    /// Split a `|`-marked fixture into `(source, caret_offset)`.
7601    fn parse_caret(marked: &str) -> (String, usize) {
7602        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
7603        (marked.replacen('|', "", 1), caret)
7604    }
7605
7606    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
7607    /// selection) so a result reads like the fixtures.
7608    fn render_caret(d: &Doc) -> String {
7609        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
7610        // so the caret always renders inside its own selection.
7611        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
7612        if let Some((s, e)) = d.selection() {
7613            marks.push((s, 0, '['));
7614            marks.push((e, 2, ']'));
7615        }
7616        // Insert right-to-left: descending offset, then descending rank.
7617        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
7618        let mut out = d.source.clone();
7619        for (at, _, ch) in marks {
7620            out.insert(at, ch);
7621        }
7622        out
7623    }
7624
7625    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
7626    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
7627        golden_in(View::Source, name, marked, action)
7628    }
7629
7630    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
7631    /// the same fixture has to read the same way in both.
7632    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
7633        let (src, caret) = parse_caret(marked);
7634        let mut d = doc_in(view, name, &src);
7635        d.caret = caret;
7636        action(&mut d);
7637        render_caret(&d)
7638    }
7639
7640    #[test]
7641    fn word_motion_walks_word_by_word() {
7642        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
7643        assert_eq!(
7644            g("hello wor|ld", |d| d.move_word_left(false)),
7645            "hello |world"
7646        );
7647        assert_eq!(
7648            g("hello| world", |d| d.move_word_left(false)),
7649            "|hello world"
7650        );
7651        assert_eq!(
7652            g("hel|lo world", |d| d.move_word_right(false)),
7653            "hello| world"
7654        );
7655        assert_eq!(
7656            g("hello| world", |d| d.move_word_right(false)),
7657            "hello world|"
7658        );
7659        // Punctuation is its own class, so motion stops at the boundary.
7660        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
7661    }
7662
7663    #[test]
7664    fn word_motion_extends_the_selection_when_asked() {
7665        assert_eq!(
7666            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
7667            "hello [world|]"
7668        );
7669    }
7670
7671    #[test]
7672    fn delete_word_removes_a_whole_word() {
7673        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
7674        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
7675        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
7676        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
7677    }
7678
7679    // ── Home / End ───────────────────────────────────────────────────────────
7680
7681    #[test]
7682    fn home_toggles_between_the_line_s_text_and_its_margin() {
7683        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
7684        // indent to the markup it spells everywhere it means one, so the fixture
7685        // with whitespace left to walk is a code block, which is verbatim.
7686        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
7687        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
7688        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
7689        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
7690        // A line with no indentation has one place to go, so the toggle is a
7691        // no-op rather than a trip to nowhere.
7692        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
7693        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
7694
7695        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
7696        let indent = d.source.find("    indented").unwrap();
7697        d.caret = indent + 6; // inside "indented"
7698        d.move_home(false);
7699        assert_eq!(
7700            d.caret,
7701            indent + 4,
7702            "wysiwyg: Home aims at the code line's text"
7703        );
7704        d.move_home(false);
7705        assert_eq!(
7706            d.caret, indent,
7707            "wysiwyg: the second press takes the indent"
7708        );
7709        d.move_home(false);
7710        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
7711    }
7712
7713    #[test]
7714    fn end_takes_the_line_the_view_is_showing() {
7715        // The line differs by view for the same document, and that is the point:
7716        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
7717        // as a space on one row and the source view as two lines.
7718        let mut d = doc_with("end_src", "one two\nthree\n");
7719        d.caret = 1;
7720        d.move_end(false);
7721        assert_eq!(d.caret, 7, "source: the end of the source line");
7722
7723        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
7724        d.caret = 1;
7725        d.move_end(false);
7726        assert_eq!(
7727            d.caret, 13,
7728            "wysiwyg: the end of the row, soft break and all"
7729        );
7730    }
7731
7732    #[test]
7733    fn home_and_end_extend_the_selection_when_asked() {
7734        for (view, tag) in VIEWS {
7735            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
7736            d.caret = 6;
7737            d.move_end(true);
7738            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
7739            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
7740            d.caret = 6;
7741            d.move_home(true);
7742            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
7743        }
7744    }
7745
7746    // ── kill to the line's start / end ───────────────────────────────────────
7747
7748    #[test]
7749    fn kill_to_the_line_start_and_end_in_both_views() {
7750        for (view, tag) in VIEWS {
7751            // The gap that reads as a paragraph break in each view: the source
7752            // view's lines are the renderer's rows only where the source says so.
7753            let gap = if view == View::Source { "\n" } else { "\n\n" };
7754            let mut d = doc_in(
7755                view,
7756                &format!("kill_end_{tag}"),
7757                &format!("one two{gap}three\n"),
7758            );
7759            d.caret = 3;
7760            d.delete_to_line_end();
7761            assert_eq!(
7762                d.source,
7763                format!("one{gap}three\n"),
7764                "{tag}: ^K to the line's end"
7765            );
7766            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
7767
7768            let mut d = doc_in(
7769                view,
7770                &format!("kill_start_{tag}"),
7771                &format!("one two{gap}three\n"),
7772            );
7773            d.caret = 7; // the end of the first line
7774            d.delete_to_line_start();
7775            assert_eq!(
7776                d.source,
7777                format!("{gap}three\n"),
7778                "{tag}: ⌘⌫ to the line's start"
7779            );
7780            assert_eq!(d.caret, 0, "{tag}");
7781        }
7782    }
7783
7784    #[test]
7785    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
7786        // The decision: at the boundary both kills do nothing, rather than
7787        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
7788        // at a soft wrap as often as at a newline, where there is nothing
7789        // written to delete — and a source newline is only half of the blank
7790        // line between two paragraphs, so taking it leaves a soft break rather
7791        // than the join it looks like. Backspace and Delete are the keys for it.
7792        for (view, tag) in VIEWS {
7793            let gap = if view == View::Source { "\n" } else { "\n\n" };
7794            let src = format!("one{gap}three\n");
7795            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
7796            d.caret = 3; // the end of "one"
7797            d.delete_to_line_end();
7798            assert_eq!(
7799                d.source, src,
7800                "{tag}: ^K at the line's end joined it to the next"
7801            );
7802
7803            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
7804            d.caret = 3 + gap.len(); // the start of "three"
7805            d.delete_to_line_start();
7806            assert_eq!(
7807                d.source, src,
7808                "{tag}: ⌘⌫ at the line's start joined it to the last"
7809            );
7810        }
7811    }
7812
7813    #[test]
7814    fn a_kill_takes_the_selection_when_there_is_one() {
7815        // What every other delete here does with one, so these two as well.
7816        for (view, tag) in VIEWS {
7817            for (name, kill) in [
7818                (
7819                    "end",
7820                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
7821                ),
7822                ("start", |d: &mut Doc| d.delete_to_line_start()),
7823            ] {
7824                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
7825                d.anchor = Some(4);
7826                d.caret = 7; // "two"
7827                kill(&mut d);
7828                assert_eq!(
7829                    d.source, "one  three\n",
7830                    "{tag}: {name} ignored the selection"
7831                );
7832                assert_eq!(d.selection(), None, "{tag}: {name}");
7833            }
7834        }
7835    }
7836
7837    #[test]
7838    fn a_kill_takes_the_markup_it_empties_with_it() {
7839        // The same hazard a word-delete has: a WYSIWYG range covers what the
7840        // user can see, which for `**bold**` is the word and never the
7841        // delimiters, so a kill that stopped at the text would leave `a ****` —
7842        // markup wrapped around nothing.
7843        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
7844        d.caret = d.source.find("bold").unwrap();
7845        d.delete_to_line_end();
7846        assert_eq!(d.source, "a \n");
7847    }
7848
7849    #[test]
7850    fn a_kill_is_undone_in_one_step() {
7851        for (view, tag) in VIEWS {
7852            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
7853            d.caret = 3;
7854            d.delete_to_line_end();
7855            assert_eq!(d.source, "one\n", "{tag}");
7856            d.undo();
7857            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
7858        }
7859    }
7860
7861    #[test]
7862    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
7863        // Regression: triple-click used move_home/move_end over visual rows, so
7864        // it only worked on a paragraph's first row (a wrap-boundary offset maps
7865        // to the earlier row). select_block_at reads the AST, so every offset in
7866        // the paragraph selects the whole thing.
7867        let body = "one two three four five six seven eight\n";
7868        let mut d = doc_with("sel_block", body);
7869        d.view = View::Wysiwyg;
7870        d.build_visual(12); // force the paragraph to wrap into several rows
7871        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
7872        let para = (0, "one two three four five six seven eight".len());
7873        for off in [0usize, 8, 19, 28, 38] {
7874            d.caret = 0;
7875            d.anchor = None;
7876            d.select_block_at(off);
7877            assert_eq!(
7878                d.selection(),
7879                Some(para),
7880                "offset {off} should select the paragraph"
7881            );
7882        }
7883    }
7884
7885    #[test]
7886    fn select_block_uses_content_span_for_a_heading() {
7887        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
7888        d.select_block_at(4); // inside "Title"
7889        // content_span excludes the "# " marker.
7890        assert_eq!(d.selected_text(), Some("Title"));
7891        d.select_block_at(10); // inside "body"
7892        assert_eq!(d.selected_text(), Some("body"));
7893    }
7894
7895    #[test]
7896    fn select_all_spans_the_document() {
7897        let mut d = doc_with("sel_all", "abc\n\ndef\n");
7898        d.select_all();
7899        assert_eq!(d.selection(), Some((0, d.source.len())));
7900    }
7901
7902    #[test]
7903    fn select_word_at_picks_the_surrounding_word() {
7904        let mut d = doc_with("sel_word", "hello world\n");
7905        d.select_word_at(8); // inside "world"
7906        assert_eq!(d.selection(), Some((6, 11)));
7907        // Double-clicking at end-of-word still grabs the word to its left.
7908        d.select_word_at(5); // the space between the words
7909        assert_eq!(d.selection(), Some((5, 6)));
7910    }
7911
7912    #[test]
7913    fn word_helpers_respect_utf8_boundaries() {
7914        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
7915        assert_eq!(
7916            golden("utf8", "|café ok", |d| d.move_word_right(false)),
7917            "café| ok"
7918        );
7919        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
7920    }
7921
7922    #[test]
7923    fn typing_inserts_at_the_caret_and_advances_it() {
7924        let mut d = doc_with("type", "hello\n");
7925        d.insert("Hi ");
7926        assert_eq!(d.source, "Hi hello\n");
7927        assert_eq!(d.caret, 3);
7928        assert!(d.dirty);
7929    }
7930
7931    #[test]
7932    fn backspace_deletes_the_char_before_the_caret() {
7933        let mut d = doc_with("bs", "hello\n");
7934        d.caret = 3; // after "hel"
7935        d.backspace();
7936        assert_eq!(d.source, "helo\n");
7937        assert_eq!(d.caret, 2);
7938    }
7939
7940    #[test]
7941    fn typing_replaces_the_selection() {
7942        let mut d = doc_with("replace", "a word b\n");
7943        d.anchor = Some(2);
7944        d.caret = 6; // "word" selected
7945        d.insert("X");
7946        assert_eq!(d.source, "a X b\n");
7947        assert_eq!(d.caret, 3);
7948        assert_eq!(d.anchor, None);
7949    }
7950
7951    #[test]
7952    fn toggle_bold_wraps_then_unwraps_the_selection() {
7953        let mut d = doc_with("bold", "a word b\n");
7954        d.anchor = Some(2);
7955        d.caret = 6;
7956        d.toggle(InlineKind::Strong);
7957        assert_eq!(d.source, "a **word** b\n");
7958        // The toggled region stays selected, so a second toggle reverses it.
7959        d.toggle(InlineKind::Strong);
7960        assert_eq!(d.source, "a word b\n");
7961        d.toggle(InlineKind::Strong);
7962        assert_eq!(d.source, "a **word** b\n");
7963    }
7964
7965    #[test]
7966    fn toggle_code_wraps_then_unwraps_the_selection() {
7967        let mut d = doc_with("code_rt", "a word b\n");
7968        d.anchor = Some(2);
7969        d.caret = 6;
7970        d.toggle(InlineKind::Verbatim);
7971        assert_eq!(d.source, "a `word` b\n");
7972        d.toggle(InlineKind::Verbatim);
7973        assert_eq!(d.source, "a word b\n");
7974    }
7975
7976    #[test]
7977    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
7978        // ⌘b at a bare caret, then type: the text comes out bold with no
7979        // selection ever made — the word-processor "start bold here" gesture.
7980        let mut d = doc_with("sticky_wrap", "xy\n");
7981        d.caret = 1; // between x and y
7982        d.toggle(InlineKind::Strong);
7983        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
7984        d.insert("A");
7985        assert_eq!(d.source, "x**A**y\n");
7986    }
7987
7988    #[test]
7989    fn sticky_bold_lights_the_toolbar_before_any_typing() {
7990        // The button must light the instant ⌘b is pressed, or the mode is
7991        // invisible until the first character lands.
7992        let mut d = doc_with("sticky_light", "xy\n");
7993        d.caret = 1;
7994        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
7995        d.toggle(InlineKind::Strong);
7996        assert!(d.active_inline_marks().contains(InlineKind::Strong));
7997    }
7998
7999    #[test]
8000    fn sticky_bold_toggled_off_types_normally_again() {
8001        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
8002        // in the flow of typing, the exact sequence the user described.
8003        let mut d = doc_with("sticky_off", "\n");
8004        d.caret = 0;
8005        d.toggle(InlineKind::Strong);
8006        d.insert("a");
8007        d.insert("b"); // continues inside the run, no re-arming
8008        assert_eq!(d.source, "**ab**\n");
8009        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
8010        d.insert("c");
8011        assert_eq!(d.source, "**ab**c\n");
8012    }
8013
8014    #[test]
8015    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
8016        // Once a mark is realised the caret sits inside the run, so plain typing
8017        // extends it rather than starting a second, adjacent bold span.
8018        let mut d = doc_with("sticky_cont", "\n");
8019        d.caret = 0;
8020        d.toggle(InlineKind::Emph);
8021        d.insert("h");
8022        d.insert("i");
8023        assert_eq!(d.source, "*hi*\n");
8024    }
8025
8026    #[test]
8027    fn moving_the_caret_disarms_a_sticky_mark() {
8028        // Arming a mark and then moving away must not style text elsewhere.
8029        let mut d = doc_with("sticky_disarm", "xy\n");
8030        d.caret = 0;
8031        d.toggle(InlineKind::Strong);
8032        d.move_right(false); // caret 0 → 1, disarms
8033        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8034        d.insert("A");
8035        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
8036    }
8037
8038    #[test]
8039    fn stacked_sticky_marks_apply_together() {
8040        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
8041        let mut d = doc_with("sticky_stack", "\n");
8042        d.caret = 0;
8043        d.toggle(InlineKind::Strong);
8044        d.toggle(InlineKind::Emph);
8045        d.insert("x");
8046        // Land the caret on the styled character and confirm both marks are live.
8047        d.anchor = Some(d.source.find('x').unwrap());
8048        d.caret = d.anchor.unwrap() + 1;
8049        let marks = d.active_inline_marks();
8050        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
8051        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
8052    }
8053
8054    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
8055
8056    #[test]
8057    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
8058        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
8059        // The space inside the run made `**bold **`, which is *not* bold — four
8060        // literal asterisks — so the rich view drew them, correctly and
8061        // uselessly, until the next character happened to close the run again.
8062        let mut d = wysiwyg_doc("edge_typing", "a \n");
8063        d.caret = 2;
8064        d.toggle(InlineKind::Strong);
8065        for c in "bold".chars() {
8066            d.insert(&c.to_string());
8067        }
8068        assert_eq!(d.source, "a **bold**\n");
8069        d.insert(" ");
8070        assert_eq!(
8071            d.source, "a **bold** \n",
8072            "the space belongs outside the run"
8073        );
8074        assert!(
8075            d.active_inline_marks().contains(InlineKind::Strong),
8076            "bold is still what's being typed, so the button stays lit"
8077        );
8078        // What the writer is looking at while all this happens: their words.
8079        d.build_visual(80);
8080        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8081        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
8082        for c in "hey".chars() {
8083            d.insert(&c.to_string());
8084        }
8085        assert_eq!(
8086            d.source, "a **bold hey**\n",
8087            "one bold phrase, not two runs"
8088        );
8089    }
8090
8091    #[test]
8092    fn typing_past_a_space_can_still_leave_the_bold_behind() {
8093        // The other half: the marks stay armed across the space, so ⌘b turns
8094        // them off again there and the next word is plain — the run isn't
8095        // rejoined by a caret that was told not to.
8096        let mut d = wysiwyg_doc("edge_shed", "\n");
8097        d.caret = 0;
8098        d.toggle(InlineKind::Strong);
8099        for c in "bold ".chars() {
8100            d.insert(&c.to_string());
8101        }
8102        assert_eq!(d.source, "**bold** \n");
8103        d.toggle(InlineKind::Strong);
8104        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8105        d.insert("x");
8106        assert_eq!(d.source, "**bold** x\n");
8107    }
8108
8109    #[test]
8110    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
8111        // ⌘b and then a space before any word: the space is not marked (nothing
8112        // is), and the word after it is.
8113        let mut d = wysiwyg_doc("edge_space_first", "a\n");
8114        d.caret = 1;
8115        d.toggle(InlineKind::Strong);
8116        d.insert(" ");
8117        assert_eq!(d.source, "a \n");
8118        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8119        d.insert("b");
8120        assert_eq!(d.source, "a **b**\n");
8121    }
8122
8123    #[test]
8124    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
8125        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
8126        d.caret = 8; // the caret's home at the end of the run's text
8127        d.insert(" ");
8128        assert_eq!(
8129            d.source, "x **bold** \n",
8130            "the space lands past the delimiters"
8131        );
8132        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
8133
8134        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
8135        d.caret = 4; // in front of the "b"
8136        d.insert(" ");
8137        assert_eq!(d.source, "x  **bold** y\n");
8138        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
8139    }
8140
8141    #[test]
8142    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
8143        // Backspace over the last letter of a bold phrase.
8144        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
8145        d.caret = 10; // past the "h"
8146        d.backspace();
8147        assert_eq!(d.source, "a **bold** \n");
8148        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
8149        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8150        d.insert("x");
8151        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
8152    }
8153
8154    #[test]
8155    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
8156        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
8157        // mark, which is only text. The marks live on in the caret instead.
8158        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
8159        d.caret = 5;
8160        d.backspace();
8161        assert_eq!(d.source, "a  c\n");
8162        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8163        d.insert("x");
8164        assert_eq!(d.source, "a **x** c\n");
8165    }
8166
8167    #[test]
8168    fn typing_over_a_whole_bold_word_keeps_it_bold() {
8169        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
8170        d.anchor = Some(4);
8171        d.caret = 8; // the word, not its delimiters
8172        d.insert("x");
8173        assert_eq!(d.source, "a **x** c\n");
8174    }
8175
8176    #[test]
8177    fn a_code_span_keeps_the_space_it_is_given() {
8178        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
8179        // is still verbatim, so nothing is re-spelt. The repair asks the parser
8180        // rather than a table of kinds, and this is the answer it gets.
8181        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
8182        d.caret = 7;
8183        d.insert(" ");
8184        assert_eq!(d.source, "a `code ` c\n");
8185    }
8186
8187    #[test]
8188    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
8189        // A run's closing delimiter has a caret home on each side of it, one
8190        // column apart on screen — and a plain ← off the space after a bold word
8191        // lands on the outer one. The character drawn behind the caret there is
8192        // still the last letter of the phrase, so that is what Backspace takes;
8193        // the byte behind it is a `*` nobody can see.
8194        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
8195        d.caret = 9;
8196        d.move_left(false);
8197        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
8198        d.backspace();
8199        assert_eq!(
8200            d.source, "**bol** x\n",
8201            "a letter of the phrase, not its `*`"
8202        );
8203        assert_eq!(d.caret, 5);
8204
8205        // And the mirror in front of the opening delimiter, where Delete's
8206        // character is the first letter of the run.
8207        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
8208        d.caret = 1;
8209        d.delete_forward();
8210        assert_eq!(d.source, "x**old**\n");
8211        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
8212    }
8213
8214    #[test]
8215    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
8216        // The byte beside the caret at either edge of a bold word is a `*` the
8217        // rich view draws nothing for. Taking it is not the character delete the
8218        // key was pressed for — it unspells the run and puts a literal asterisk
8219        // on screen (`a *bold** c`). The visible character is the one that goes.
8220        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
8221        d.caret = 4; // in front of the "b"
8222        d.backspace();
8223        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
8224
8225        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
8226        d.caret = 8; // past the "d"
8227        d.delete_forward();
8228        assert_eq!(d.source, "a **bold**c\n");
8229        assert_eq!(d.caret, 8, "and the caret stays inside the run");
8230        d.insert("x");
8231        assert_eq!(d.source, "a **boldx**c\n");
8232
8233        // A code span's backticks are hidden the same way, so they are covered
8234        // by the same rule and not by a list of kinds.
8235        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
8236        d.caret = 3;
8237        d.backspace();
8238        assert_eq!(d.source, "a`code` c\n");
8239    }
8240
8241    #[test]
8242    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
8243        // The asterisks are on the screen there and the caret can stand between
8244        // them, so a delete takes exactly the byte it is aimed at.
8245        let mut d = doc_with("edge_open_src", "a **bold** c\n");
8246        d.caret = 4;
8247        d.backspace();
8248        assert_eq!(d.source, "a *bold** c\n");
8249
8250        let mut d = doc_with("edge_close_src", "a **bold** c\n");
8251        d.caret = 8;
8252        d.delete_forward();
8253        assert_eq!(d.source, "a **bold* c\n");
8254    }
8255
8256    #[test]
8257    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
8258        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
8259        // The space had stepped outside the run (the mark-edge rule), taking the
8260        // caret with it, so the delete put it back down on the far side of the
8261        // closing `**` — one place on screen, and the wrong side of it. Typing
8262        // came out plain and the toolbar went dark, with nothing to see.
8263        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
8264        d.caret = 0;
8265        d.toggle(InlineKind::Strong);
8266        for c in "bold".chars() {
8267            d.insert(&c.to_string());
8268        }
8269        d.insert(" ");
8270        assert_eq!(d.source, "**bold** \n");
8271        d.backspace();
8272        assert_eq!(
8273            d.source, "**bold**\n",
8274            "the space goes, the delimiters stay"
8275        );
8276        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
8277        assert!(
8278            d.active_inline_marks().contains(InlineKind::Strong),
8279            "so the button is still lit"
8280        );
8281        d.insert("x");
8282        assert_eq!(
8283            d.source, "**boldx**\n",
8284            "and the next character is still bold"
8285        );
8286    }
8287
8288    #[test]
8289    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
8290        // What the stranded caret did next: the byte behind it was the closing
8291        // `*`, so a second press took that instead of a letter — `**bold*`, the
8292        // styling gone and an asterisk on the screen where the word had been.
8293        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
8294        d.caret = 0;
8295        d.toggle(InlineKind::Strong);
8296        for c in "bold ".chars() {
8297            d.insert(&c.to_string());
8298        }
8299        assert_eq!(d.source, "**bold** \n");
8300        d.backspace();
8301        d.backspace();
8302        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
8303        assert_eq!(d.caret, 5);
8304    }
8305
8306    #[test]
8307    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
8308        // `***both***` closes two runs with one stack of asterisks: the caret has
8309        // to walk in through all of them, or it lands between the emph and the
8310        // strong and types half-marked.
8311        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
8312        d.caret = 11;
8313        d.backspace();
8314        assert_eq!(d.source, "***both***\n");
8315        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
8316        d.insert("x");
8317        assert_eq!(d.source, "***bothx***\n");
8318    }
8319
8320    #[test]
8321    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
8322        // The settle only moves a caret a run actually closed over. Ordinary
8323        // deletes — inside a run, or in plain prose — are untouched.
8324        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
8325        d.caret = 8;
8326        d.backspace();
8327        assert_eq!(d.source, "a **bol** c\n");
8328        assert_eq!(d.caret, 7);
8329
8330        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
8331        d.caret = 5;
8332        d.backspace();
8333        assert_eq!(d.source, "plai\n");
8334        assert_eq!(d.caret, 4);
8335    }
8336
8337    #[test]
8338    fn the_source_view_leaves_a_delete_where_it_landed() {
8339        // The delimiters are on the screen there, so the offset past them is a
8340        // place the caret can be seen to be — nothing to settle.
8341        let mut d = doc_with("edge_bksp_src", "**bold** \n");
8342        d.caret = 9;
8343        d.backspace();
8344        assert_eq!(d.source, "**bold**\n");
8345        assert_eq!(d.caret, 8);
8346    }
8347
8348    #[test]
8349    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
8350        // `***both***` closes two runs with one stack of asterisks; a space that
8351        // clears only the inner one lands against the outer's and breaks that
8352        // instead.
8353        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
8354        d.caret = 9;
8355        d.insert(" ");
8356        assert_eq!(d.source, "a ***both*** \n");
8357        assert_eq!(d.caret, 13);
8358        d.insert("x");
8359        assert_eq!(d.source, "a ***both x***\n");
8360    }
8361
8362    #[test]
8363    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
8364        // The delimiter shuffle is not an edit the writer made, so it is not a
8365        // step they have to undo past.
8366        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
8367        d.caret = 8;
8368        d.insert(" ");
8369        assert_eq!(d.source, "a **bold** \n");
8370        d.undo();
8371        assert_eq!(d.source, "a **bold**\n");
8372    }
8373
8374    #[test]
8375    fn the_source_view_types_the_space_where_it_was_asked_to() {
8376        // The rule is a rich-view courtesy. In the source view the delimiters are
8377        // on the screen and the user is editing the bytes they can see.
8378        let mut d = doc_with("edge_src", "a **bold** c\n");
8379        d.caret = 8;
8380        d.insert(" ");
8381        assert_eq!(d.source, "a **bold ** c\n");
8382    }
8383
8384    #[test]
8385    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
8386        // Double-clicking a word takes the space after it; bolding that must not
8387        // spell `**word **`, which is not bold at all.
8388        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
8389        d.anchor = Some(2);
8390        d.caret = 7; // "word "
8391        d.toggle(InlineKind::Strong);
8392        assert_eq!(d.source, "a **word** b\n");
8393        d.toggle(InlineKind::Strong);
8394        assert_eq!(d.source, "a word b\n");
8395        d.toggle(InlineKind::Strong);
8396        assert_eq!(
8397            d.source, "a **word** b\n",
8398            "reapplying the mark must not wrap stale delimiter offsets"
8399        );
8400        // And a selection of nothing but whitespace has no word to mark.
8401        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
8402        d.anchor = Some(6);
8403        d.caret = 7;
8404        d.toggle(InlineKind::Strong);
8405        assert_eq!(d.source, "a word b\n");
8406        assert!(d.status.is_some());
8407    }
8408
8409    #[test]
8410    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
8411        let mut d = doc_with("head_set", "hello\n");
8412        d.caret = 2; // caret inside the paragraph, no selection
8413        d.set_block(BlockKind::Heading(1));
8414        assert_eq!(d.source, "# hello\n");
8415    }
8416
8417    #[test]
8418    fn set_block_heading_works_in_wysiwyg_view() {
8419        // The app defaults to WYSIWYG; the caret is a source offset either way.
8420        let mut d = wysiwyg_doc("head_wys", "hello\n");
8421        d.caret = 2;
8422        d.set_block(BlockKind::Heading(1));
8423        assert_eq!(d.source, "# hello\n");
8424    }
8425
8426    #[test]
8427    fn toggle_heading_applies_switches_and_reverts() {
8428        let mut d = doc_with("head_toggle", "hello\n");
8429        d.caret = 2;
8430        d.toggle_heading(1);
8431        assert_eq!(d.source, "# hello\n"); // paragraph → H1
8432        d.toggle_heading(2);
8433        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
8434        d.toggle_heading(2);
8435        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
8436    }
8437
8438    #[test]
8439    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
8440        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
8441        // the blank line but the caret rendered on the *next* line, because the
8442        // separator was a non-navigable decoration row. In Preserve flow that
8443        // blank line is a real caret home — the caret must resolve onto it, and
8444        // typing there makes the soft break that continues the paragraph.
8445        let src = "line one:\nsecond line\n";
8446        let mut d = wysiwyg_doc("pre_enter_lineend", src);
8447        d.set_line_flow(LineFlow::Preserve);
8448        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
8449        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
8450        d.newline();
8451        d.build_visual_unwrapped();
8452        assert_eq!(d.source, "line one:\n\nsecond line\n");
8453        assert_eq!(
8454            d.caret, 10,
8455            "caret sits on the new blank line, not the next line"
8456        );
8457        // The blank line is row 1, and the caret resolves onto it — not row 2.
8458        assert_eq!(
8459            d.vmap.pos_of_offset(10),
8460            (1, 0),
8461            "caret renders on the blank row"
8462        );
8463        assert!(
8464            !d.vmap.rows[1].decoration,
8465            "the blank line is navigable in Preserve"
8466        );
8467        // Typing there makes a soft break: one paragraph, three lines.
8468        d.insert("new clause,");
8469        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
8470    }
8471
8472    #[test]
8473    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
8474        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
8475        // that keeps it one paragraph — where Fold would open a second paragraph.
8476        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
8477        d.set_line_flow(LineFlow::Preserve);
8478        d.caret = 3;
8479        d.newline();
8480        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
8481
8482        // End-of-paragraph: Enter then typing continues the same paragraph on a
8483        // new line (a soft break), not a fresh paragraph.
8484        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
8485        d.set_line_flow(LineFlow::Preserve);
8486        d.caret = 3;
8487        d.newline();
8488        d.insert("def");
8489        assert_eq!(
8490            d.source, "abc\ndef\n",
8491            "end-of-line Enter + typing is a soft break"
8492        );
8493    }
8494
8495    #[test]
8496    fn preserve_double_enter_still_makes_a_paragraph() {
8497        // Two Enters in a row promote to a real paragraph break: the second lands
8498        // on the blank line the first opened and takes the empty-line branch.
8499        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
8500        d.set_line_flow(LineFlow::Preserve);
8501        d.caret = 3;
8502        d.newline();
8503        d.newline();
8504        d.insert("def");
8505        assert_eq!(
8506            d.source, "abc\n\ndef\n",
8507            "double Enter is a paragraph break"
8508        );
8509    }
8510
8511    #[test]
8512    fn preserve_backspace_joins_across_a_soft_break() {
8513        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
8514        // soft break it deletes the single newline and joins the two lines.
8515        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
8516        d.set_line_flow(LineFlow::Preserve);
8517        d.build_visual(80);
8518        d.caret = 4; // start of "def", just past the soft break
8519        d.backspace();
8520        assert_eq!(
8521            d.source, "abcdef\n",
8522            "Backspace joins across the soft break"
8523        );
8524        assert_eq!(d.caret, 3, "caret lands where the lines meet");
8525    }
8526
8527    #[test]
8528    fn fold_enter_still_starts_a_new_paragraph() {
8529        // The default flow is unchanged: a lone `\n` would render as an invisible
8530        // space, so Enter keeps opening the paragraph break that actually shows.
8531        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
8532        d.caret = 3;
8533        d.newline();
8534        assert_eq!(
8535            d.source, "abc\n\ndef\n",
8536            "Fold mid-line Enter is a paragraph break"
8537        );
8538    }
8539
8540    #[test]
8541    fn wysiwyg_one_enter_starts_a_new_paragraph() {
8542        // Regression: one Enter left the caret between the two newlines, so typing
8543        // made a soft break (one paragraph) and you needed a second Enter.
8544        let mut d = wysiwyg_doc("wys_enter", "abc\n");
8545        d.caret = 3;
8546        d.newline();
8547        d.insert("def");
8548        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
8549    }
8550
8551    #[test]
8552    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
8553        // Regression: Enter at the caret's natural End-of-line resting place
8554        // after a bold run with nothing following it (on screen: right after
8555        // "bold", before the hidden closing "**") spliced the paragraph break
8556        // at that very byte offset — which sits *before* the closing "**" in
8557        // the source, since the delimiter is hidden and emits no glyph of its
8558        // own for `push_row`'s "end of row" fallback to count. That severed the
8559        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
8560        // "**" alone on the new line instead of leaving "**bold**" intact with
8561        // a fresh empty paragraph after it.
8562        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
8563        d.move_end(false); // the WYSIWYG End key, from caret 0
8564        assert_eq!(
8565            d.caret, 6,
8566            "caret rests right after \"bold\", before the hidden \"**\""
8567        );
8568        d.newline();
8569        assert!(
8570            d.source.starts_with("**bold**"),
8571            "the closing ** must stay attached to \"bold\": got {:?}",
8572            d.source
8573        );
8574        assert_eq!(
8575            d.source, "**bold**\n\n\n",
8576            "a fresh empty paragraph follows the still-intact bold run"
8577        );
8578    }
8579
8580    #[test]
8581    fn source_view_enter_is_a_single_newline() {
8582        let mut d = doc_with("src_enter", "abc\n");
8583        d.caret = 3;
8584        d.newline();
8585        assert_eq!(d.source, "abc\n\n");
8586    }
8587
8588    #[test]
8589    fn heading_applies_at_the_end_of_a_paragraph() {
8590        // The caret at a line end sits at the doc level; set_block must still find
8591        // the block on that line.
8592        let mut d = doc_with("head_end", "abc\n");
8593        d.caret = 3; // end of "abc"
8594        d.toggle_heading(1);
8595        assert_eq!(d.source, "# abc\n");
8596    }
8597
8598    #[test]
8599    fn heading_on_an_empty_new_paragraph_creates_one() {
8600        let mut d = wysiwyg_doc("head_empty", "abc\n");
8601        d.caret = 3;
8602        d.newline(); // caret now on a fresh, empty paragraph
8603        d.toggle_heading(1);
8604        d.insert("Title");
8605        assert!(d.source.contains("# Title"), "got {:?}", d.source);
8606    }
8607
8608    #[test]
8609    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
8610        // The reported bug, end to end: click a blank line with another one under
8611        // it, press H1, type. The text landed in the heading and the caret's
8612        // offset was right (the source view drew it there), but the rich view
8613        // drew it two rows lower, on the trailing blank line — the empty `# `
8614        // heading had left every row below it short by the marker's two bytes,
8615        // and the blank line ended up claiming the heading's own end offset.
8616        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
8617        d.build_visual_unwrapped();
8618        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
8619        d.toggle_heading(1);
8620        for c in "title".chars() {
8621            d.insert(&c.to_string());
8622            d.build_visual_unwrapped(); // as a frontend does, one frame per key
8623        }
8624        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
8625        assert_eq!(
8626            d.caret_pos(),
8627            (4, 5),
8628            "the caret draws at the end of the heading"
8629        );
8630    }
8631
8632    #[test]
8633    fn clicking_an_empty_heading_types_after_its_marker() {
8634        // The same anchor from the other side: the empty heading's row is its own
8635        // caret home, so a click on it must land past the hidden `# `. Landing in
8636        // front of the hashes made the first keystroke un-heading the line.
8637        let mut d = wysiwyg_doc("head_click", "# \n");
8638        d.build_visual_unwrapped();
8639        d.caret = d.vmap.offset_of_pos(0, 0);
8640        d.insert("x");
8641        assert_eq!(d.source, "# x\n");
8642    }
8643
8644    #[test]
8645    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
8646        let mut d = wysiwyg_doc("head_enter", "# Title\n");
8647        d.caret = 7; // end of the heading
8648        d.newline();
8649        d.insert("body");
8650        assert_eq!(d.source, "# Title\n\nbody\n");
8651    }
8652
8653    #[test]
8654    fn wysiwyg_enter_continues_a_bullet_list() {
8655        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
8656        d.caret = 6; // end of "item"
8657        d.newline();
8658        d.insert("two");
8659        assert_eq!(d.source, "- item\n- two\n");
8660    }
8661
8662    #[test]
8663    fn wysiwyg_enter_increments_an_ordered_list() {
8664        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
8665        d.caret = 6; // end of "one"
8666        d.newline();
8667        d.insert("two");
8668        assert_eq!(d.source, "1. one\n2. two\n");
8669    }
8670
8671    #[test]
8672    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
8673        // Regression for the "extra newline" left between a list and the paragraph
8674        // below it. Enter, Enter leaves the list on a fresh empty paragraph
8675        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
8676        // Backspace should then take the caret cleanly back to the end of the list
8677        // item, `- item\n\nnext`, not delete a single newline and strand it on the
8678        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
8679        // no caret can land on. The map is rebuilt between keystrokes exactly as a
8680        // frontend does, since Backspace reads the stop table to place the delete.
8681        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
8682        d.caret = 6; // end of "item"
8683        d.newline();
8684        d.build_visual(80);
8685        d.newline(); // leave the list onto a fresh empty paragraph
8686        d.build_visual(80);
8687        assert_eq!(
8688            d.source, "- item\n\n\n\nnext\n",
8689            "double-Enter opens the empty paragraph"
8690        );
8691        d.backspace();
8692        assert_eq!(
8693            d.source, "- item\n\nnext\n",
8694            "one Backspace collapses the whole gap"
8695        );
8696        assert_eq!(
8697            d.caret, 6,
8698            "and lands the caret back at the end of the list item"
8699        );
8700    }
8701
8702    #[test]
8703    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
8704        // The stop-wise delete must not over-reach when there is no block boundary
8705        // to cross: two blank lines in a row are one caret stop apart, so pressing
8706        // Enter on an empty line and then Backspace removes exactly the one newline
8707        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
8708        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
8709        d.caret = 5; // the empty paragraph the first Enter already opened
8710        d.build_visual(80);
8711        d.newline();
8712        d.build_visual(80);
8713        assert_eq!(
8714            d.source, "abc\n\n\n\n",
8715            "Enter on the blank line adds one newline"
8716        );
8717        d.backspace();
8718        assert_eq!(
8719            d.source, "abc\n\n\n",
8720            "Backspace takes back exactly that one newline"
8721        );
8722    }
8723
8724    #[test]
8725    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
8726        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
8727        d.caret = 6; // end of the empty "- " item
8728        d.newline();
8729        d.insert("p");
8730        assert_eq!(d.source, "- a\n\np\n");
8731    }
8732
8733    #[test]
8734    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
8735        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
8736        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
8737        // not take the list-exit path (which would splice the `- ` away as if
8738        // leaving an empty item); the AST guard sends it to a normal break and
8739        // leaves the underline intact.
8740        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
8741        assert!(
8742            d.nodes().iter().any(|n| n.kind == Kind::Heading),
8743            "precondition: twig parses this as a heading, not a list",
8744        );
8745        d.caret = 7; // on the `- ` underline line
8746        d.newline();
8747        assert!(
8748            d.source.contains("- "),
8749            "the setext underline survives, not spliced away as a list item: {:?}",
8750            d.source,
8751        );
8752    }
8753
8754    #[test]
8755    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
8756        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
8757        d.caret = 7; // end of "abc" inside the fence
8758        d.newline();
8759        d.insert("def");
8760        assert_eq!(d.source, "```\nabc\ndef\n```\n");
8761    }
8762
8763    #[test]
8764    fn wysiwyg_enter_continues_a_block_quote() {
8765        // Enter opens a new *paragraph* inside the quote, not a second line of
8766        // the same one. `> quote\n> more` is a soft break, which under
8767        // `LineFlow::Fold` renders as a space — the keystroke would look like it
8768        // did nothing. The quoted blank line is what makes the break visible, and
8769        // it's the same thing Enter does in running prose.
8770        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
8771        d.caret = 7; // end of "quote"
8772        d.newline();
8773        d.insert("more");
8774        assert_eq!(d.source, "> quote\n>\n> more\n");
8775        // Still one quote, now holding two paragraphs — not a quote and a stray
8776        // line that fell out of it.
8777        let quotes = d
8778            .nodes()
8779            .iter()
8780            .filter(|n| n.kind == Kind::BlockQuote)
8781            .count();
8782        assert_eq!(quotes, 1);
8783    }
8784
8785    #[test]
8786    fn set_block_makes_a_heading_at_the_caret() {
8787        let mut d = doc_with("head", "Title\n\nbody\n");
8788        d.caret = 0;
8789        d.set_block(BlockKind::Heading(2));
8790        assert_eq!(d.source, "## Title\n\nbody\n");
8791        d.set_block(BlockKind::Paragraph);
8792        assert_eq!(d.source, "Title\n\nbody\n");
8793    }
8794
8795    // ── block containers (quote / list) ──────────────────────────────────────
8796
8797    #[test]
8798    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
8799        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
8800        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
8801        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
8802        // A caret at a line end sits at the doc level; the block is still found.
8803        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
8804    }
8805
8806    #[test]
8807    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
8808        // Every source line of the paragraph gets its own `> `, so a caret left
8809        // on its old byte offset falls one prefix per line above it too far
8810        // back — inside the markup it just asked for rather than in its word.
8811        assert_eq!(
8812            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
8813            "> aaa\n> b|bb\n> ccc\n"
8814        );
8815    }
8816
8817    #[test]
8818    fn toggle_blockquote_works_in_wysiwyg_view() {
8819        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
8820        assert_eq!(
8821            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
8822            "> hel|lo\n"
8823        );
8824        assert_eq!(
8825            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
8826            "hel|lo\n"
8827        );
8828    }
8829
8830    #[test]
8831    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
8832        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
8833        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
8834        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
8835        // The *other* kind converts in place instead of nesting, which is what
8836        // makes the two buttons one three-state control.
8837        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
8838        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
8839        // Its own kind, over the only item the list holds, takes it off.
8840        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
8841    }
8842
8843    #[test]
8844    fn toggle_list_works_in_wysiwyg_view() {
8845        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
8846        assert_eq!(
8847            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
8848            "1. hel|lo\n"
8849        );
8850        assert_eq!(
8851            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
8852            "- hel|lo\n"
8853        );
8854        assert_eq!(
8855            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
8856            "hel|lo\n"
8857        );
8858    }
8859
8860    #[test]
8861    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
8862        // The selection has to grow with the markup: twig takes a container off
8863        // only a range covering every block it holds, so the second press can
8864        // reverse the first only if the result is what's selected.
8865        let mut d = doc_with("list_sel", "abc\n\ndef\n");
8866        d.select_all();
8867        d.toggle_list(true);
8868        assert_eq!(d.source, "1. abc\n\n2. def\n");
8869        assert_eq!(d.selection(), Some((0, d.source.len())));
8870        d.toggle_list(true);
8871        assert_eq!(d.source, "abc\n\ndef\n");
8872    }
8873
8874    #[test]
8875    fn toggle_blockquote_nests_a_partly_covered_quote() {
8876        // twig's rule: covering only some of a container's blocks nests, because
8877        // taking the quote off would drag its uncovered siblings out with it.
8878        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
8879        d.caret = 2; // in the first quoted paragraph only
8880        d.toggle_blockquote();
8881        assert_eq!(d.source, "> > a\n>\n> b\n");
8882    }
8883
8884    #[test]
8885    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
8886        // A blank line used to be no block for twig to wrap —
8887        // `toggle_block_container` answered `NotFound` — so Quote and the list
8888        // buttons did nothing on the very line the H1 button works on, and leaf
8889        // lent twig a scratch paragraph to wrap and took it back out again.
8890        // twig 3.2.0 opens an empty container there itself, so what is left here
8891        // is where the caret lands: inside the marker that was just written.
8892        let mut d = doc_with("quote_blank", "\nabc\n");
8893        d.caret = 0;
8894        d.toggle_blockquote();
8895        assert_eq!(d.source, "> \nabc\n");
8896        assert_eq!(
8897            d.caret, 2,
8898            "the caret belongs inside the quote it just opened"
8899        );
8900        assert!(d.status.is_none(), "{:?}", d.status);
8901        assert!(d.dirty);
8902
8903        // And the paragraph below is still its own block: an empty container one
8904        // soft break from `abc` would take that paragraph into the quote with it.
8905        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
8906        d.caret = 0;
8907        d.toggle_blockquote();
8908        d.build_visual(80);
8909        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
8910
8911        // The same from the other side: a blank line directly under a paragraph
8912        // earns the blank line an empty block needs, rather than being read as a
8913        // soft break inside that paragraph.
8914        let mut d = doc_with("list_blank_below", "abc\n");
8915        d.caret = 4;
8916        d.toggle_list(false);
8917        assert_eq!(d.source, "abc\n\n- ");
8918        assert_eq!(d.caret, 7);
8919    }
8920
8921    #[test]
8922    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
8923        // The gesture the rendering fix is for. `newline` inside a quote already
8924        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
8925        // spelling — but the two marker lines it adds belonged to no node until
8926        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
8927        // just made drew as plain prose under the quote.
8928        let mut d = wysiwyg_doc("quote_enter", "> a\n");
8929        d.caret = 3; // past `a`, at the end of the quoted line
8930        d.newline();
8931        assert_eq!(d.source, "> a\n>\n> \n");
8932        d.build_visual(80);
8933        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
8934        // And the caret is on the new line, not stranded on the old one.
8935        assert_eq!(d.caret, 8);
8936    }
8937
8938    #[test]
8939    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
8940        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
8941        // now it is twig's single edit. Either way one ⌘z has to put the blank
8942        // line back rather than undoing into a half-built document.
8943        for open in [
8944            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
8945            &|d: &mut Doc| d.toggle_list(false),
8946            &|d: &mut Doc| d.toggle_list(true),
8947        ] {
8948            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
8949            d.caret = 3;
8950            open(&mut d);
8951            assert_ne!(d.source, "a\n\n\n\nb\n");
8952            d.undo();
8953            assert_eq!(d.source, "a\n\n\n\nb\n");
8954        }
8955    }
8956
8957    #[test]
8958    fn a_container_toggle_is_one_undo_step() {
8959        let mut d = doc_with("quote_undo", "hello\n");
8960        d.caret = 3;
8961        d.insert("X"); // a typing run the structural edit must not fold into
8962        d.toggle_blockquote();
8963        assert_eq!(d.source, "> helXlo\n");
8964        d.undo();
8965        assert_eq!(d.source, "helXlo\n");
8966    }
8967
8968    // ── links ────────────────────────────────────────────────────────────────
8969
8970    #[test]
8971    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
8972        let mut d = doc_with("link_sel", "word here\n");
8973        d.anchor = Some(0);
8974        d.caret = 4;
8975        d.insert_link("http://x.dev");
8976        assert_eq!(d.source, "[word](http://x.dev) here\n");
8977        // The text, not the destination — so a second press re-points the link
8978        // the first one made rather than nesting one inside it.
8979        assert_eq!(d.selected_text(), Some("word"));
8980        d.insert_link("http://y.dev");
8981        assert_eq!(d.source, "[word](http://y.dev) here\n");
8982        assert_eq!(d.selected_text(), Some("word"));
8983    }
8984
8985    #[test]
8986    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
8987        let mut d = doc_with("img_caret", "before after\n");
8988        d.caret = 7; // between "before " and "after"
8989        d.insert_image("cat.png", "a cat");
8990        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
8991        // The caret sits just past the inserted image, nothing selected.
8992        assert_eq!(d.selection(), None);
8993        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
8994    }
8995
8996    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
8997    /// destination at the first space, so the `format!` this used to be wrote
8998    /// something that was not an image at all — and the reader saw the markup as
8999    /// text. twig owns the spelling now, and moves it into the angle form.
9000    #[test]
9001    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
9002        let mut d = doc_with("img_space", "x\n");
9003        d.caret = 0;
9004        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
9005        assert_eq!(
9006            d.source,
9007            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
9008        );
9009        // And it reads back as an image pointing at the unescaped path — the angle
9010        // brackets are spelling, not part of the destination.
9011        d.caret = 2;
9012        assert_eq!(
9013            d.image_destination_at_caret(),
9014            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
9015        );
9016    }
9017
9018    /// A `)` in a caption or a filename must not close the image early.
9019    #[test]
9020    fn insert_image_escapes_a_paren_in_either_half() {
9021        let mut d = doc_with("img_paren", "x\n");
9022        d.caret = 0;
9023        d.insert_image("a)b.png", "");
9024        assert_eq!(d.source, "![](a\\)b.png)x\n");
9025        d.caret = 2;
9026        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
9027    }
9028
9029    #[test]
9030    fn insert_image_uses_the_selection_as_alt_text() {
9031        let mut d = doc_with("img_sel", "caption here\n");
9032        d.anchor = Some(0);
9033        d.caret = 7; // "caption"
9034        d.insert_image("p.png", "ignored fallback");
9035        assert_eq!(d.source, "![caption](p.png) here\n");
9036    }
9037
9038    #[test]
9039    fn insert_image_with_no_alt_leaves_empty_brackets() {
9040        let mut d = doc_with("img_noalt", "\n");
9041        d.caret = 0;
9042        d.insert_image("logo.svg", "");
9043        assert_eq!(d.source, "![](logo.svg)\n");
9044    }
9045
9046    #[test]
9047    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
9048        // The round trip is the point: it's no use writing markup the reader
9049        // can't pick up again. This is the pair that only holds from twig 2.5.1
9050        // on — before it, the one-line form went in fine and came back as a
9051        // paragraph of raw tags, publishing no media at all.
9052        let mut d = doc_with("vid_rt", "\n");
9053        d.caret = 0;
9054        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
9055        assert_eq!(
9056            d.source,
9057            "<video src=\"clip.mp4\" controls>a clip</video>\n"
9058        );
9059
9060        d.build_visual(80);
9061        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
9062        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
9063        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
9064        assert_eq!(d.vmap.media[0].alt, "a clip");
9065    }
9066
9067    #[test]
9068    fn insert_media_spells_audio_with_its_own_tag() {
9069        let mut d = doc_with("aud_rt", "\n");
9070        d.caret = 0;
9071        d.insert_media(MediaKind::Audio, "take.mp3", "");
9072        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
9073        d.build_visual(80);
9074        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
9075    }
9076
9077    #[test]
9078    fn insert_media_uses_the_selection_as_fallback_text() {
9079        // The same courtesy `insert_image` does with alt: select a caption,
9080        // insert, and the caption labels the thing rather than being replaced.
9081        let mut d = doc_with("vid_sel", "the talk here\n");
9082        d.anchor = Some(0);
9083        d.caret = 8; // "the talk"
9084        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
9085        assert_eq!(
9086            d.source,
9087            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
9088        );
9089    }
9090
9091    #[test]
9092    fn insert_media_with_an_image_kind_is_just_insert_image() {
9093        let mut d = doc_with("img_via_media", "\n");
9094        d.caret = 0;
9095        d.insert_media(MediaKind::Image, "logo.svg", "x");
9096        assert_eq!(d.source, "![x](logo.svg)\n");
9097    }
9098
9099    // ── thematic breaks ─────────────────────────────────────────────────────
9100
9101    /// The node the source parses as at `caret` — what confirms an inserted
9102    /// `---` actually reads back as a rule, not stray text or a setext heading.
9103    ///
9104    /// The *narrowest* node covering the offset. Every ancestor covers it too,
9105    /// and since twig 2.8 that includes the `doc` root, which now carries a real
9106    /// span (it reported none before, so taking the first match used to land on
9107    /// the block by luck and now always answers `"doc"`).
9108    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
9109        d.nodes()
9110            .into_iter()
9111            .filter(|n| n.span.start <= caret && caret < n.span.end)
9112            .min_by_key(|n| n.span.end - n.span.start)
9113            .map(|n| n.kind)
9114    }
9115
9116    #[test]
9117    fn a_task_box_toggles_at_the_caret_and_reads_back() {
9118        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
9119        d.caret = 8; // inside "todo"
9120        assert_eq!(d.task_checked_at_caret(), Some(false));
9121        d.toggle_task_checked();
9122        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
9123        assert_eq!(d.task_checked_at_caret(), Some(true));
9124        d.toggle_task_checked();
9125        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
9126    }
9127
9128    #[test]
9129    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
9130        // The whole reason `toggle_task_at` exists apart from the caret form:
9131        // ticking a box elsewhere must not move the cursor out of what's being
9132        // typed.
9133        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
9134        d.caret = 8; // inside "first"
9135        let second = d.source.find("second").unwrap();
9136        d.toggle_task_at(second);
9137        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
9138        assert_eq!(d.caret, 8, "the caret stayed in the first item");
9139    }
9140
9141    #[test]
9142    fn a_plain_item_gains_and_loses_a_box() {
9143        let mut d = doc_with("task_mint", "- plain\n");
9144        d.caret = 4;
9145        assert_eq!(d.task_checked_at_caret(), None);
9146        d.toggle_task_item();
9147        assert_eq!(d.source, "- [ ] plain\n");
9148        assert_eq!(
9149            d.task_checked_at_caret(),
9150            Some(false),
9151            "a new box arrives unticked"
9152        );
9153        d.toggle_task_item();
9154        assert_eq!(d.source, "- plain\n");
9155    }
9156
9157    #[test]
9158    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
9159        // `set checked` must not silently convert a bullet into a task — that is
9160        // `toggle_task_item`'s job, and twig refuses it here.
9161        let mut d = doc_with("task_none", "- plain\n");
9162        d.caret = 4;
9163        d.toggle_task_checked();
9164        assert_eq!(d.source, "- plain\n", "nothing written");
9165        assert!(
9166            d.status.is_some(),
9167            "the refusal should reach the status line"
9168        );
9169    }
9170
9171    #[test]
9172    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
9173        let mut d = doc_with("task_quote", "> - [ ] nested\n");
9174        d.caret = d.source.find("nested").unwrap();
9175        assert_eq!(d.task_checked_at_caret(), Some(false));
9176        d.toggle_task_checked();
9177        assert_eq!(d.source, "> - [x] nested\n");
9178    }
9179
9180    #[test]
9181    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
9182        // A rule is a block, so twig's `insert_thematic_break` alone lands it
9183        // after the whole paragraph. `split_block` parts the paragraph first and
9184        // the rule is aimed at the *first* half, which is what a rule button is
9185        // understood to do — and what leaf spelled by hand until twig grew both
9186        // halves of the gesture.
9187        let mut d = doc_with("hr_mid", "before after\n");
9188        d.caret = 7; // between "before " and "after"
9189        d.insert_thematic_break();
9190        assert_eq!(d.source, "before \n\n---\n\nafter\n");
9191        assert_eq!(d.selection(), None);
9192        assert_eq!(
9193            kind_at(&mut d, "before \n\n".len()),
9194            Some(Kind::ThematicBreak)
9195        );
9196    }
9197
9198    #[test]
9199    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
9200        // At the end there is nothing to part, and a split there writes the
9201        // separator anyway — a blank line and the empty slot the next paragraph
9202        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
9203        // two blank lines nothing fills. Now the rule lands after the paragraph,
9204        // where the split-and-aim was sending it regardless. Both formats, and
9205        // both shapes of a last line — terminated, and still being typed —
9206        // because the two reach the split through different doors: Markdown's
9207        // paragraph span stops before its newline, so `para\n` at 4 never split
9208        // there, but `para` at 4 did.
9209        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9210            for src in ["para\n", "para"] {
9211                let mut d = Doc::from_source(src.into(), fmt).unwrap();
9212                d.caret = 4;
9213                d.insert_thematic_break();
9214                assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
9215                assert_eq!(d.caret, d.source.len());
9216            }
9217            // Mid-document the slot sat between the rule and the next block.
9218            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
9219            d.caret = 4;
9220            d.insert_thematic_break();
9221            assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
9222            // Trailing whitespace is nothing to part either.
9223            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
9224            d.caret = 4;
9225            d.insert_thematic_break();
9226            assert_eq!(d.source, format!("para  \n\n{rule}\n"), "{fmt:?}");
9227        }
9228    }
9229
9230    #[test]
9231    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
9232        // The split at the start parts nothing, but it is kept on purpose:
9233        // `|para` becomes `\npara` with the caret on a blank line, and twig
9234        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
9235        // way "before the paragraph" is reachable through a gesture that only
9236        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
9237        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9238            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
9239            d.caret = 0;
9240            d.insert_thematic_break();
9241            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
9242            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
9243            d.caret = 6;
9244            d.insert_thematic_break();
9245            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
9246        }
9247    }
9248
9249    #[test]
9250    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
9251        // The gap between two blocks is where a click lands the caret; the
9252        // rule goes on the blank, one blank each side.
9253        let mut d = doc_with("hr_gap", "a\n\nb\n");
9254        d.caret = 2;
9255        d.insert_thematic_break();
9256        assert_eq!(d.source, "a\n\n---\n\nb\n");
9257    }
9258
9259    #[test]
9260    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
9261        // The same door as the rule's, through the placement they share.
9262        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9263        d.caret = 4;
9264        d.insert_table(1, 1);
9265        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
9266        let mut d = doc_with("table_end_typed", "para");
9267        d.caret = 4;
9268        d.insert_table(1, 1);
9269        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
9270        assert!(d.caret_in_table());
9271    }
9272
9273    #[test]
9274    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
9275        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
9276        // and leaf wrote the first into both until twig started spelling it.
9277        let mut md = doc_with("hr_md", "para\n");
9278        md.caret = 2;
9279        md.insert_thematic_break();
9280        assert_eq!(md.source, "pa\n\n---\n\nra\n");
9281
9282        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9283        dj.caret = 2;
9284        dj.insert_thematic_break();
9285        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
9286    }
9287
9288    #[test]
9289    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
9290        // The table goes *at* the caret the way the rule does: the paragraph is
9291        // parted first, and twig writes the grid after its first half. The
9292        // caret then sits in the first header cell — selected, as Tab would
9293        // leave it — so the next keystroke is the heading.
9294        let mut d = doc_with("table_mid", "before after\n");
9295        d.caret = 7;
9296        d.insert_table(2, 3);
9297        assert_eq!(
9298            d.source,
9299            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
9300        );
9301        assert!(d.caret_in_table());
9302        let first_bar = d.source.find('|').unwrap();
9303        assert!(
9304            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
9305            "caret {} is not in the header row",
9306            d.caret
9307        );
9308        d.insert("Name");
9309        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
9310        // And the grid the table was written into is one the table keys walk
9311        // (over the map a frontend rebuilds after every edit).
9312        d.build_visual(80);
9313        assert!(d.cell_tab(true));
9314        d.insert("Qty");
9315        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
9316    }
9317
9318    #[test]
9319    fn insert_table_spells_the_grid_the_format_s_own_way() {
9320        // Djot's delimiter row is unpadded, and leaf never has to know that.
9321        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9322        dj.caret = 2;
9323        dj.insert_table(1, 2);
9324        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
9325        assert!(dj.caret_in_table());
9326    }
9327
9328    #[test]
9329    fn insert_table_refuses_where_the_format_spells_no_table() {
9330        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
9331        d.caret = 4;
9332        d.insert_table(1, 1);
9333        assert_eq!(d.source, "<p>ab</p>\n");
9334        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
9335        assert!(!d.capabilities().table);
9336    }
9337
9338    #[test]
9339    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
9340        let mut d = doc_with("table_zero", "para\n");
9341        d.caret = 2;
9342        d.insert_table(0, 2);
9343        assert_eq!(d.source, "para\n");
9344        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
9345    }
9346
9347    #[test]
9348    fn clicking_below_a_final_thematic_break_can_type_after_it() {
9349        let mut d = wysiwyg_doc("hr_final_click", "---\n");
9350        d.build_visual(80);
9351        d.click(d.vmap.num_rows() + 2, 0, false);
9352        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
9353        d.insert("after");
9354        assert_eq!(d.source, "---\nafter");
9355    }
9356
9357    #[test]
9358    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
9359        // The same bytes are two documents. In Markdown `  - b` is a nested item
9360        // and the next one belongs beside it, at its indent. In Djot a list
9361        // marker can't interrupt a paragraph, so those bytes are literal text in
9362        // item `a` and there is only one item — writing `  - ` under it would add
9363        // no item at all, just more text, and the new sibling has to go to
9364        // column zero. Both spellings come out of the *enclosing item's* line.
9365        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
9366        md.caret = "- a\n  - b".len();
9367        md.newline();
9368        assert_eq!(md.source, "- a\n  - b\n  - \n");
9369        assert_eq!(list_items(&mut md), 3);
9370
9371        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
9372        dj.view = View::Wysiwyg;
9373        dj.build_visual(80);
9374        dj.caret = "- a\n  - b".len();
9375        dj.newline();
9376        assert_eq!(dj.source, "- a\n  - b\n- \n");
9377        assert_eq!(list_items(&mut dj), 2);
9378
9379        // Where Djot's nesting is real — opened by a blank line — the indent is
9380        // reproduced there too, and the two formats agree again.
9381        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
9382        dj.view = View::Wysiwyg;
9383        dj.build_visual(80);
9384        dj.caret = "- a\n\n  - b".len();
9385        dj.newline();
9386        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
9387        assert_eq!(list_items(&mut dj), 3);
9388    }
9389
9390    #[test]
9391    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
9392        // Tab replaces the line's whole prefix with the one twig spells, so the
9393        // quote markers, the parent's indent and an ordered marker's extra
9394        // column are all its answer rather than leaf's arithmetic.
9395        for (name, body, caret, want) in [
9396            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
9397            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
9398            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
9399            // A checkbox is markup the item's own text wraps past, but a nested
9400            // list may only open at the *list* marker's column — four in from
9401            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
9402            // parses as one item, not two.
9403            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
9404            (
9405                "quoted task",
9406                "> - [ ] a\n> - [ ] b\n",
9407                18,
9408                "> - [ ] a\n>   - [ ] b\n",
9409            ),
9410        ] {
9411            let mut doc = wysiwyg_doc(name, body);
9412            doc.caret = caret;
9413            doc.indent();
9414            assert_eq!(doc.source, want, "{name}");
9415            // The nesting is real, not just indented text.
9416            assert_eq!(list_items(&mut doc), 2, "{name}");
9417        }
9418    }
9419
9420    #[test]
9421    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
9422        // The same bytes, the two formats disagreeing, and a gesture that used
9423        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
9424        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
9425        // so those bytes are literal text inside item `a` — there is nothing to
9426        // outdent, and treating them as a marker turned one item into two, a
9427        // structural edit from a keystroke that should delete one character.
9428        //
9429        // twig's `line_prefix` is what tells them apart: it reports the marker
9430        // on the Markdown line and nothing on the Djot one, which is a
9431        // continuation. No byte scan can reach that answer.
9432        let src = "- a\n  - b\n";
9433        let at = "- a\n  - ".len();
9434
9435        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
9436        md.view = View::Wysiwyg;
9437        md.build_visual(80);
9438        md.caret = at;
9439        md.backspace();
9440        assert_eq!(md.source, "- a\n- b\n");
9441        assert_eq!(list_items(&mut md), 2);
9442
9443        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
9444        dj.view = View::Wysiwyg;
9445        dj.build_visual(80);
9446        dj.caret = at;
9447        dj.backspace();
9448        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
9449        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
9450    }
9451
9452    #[test]
9453    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
9454        // Leaf used to spell the next item from the marker bytes it scanned, and
9455        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
9456        // and dropped out of the checklist. twig reproduces the whole
9457        // continuation, and a fresh item is always unticked however the one above
9458        // it stands.
9459        for (name, body, want) in [
9460            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
9461            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
9462        ] {
9463            let mut doc = wysiwyg_doc(name, body);
9464            doc.caret = body.trim_end_matches('\n').len();
9465            doc.newline();
9466            assert_eq!(doc.source, want, "{name}");
9467            // Both items are checklist items — the new one is a box, not the
9468            // plain bullet the old marker scan left behind — and it is unticked
9469            // whichever way the one above it faces.
9470            let boxes: Vec<Option<bool>> = doc
9471                .nodes()
9472                .iter()
9473                .filter(|n| n.kind == Kind::TaskListItem)
9474                .map(|n| n.checked)
9475                .collect();
9476            assert_eq!(boxes.len(), 2, "{name}");
9477            assert_eq!(boxes[1], Some(false), "{name}");
9478        }
9479    }
9480
9481    #[test]
9482    fn a_split_takes_the_space_the_caret_was_in_front_of() {
9483        // Splicing a break at the caret strands the space the words were parted
9484        // at on the head of the second block, where it reads as an indent nobody
9485        // typed. twig's split consumes it.
9486        for (name, body, caret, want) in [
9487            ("para", "one two\n", 3, "one\n\ntwo\n"),
9488            ("item", "- one two\n", 5, "- one\n- two\n"),
9489            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
9490            // A heading takes leaf's own path, which has to match.
9491            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
9492        ] {
9493            let mut doc = wysiwyg_doc(name, body);
9494            doc.caret = caret;
9495            doc.newline();
9496            assert_eq!(doc.source, want, "{name}");
9497        }
9498    }
9499
9500    #[test]
9501    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
9502        // The one place leaf keeps its own break: `split_block` repeats the `#`,
9503        // and Enter after a title is how the body under it is asked for.
9504        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
9505        doc.caret = "# Title".len();
9506        doc.newline();
9507        doc.insert("body");
9508        assert_eq!(doc.source, "# Title\n\nbody\n");
9509        assert_eq!(
9510            doc.nodes()
9511                .iter()
9512                .filter(|n| n.kind == Kind::Heading)
9513                .count(),
9514            1
9515        );
9516    }
9517
9518    #[test]
9519    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
9520        // A quoted item's marker doesn't open its line, so a scan that starts at
9521        // column zero finds a `>` where it wanted a bullet, calls the line "not a
9522        // list" and hands Enter to the plain-quote branch — which writes `> ` and
9523        // drops the list. The next item has to carry the whole prefix.
9524        for (name, body, want) in [
9525            ("flat", "> - a\n", "> - a\n> - \n"),
9526            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
9527            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
9528            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
9529            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
9530        ] {
9531            let mut doc = wysiwyg_doc(name, body);
9532            doc.caret = body.trim_end_matches('\n').len();
9533            doc.newline();
9534            assert_eq!(doc.source, want, "{name}");
9535            // The marker isn't just spelled right, it parses as an item.
9536            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
9537        }
9538    }
9539
9540    #[test]
9541    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
9542        // Double-Enter exits the list. Unquoted that means a blank line, but a
9543        // *bare* blank line would end the quote too and drop the caret out of it,
9544        // so the separator keeps its `>` and the caret's line keeps its `> `.
9545        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
9546        doc.caret = "> - a\n> - ".len();
9547        doc.newline();
9548        assert_eq!(doc.source, "> - a\n>\n> \n");
9549        assert_eq!(list_items(&mut doc), 1);
9550        // What "still in the quote" means for the next keystroke: the caret sits
9551        // behind the prefix, and what's typed there lands inside the quote as a
9552        // paragraph of its own — not as more of item `a`.
9553        doc.insert("x");
9554        assert_eq!(doc.source, "> - a\n>\n> x\n");
9555        assert!(
9556            doc.editor
9557                .ancestors_at(doc.caret - 1)
9558                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
9559        );
9560    }
9561
9562    #[test]
9563    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
9564        // The marker is hidden block markup, so Backspace over it is structural —
9565        // but only the marker is the list's. Splicing from the line start would
9566        // take the `>` with it and silently unquote the line.
9567        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
9568        doc.caret = "> - ".len();
9569        doc.backspace();
9570        assert_eq!(doc.source, "> a\n");
9571        assert_eq!(list_items(&mut doc), 0);
9572
9573        // A nested one outdents instead, moving the bullet within the quote
9574        // rather than moving the quote.
9575        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
9576        doc.caret = "> - a\n>   - ".len();
9577        doc.backspace();
9578        assert_eq!(doc.source, "> - a\n> - b\n");
9579        assert_eq!(list_items(&mut doc), 2);
9580    }
9581
9582    #[test]
9583    fn only_a_bare_paragraph_is_parted_around_the_caret() {
9584        // The split is deliberately narrow. Parting a fenced block would leave
9585        // two fences with a rule between them, and parting a list item would
9586        // mint an item nobody asked for on the way to a rule that lands after
9587        // the list either way — so both keep the whole block intact and take the
9588        // rule after it. A caret in a quote is likewise left alone.
9589        for (name, body, caret, want) in [
9590            (
9591                "code",
9592                "```\nfn x() {}\n```\n",
9593                8,
9594                "```\nfn x() {}\n```\n\n---\n",
9595            ),
9596            ("list", "- one two\n", 6, "- one two\n\n---\n"),
9597            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
9598        ] {
9599            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
9600            d.caret = caret;
9601            d.insert_thematic_break();
9602            assert_eq!(d.source, want, "{name}: the block should stay whole");
9603        }
9604    }
9605
9606    #[test]
9607    fn insert_thematic_break_replaces_the_selection() {
9608        // Now that the rule lands *at* the caret again, replacing the selection
9609        // is coherent once more: the text goes, and the rule takes its place.
9610        // The space the deletion left leading the second half is consumed by the
9611        // split rather than opening the new paragraph with it.
9612        let mut d = doc_with("hr_sel", "one two three\n");
9613        d.anchor = Some(4);
9614        d.caret = 7; // "two"
9615        d.insert_thematic_break();
9616        assert_eq!(d.source, "one \n\n---\n\nthree\n");
9617        assert_eq!(d.selection(), None);
9618    }
9619
9620    #[test]
9621    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
9622        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
9623        // because writing `---` into one is code, not a rule — twig now walks out
9624        // to the block that owns the caret's line, so there is nothing to refuse.
9625        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
9626        code.caret = 5; // inside the fenced code
9627        code.insert_thematic_break();
9628        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
9629        assert_eq!(code.status, None, "no refusal to report any more");
9630
9631        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
9632        table.caret = 3; // in the header row
9633        table.insert_thematic_break();
9634        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
9635    }
9636
9637    #[test]
9638    fn insert_thematic_break_in_a_list_item_ends_the_list() {
9639        // The un-indented rule cannot continue the list, so it closes the list
9640        // and lands at the top level rather than nested inside it.
9641        let mut d = doc_with("hr_list", "- one\n- two\n");
9642        d.caret = "- one\n- tw".len(); // mid "two"
9643        d.insert_thematic_break();
9644        d.build_visual(80);
9645        let rule_at = d.source.find("---").unwrap();
9646        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
9647        assert!(
9648            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
9649                && n.span.start <= rule_at
9650                && rule_at < n.span.end),
9651            "the rule must not be nested inside the list"
9652        );
9653    }
9654
9655    #[test]
9656    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
9657        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
9658        // which is the document the gesture was actually asked for.
9659        let mut d = doc_with("hr_quote", "> hello\n");
9660        d.caret = 4; // inside the quoted text
9661        d.insert_thematic_break();
9662        assert_eq!(d.source, "> hello\n>\n> ---\n");
9663        d.build_visual(80);
9664        let rule_at = d.source.find("---").unwrap();
9665        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
9666        assert!(
9667            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
9668                && n.span.start <= rule_at
9669                && rule_at < n.span.end),
9670            "the rule belongs to the quote it was asked for"
9671        );
9672    }
9673
9674    // ── typing against a block picture ────────────────────────────────────────
9675
9676    /// A rendered-view document with the caret parked on one of the picture's two
9677    /// stops, and the map already built — the state a frontend is in between
9678    /// drawing a frame and the next keystroke.
9679    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
9680        let mut d = doc_in(View::Wysiwyg, name, src);
9681        d.build_visual_unwrapped();
9682        let start = src.find("![").unwrap();
9683        d.caret = match side {
9684            MediaStop::Before => start,
9685            MediaStop::After => start + "![](p.png)".len(),
9686        };
9687        d
9688    }
9689
9690    /// The block media the map publishes, after rebuilding it — "is this still a
9691    /// picture, or has it become a line of text with an image in it?"
9692    fn media_count(d: &mut Doc) -> usize {
9693        d.build_visual_unwrapped();
9694        d.vmap.media.len()
9695    }
9696
9697    #[test]
9698    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
9699        // The accident this prevents: tap the blank page under a photo (which
9700        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
9701        // paragraph with an *inline* image — the photo stops being drawn.
9702        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
9703        d.insert("xy");
9704        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
9705        assert_eq!(media_count(&mut d), 1, "still a picture");
9706    }
9707
9708    #[test]
9709    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
9710        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
9711        d.insert("xy");
9712        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
9713        assert_eq!(media_count(&mut d), 1);
9714    }
9715
9716    #[test]
9717    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
9718        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
9719        d.insert("x");
9720        assert_eq!(d.source, "x\n\n![](p.png)\n");
9721        assert_eq!(media_count(&mut d), 1);
9722    }
9723
9724    #[test]
9725    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
9726        // The opened paragraph is part of the keystroke, not an edit the writer
9727        // made — so it undoes with the character, not a step later.
9728        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
9729        d.insert("x");
9730        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
9731        d.undo();
9732        assert_eq!(d.source, "hi\n\n![](p.png)\n");
9733    }
9734
9735    #[test]
9736    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
9737        // ⌘V dissolves the picture exactly as a keystroke does.
9738        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
9739        d.paste("pasted");
9740        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
9741        assert_eq!(media_count(&mut d), 1);
9742    }
9743
9744    #[test]
9745    fn typing_beside_an_inline_image_is_ordinary_editing() {
9746        // An inline image has no placeholder row and no stops of its own. Opening
9747        // a paragraph mid-sentence would be the bug, not the fix.
9748        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
9749        d.build_visual_unwrapped();
9750        d.caret = "see ![](p.png)".len();
9751        d.insert("!");
9752        assert_eq!(d.source, "see ![](p.png)! here\n");
9753    }
9754
9755    #[test]
9756    fn source_view_types_raw_markup_against_an_image_untouched() {
9757        // Source view is for writing the markup itself; a break inserted behind
9758        // the writer's back there would be the editor arguing with them.
9759        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
9760        d.caret = "![](p.png)".len();
9761        d.insert("x");
9762        assert_eq!(d.source, "![](p.png)x\n");
9763    }
9764
9765    #[test]
9766    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
9767        // A selection is replaced, not joined into, so there is nothing to
9768        // protect: the range takes the picture with it.
9769        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
9770        d.anchor = Some(d.caret);
9771        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
9772        d.insert("x");
9773        assert_eq!(d.source, "hi\n\nx\n");
9774    }
9775
9776    #[test]
9777    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
9778        // What this actually cost: a real vault's photo, to one stray Backspace.
9779        // The caret past `![](p.png)` was deleting the closing paren — invisible
9780        // in the rendered view — and the photo became the text `![](p.png`.
9781        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
9782        d.backspace();
9783        assert_eq!(d.source, "hi\n");
9784        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
9785        d.undo();
9786        assert_eq!(
9787            d.source, "hi\n\n![](p.png)\n",
9788            "and comes back in one piece"
9789        );
9790    }
9791
9792    #[test]
9793    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
9794        // Deleting the break here would join the picture to the paragraph above,
9795        // where it is an *inline* image and stops being drawn. Step over the
9796        // boundary; the next press deletes in the paragraph the caret reached.
9797        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
9798        d.backspace();
9799        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
9800        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
9801        d.backspace();
9802        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
9803        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
9804    }
9805
9806    #[test]
9807    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
9808        // The mirror. A byte-step here eats the `!` and leaves a link.
9809        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
9810        d.delete_forward();
9811        assert_eq!(d.source, "hi\n\nbye\n");
9812        assert_eq!(media_count(&mut d), 0);
9813    }
9814
9815    #[test]
9816    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
9817        let mut d = doc_at_picture(
9818            "pic_del_after",
9819            "hi\n\n![](p.png)\n\nbye\n",
9820            MediaStop::After,
9821        );
9822        d.delete_forward();
9823        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
9824        assert_eq!(
9825            d.caret,
9826            d.source.find("bye").unwrap(),
9827            "the caret stepped down to `bye`"
9828        );
9829    }
9830
9831    #[test]
9832    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
9833        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
9834        d.backspace();
9835        assert_eq!(d.source, "\n");
9836        assert_eq!(media_count(&mut d), 0);
9837    }
9838
9839    #[test]
9840    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
9841        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
9842        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
9843        d.delete_word_back();
9844        assert_eq!(d.source, "hi there\n");
9845
9846        // And in front of one it runs *through* the paragraph break into the
9847        // prose above, which merges the picture inline — so it steps out first,
9848        // and the second press deletes the word it was aimed at.
9849        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
9850        d.delete_word_back();
9851        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
9852        d.delete_word_back();
9853        assert_eq!(
9854            d.source, "hi \n\n![](p.png)\n",
9855            "the word above went, the picture stayed"
9856        );
9857        assert_eq!(media_count(&mut d), 1);
9858    }
9859
9860    #[test]
9861    fn source_view_deletes_raw_markup_against_an_image_untouched() {
9862        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
9863        d.caret = "![](p.png)".len();
9864        d.backspace();
9865        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
9866    }
9867
9868    #[test]
9869    fn image_destination_at_caret_reads_the_image_under_the_caret() {
9870        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
9871        d.caret = 3; // inside the image markup
9872        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
9873        // Past the image, the caret is in no image.
9874        d.caret = "![a cat](cat.png)".len();
9875        assert_eq!(d.image_destination_at_caret(), None);
9876    }
9877
9878    #[test]
9879    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
9880        // The image is one placeholder row by default, and `set_media_rows` grows
9881        // it to the height the frontend measured: the label row plus blank
9882        // `decoration` fillers that hold the vertical space a raster is drawn into.
9883        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
9884        assert_eq!(d.vmap.media.len(), 1);
9885        let img_row = d.vmap.media[0].rows_span.start;
9886        assert_eq!(
9887            d.vmap.media[0].rows_span,
9888            img_row..img_row + 1,
9889            "default is one row"
9890        );
9891
9892        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
9893        d.build_visual(80);
9894        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
9895        let span = d.vmap.media[0].rows_span.clone();
9896        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
9897        // The label row carries the mark and its glyphs; the three below are blank
9898        // decoration — drawn, but no caret and no text.
9899        assert!(
9900            d.vmap.rows[span.start].media.is_some(),
9901            "mark rides the first row"
9902        );
9903        for r in (span.start + 1)..span.end {
9904            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
9905            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
9906            assert!(
9907                d.vmap.rows[r].media.is_none(),
9908                "only the first row is marked"
9909            );
9910        }
9911    }
9912
9913    #[test]
9914    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
9915        // The extra rows are pure spacers: the caret's only homes stay the stop in
9916        // front of the image and the one just past it, so walking the document top
9917        // to bottom visits the same offsets whether the image is 1 row or 5.
9918        let body = "ab\n\n![x](p.png)\n\ncd\n";
9919        let stops_at = |rows: usize| -> Vec<usize> {
9920            let mut d = wysiwyg_doc("img_stops", body);
9921            if rows > 1 {
9922                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
9923                d.build_visual(80);
9924            }
9925            d.caret = 0;
9926            let mut seen = vec![d.caret];
9927            loop {
9928                d.move_right(false);
9929                if *seen.last().unwrap() == d.caret {
9930                    break;
9931                }
9932                seen.push(d.caret);
9933            }
9934            seen
9935        };
9936        assert_eq!(
9937            stops_at(1),
9938            stops_at(5),
9939            "reserving rows must not add stops"
9940        );
9941    }
9942
9943    #[test]
9944    fn insert_link_repoints_the_link_at_a_bare_caret() {
9945        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
9946        d.caret = 3; // in the link's text, nothing selected
9947        d.insert_link("http://y.dev");
9948        assert_eq!(d.source, "[word](http://y.dev)\n");
9949        assert_eq!(d.selected_text(), Some("word"));
9950    }
9951
9952    #[test]
9953    fn insert_link_on_an_empty_range_autolinks_a_url() {
9954        // A link with no text of its own is an autolink, and twig spells it —
9955        // `<…>` is the canonical form and needs no text typed into it, so the
9956        // caret lands after it rather than selecting a finished link.
9957        let mut d = doc_with("link_empty", "\n");
9958        d.caret = 0;
9959        d.insert_link("http://x.dev");
9960        assert_eq!(d.source, "<http://x.dev>\n");
9961        assert_eq!(d.selection(), None);
9962        assert_eq!(d.caret, 14);
9963    }
9964
9965    #[test]
9966    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
9967        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
9968        // in Markdown, so a destination that can't autolink doubles as the text
9969        // instead — which is then selected, ready to be typed over.
9970        let mut d = doc_with("link_rel", "\n");
9971        d.caret = 0;
9972        d.insert_link("./notes.md");
9973        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
9974        assert_eq!(d.selection(), Some((1, 11)));
9975        d.insert("Notes");
9976        assert_eq!(d.source, "[Notes](./notes.md)\n");
9977    }
9978
9979    #[test]
9980    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
9981        // The autolink's text is its URL, so re-pointing replaces the whole
9982        // node — the caret must not splice a second link inside the first.
9983        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
9984        d.caret = 10;
9985        d.insert_link("https://y.dev");
9986        assert_eq!(d.source, "see <https://y.dev> ok\n");
9987    }
9988
9989    #[test]
9990    fn code_language_reads_and_edits_through_the_fence() {
9991        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
9992        d.caret = 10; // inside the code body
9993        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
9994        assert!(d.caret_in_fenced_code());
9995
9996        d.set_code_language("python");
9997        assert!(
9998            d.source.starts_with("```python\n"),
9999            "source: {:?}",
10000            d.source
10001        );
10002        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
10003
10004        // Clearing it leaves a bare fence and no label.
10005        d.set_code_language("");
10006        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
10007        assert_eq!(d.code_language_at_caret(), None);
10008
10009        // A caret outside any code block edits nothing.
10010        let mut p = doc_with("code_lang_none", "just prose\n");
10011        assert!(!p.caret_in_fenced_code());
10012        p.set_code_language("rust");
10013        assert_eq!(p.source, "just prose\n");
10014    }
10015
10016    #[test]
10017    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
10018        // Markdown's info string ends at whitespace, so `two words` would write
10019        // a fence that reads back with a different language than the one asked
10020        // for. twig refuses it; leaf reports that and leaves the source alone.
10021        // The old splice trimmed the ends and wrote whatever was left.
10022        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
10023        d.caret = 10;
10024        d.set_code_language("two words");
10025        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
10026        assert!(d.status.is_some(), "the refusal should be reported");
10027        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10028    }
10029
10030    #[test]
10031    fn link_destination_at_caret_reads_both_spellings() {
10032        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
10033        d.caret = 5;
10034        assert_eq!(
10035            d.link_destination_at_caret().as_deref(),
10036            Some("https://x.dev")
10037        );
10038        d.caret = 0;
10039        assert_eq!(d.link_destination_at_caret(), None);
10040
10041        // An autolink has no `destination`; its text is the URL.
10042        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
10043        a.caret = 10;
10044        assert_eq!(
10045            a.link_destination_at_caret().as_deref(),
10046            Some("https://x.dev")
10047        );
10048        a.caret = 21;
10049        assert_eq!(a.link_destination_at_caret(), None);
10050    }
10051
10052    #[test]
10053    fn locate_finds_the_block_a_declared_id_names() {
10054        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
10055        // verse. The locator has to land on the *verse*, which is the whole
10056        // reason a link carries one.
10057        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
10058                   {#v2}\nYea, I make a record in the language of my father.\n";
10059        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10060        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
10061        assert_eq!(
10062            d.source[v2.start..v2.end].trim_end(),
10063            "Yea, I make a record in the language of my father."
10064        );
10065        // The attribute line is not part of it: `start` is a place to put a
10066        // caret, and `{#v2}` is markup the caret has no business landing in.
10067        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
10068        assert_eq!(d.locate("v99"), None);
10069    }
10070
10071    #[test]
10072    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
10073        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
10074        // Markdown heading is literal text. So `#the-second-part` can only be
10075        // the heading's own words, which is the rule every Markdown renderer
10076        // already follows and therefore the one a link was authored against.
10077        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
10078        let mut d = doc_with("locate_md", src);
10079        let hit = d.locate("the-second-part").expect("the heading's slug");
10080        assert!(d.source[hit.start..].starts_with("## The Second Part"));
10081        // Bounded by the next heading that isn't under it, so a peek shows the
10082        // section rather than only its title.
10083        assert_eq!(
10084            &d.source[hit.start..hit.end],
10085            "## The Second Part\n\nbody\n\n"
10086        );
10087
10088        // A subsection does not end its parent: `# Title` runs to `## Third`'s
10089        // sibling only because there is no other `#`, so it covers the lot.
10090        let title = d.locate("title").expect("the top heading");
10091        assert_eq!(title.end, d.source.len());
10092    }
10093
10094    #[test]
10095    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
10096        // djot mints `Some-Heading-Here`; a link to it is written
10097        // `#some-heading-here` by nearly everything that writes links. Both
10098        // spellings are one question.
10099        let src = "## Some Heading Here\n\nbody\n";
10100        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10101        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
10102        let slugged = d.locate("some-heading-here").expect("the link's spelling");
10103        assert_eq!(exact, slugged);
10104        // The section, not the heading line — there is more to show than a title.
10105        assert_eq!(&d.source[exact.start..exact.end], src);
10106    }
10107
10108    #[test]
10109    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
10110        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
10111        assert_eq!(d.locate(""), None);
10112        assert_eq!(d.locate("   "), None);
10113        // All punctuation: it names nothing, and must not be read as "match the
10114        // first heading whose slug is also empty".
10115        assert_eq!(d.locate("!!!"), None);
10116    }
10117
10118    #[test]
10119    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
10120        // The document's mistake, and the answer every other anchor
10121        // implementation gives — the alternative is for a link to mean whichever
10122        // of the two a walk happened to reach first.
10123        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
10124        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10125        let hit = d.locate("dup").expect("the first `{#dup}`");
10126        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
10127    }
10128
10129    #[test]
10130    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
10131        // The button's whole job: a reference where the caret was, a definition
10132        // to give it meaning, and the caret waiting in the empty note so the
10133        // next keystroke is the note's first word.
10134        let mut d = doc_with("fn_insert", "A claim and more.\n");
10135        d.caret = 7; // just past "A claim"
10136        d.insert_footnote();
10137        assert!(
10138            d.source.starts_with("A claim[^1] and more."),
10139            "{:?}",
10140            d.source
10141        );
10142        assert!(
10143            d.source.contains("[^1]:"),
10144            "the definition too: {:?}",
10145            d.source
10146        );
10147        assert_eq!(d.status, None);
10148
10149        let reference = d.source.find("[^1]").unwrap();
10150        let note = d
10151            .footnote_at(reference + 2)
10152            .expect("the reference just written");
10153        assert_eq!(note.label, "1");
10154        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
10155        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
10156        // …and typing there is typing into the note, not near it.
10157        d.insert("the note");
10158        assert_eq!(
10159            d.footnote_at(reference + 2).and_then(|f| f.text),
10160            Some("the note".to_string())
10161        );
10162    }
10163
10164    #[test]
10165    fn insert_footnote_numbers_past_the_notes_already_written() {
10166        // A second press must not hand back a label somebody else is using: twig
10167        // reuses a defined label rather than appending a rival definition, so a
10168        // repeat of `1` would quietly point the new reference at the old note.
10169        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
10170        d.caret = 7; // past `[^1]`, before " two."
10171        d.insert_footnote();
10172        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
10173        assert_eq!(d.source.matches("[^2]:").count(), 1);
10174    }
10175
10176    #[test]
10177    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
10178        // `[^2]` with no definition is still a 2 that means something to whoever
10179        // wrote it — stepping over it would mint a note for their reference. A
10180        // word label takes no number, so it blocks none.
10181        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
10182        d.caret = d.source.find(" c").unwrap();
10183        d.insert_footnote();
10184        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
10185        assert!(
10186            d.source.starts_with("a[^2] b[^why][^1] c"),
10187            "{:?}",
10188            d.source
10189        );
10190    }
10191
10192    #[test]
10193    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
10194        // A reference annotates the words before it. Consuming the selection —
10195        // which is what an insert normally does — would delete the very claim
10196        // the author selected in order to footnote.
10197        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
10198        d.anchor = Some(2);
10199        d.caret = 7; // "claim" selected
10200        d.insert_footnote();
10201        assert!(
10202            d.source.starts_with("A claim[^1] and more."),
10203            "{:?}",
10204            d.source
10205        );
10206    }
10207
10208    #[test]
10209    fn a_note_just_written_still_knows_where_its_reference_is() {
10210        // The authoring loop in one test: press the button, type the note, ask to
10211        // go back. The caret ends at the note's last byte — which is the *end* of
10212        // the definition's span, the one offset the query used to exclude — so
10213        // this is where the round trip either works or doesn't.
10214        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
10215        d.caret = 7;
10216        d.insert_footnote();
10217        d.insert("the note");
10218        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
10219        let back = d
10220            .footnote_definition_at_caret()
10221            .expect("still in the note we just typed");
10222        assert_eq!(back.label, "1");
10223        // …and following it lands on the reference's label, where a reader's
10224        // return leg lands.
10225        assert_eq!(back.offset, Some(9));
10226        assert_eq!(&d.source[9..10], "1");
10227    }
10228
10229    #[test]
10230    fn insert_footnote_takes_one_undo_for_both_halves() {
10231        // twig writes the pair as a single edit; the point of that is here.
10232        let before = "A claim and more.\n";
10233        let mut d = doc_with("fn_insert_undo", before);
10234        d.caret = 7;
10235        d.insert_footnote();
10236        assert_ne!(d.source, before);
10237        d.undo();
10238        assert_eq!(d.source, before, "one undo takes back both halves");
10239    }
10240
10241    #[test]
10242    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
10243        // HTML is authorable — it spells the inline marks — and has no footnote.
10244        // The refusal says so rather than writing brackets that would render as
10245        // brackets.
10246        let src = "<p>A claim.</p>\n";
10247        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
10248        assert!(!Capabilities::of(Format::Html).footnote);
10249        d.caret = 5;
10250        d.insert_footnote();
10251        assert_eq!(d.source, src, "nothing written");
10252        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
10253    }
10254
10255    #[test]
10256    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
10257        // The empty body is the one place this could go wrong: the definition
10258        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
10259        // byte early would draw up in the paragraph above the note it belongs to.
10260        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
10261        d.place_caret(7, false);
10262        d.insert_footnote();
10263        d.build_visual(80); // the frame a frontend draws after the edit
10264        assert_eq!(
10265            d.vmap.snap_to_stop(d.caret),
10266            d.caret,
10267            "the caret sits on a stop"
10268        );
10269        let (row, _) = d.caret_pos();
10270        assert!(
10271            drawn_rows(&d)[row].contains("[1]"),
10272            "the caret is on the note's row, not above it: {:?}",
10273            drawn_rows(&d)
10274        );
10275    }
10276
10277    #[test]
10278    fn footnote_at_caret_resolves_a_reference_to_its_note() {
10279        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
10280        // blank line, as one has to.
10281        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
10282        d.caret = 9;
10283        let f = d
10284            .footnote_at_caret()
10285            .expect("the caret stands in a reference");
10286        assert_eq!(f.label, "1");
10287        assert_eq!(f.text.as_deref(), Some("the note"));
10288        // The offset points at the note's first word, not at the definition's
10289        // `[` — the marker is decoration with no caret stop on it.
10290        assert_eq!(f.offset, Some(29));
10291        assert_eq!(&d.source[29..37], "the note");
10292        // …and `end` closes the range, so a frontend can ask which rendered rows
10293        // the note occupies rather than re-deriving them from the text.
10294        assert_eq!(f.end, Some(37));
10295        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
10296    }
10297
10298    /// Two definitions in a row: each is its own note, and neither reaches into
10299    /// the other.
10300    ///
10301    /// A djot definition's span used to run past the blank line into the first
10302    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
10303    /// offsets named the *next* note's rows too, showing a reader two footnotes
10304    /// when they had asked about one. twig 3.1 ends the span after the block's
10305    /// own last line; the test outlives the workaround leaf carried for it.
10306    #[test]
10307    fn footnote_at_stops_a_note_at_the_definition_after_it() {
10308        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
10309        for format in [Format::Markdown, Format::Djot] {
10310            let mut d = Doc::from_source(src.to_string(), format).unwrap();
10311            d.caret = 7;
10312            let f = d.footnote_at_caret().expect("a reference");
10313            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
10314            assert_eq!(
10315                &src[f.offset.unwrap()..f.end.unwrap()],
10316                "first note.",
10317                "in {format:?}"
10318            );
10319        }
10320    }
10321
10322    /// The other side of that boundary: a blank line *inside* a definition is
10323    /// interior to it, and the note keeps its second paragraph.
10324    ///
10325    /// This is what the old body scan cost. It stopped at the first line not
10326    /// indented under the note — a blank line is not — so a two-paragraph note
10327    /// came back as its first paragraph, and "go to note" framed half of it.
10328    /// Reading the span twig gives is both simpler and right.
10329    #[test]
10330    fn footnote_at_keeps_a_notes_second_paragraph() {
10331        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
10332        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10333        d.caret = 7;
10334        let f = d.footnote_at_caret().expect("a reference");
10335        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
10336        // And it stops there — `After.` is the next block, not more note.
10337        assert_eq!(
10338            &src[f.offset.unwrap()..f.end.unwrap()],
10339            f.text.as_deref().unwrap()
10340        );
10341        assert!(!f.text.as_deref().unwrap().contains("After"));
10342    }
10343
10344    #[test]
10345    fn footnote_at_bounds_a_note_whose_body_is_empty() {
10346        // `[^1]:` with nothing after it. The range is empty rather than
10347        // inverted, and still points inside the definition — which is what keeps
10348        // a frontend's row lookup from walking off into the block above.
10349        let src = "A claim[^1].\n\n[^1]:\n";
10350        let mut d = doc_with("fn_empty_body", src);
10351        d.caret = 9;
10352        let f = d.footnote_at_caret().expect("a reference");
10353        assert_eq!(f.text.as_deref(), Some(""));
10354        assert_eq!(f.offset, f.end, "an empty note is an empty range");
10355        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
10356    }
10357
10358    #[test]
10359    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
10360        let mut d = doc_with(
10361            "fn_at_caret_none",
10362            "A claim[^1] and more.\n\n[^1]: the note\n",
10363        );
10364        d.caret = 2; // in the prose
10365        assert_eq!(d.footnote_at_caret(), None);
10366    }
10367
10368    #[test]
10369    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
10370        // The two are deliberately separate: a reference names a note in this
10371        // document, a link names somewhere to leave for, and answering one with
10372        // the other is what made a reference click do nothing at all.
10373        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
10374        d.caret = 3; // the `1` of `[^1]`
10375        assert!(d.footnote_at_caret().is_some());
10376        assert_eq!(
10377            d.link_destination_at_caret(),
10378            None,
10379            "a reference is not a link"
10380        );
10381
10382        d.caret = 10; // inside the link's label
10383        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
10384        assert_eq!(
10385            d.link_destination_at_caret().as_deref(),
10386            Some("https://x.dev")
10387        );
10388    }
10389
10390    #[test]
10391    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
10392        // A `[^99]` the document never defines is a real state — a note deleted
10393        // out from under its reference — and the label is what lets a frontend
10394        // say so. `None` here would be indistinguishable from "not on a
10395        // reference", which is the wrong thing to tell a reader.
10396        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
10397        d.caret = 9;
10398        let f = d
10399            .footnote_at_caret()
10400            .expect("the reference is still a reference");
10401        assert_eq!(f.label, "99");
10402        assert_eq!(f.text, None);
10403        assert_eq!(f.offset, None);
10404    }
10405
10406    #[test]
10407    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
10408        // Labels are not always numbers, and a note's body runs past its first
10409        // line — the indented continuation belongs to the note, so it comes back
10410        // with it (source bytes, verbatim, as documented).
10411        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
10412        let mut d = doc_with("fn_word_label", src);
10413        d.caret = 6;
10414        let f = d
10415            .footnote_at_caret()
10416            .expect("the caret stands in a reference");
10417        assert_eq!(f.label, "note");
10418        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
10419    }
10420
10421    #[test]
10422    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
10423        // The point of the offset form: a pointer hovering a reference asks what
10424        // note it names, and must not drag the caret along to ask.
10425        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
10426        d.caret = 0;
10427        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
10428        assert_eq!(f.label, "1");
10429        assert_eq!(f.text.as_deref(), Some("the note"));
10430        assert_eq!(d.caret, 0, "asking must not move the caret");
10431        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
10432    }
10433
10434    #[test]
10435    fn footnote_definition_at_caret_points_back_at_the_reference() {
10436        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
10437        // the caret can rest on — is at 9.
10438        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
10439        d.caret = 30; // inside the note's body
10440        let f = d
10441            .footnote_definition_at_caret()
10442            .expect("the caret stands in a definition");
10443        assert_eq!(f.label, "1");
10444        assert_eq!(f.offset, Some(9));
10445        assert_eq!(&d.source[7..11], "[^1]");
10446    }
10447
10448    #[test]
10449    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
10450        // The two legs have to meet: wherever `footnote_at` sends the caret, the
10451        // definition query must answer for — otherwise arriving at a note leaves
10452        // the reader somewhere the way back isn't offered.
10453        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
10454        let mut d = doc_with("fn_def_marker", src);
10455        let landed = d.footnote_at(9).unwrap().offset.unwrap();
10456        assert_eq!(
10457            d.footnote_definition_at(landed).and_then(|f| f.offset),
10458            Some(9),
10459            "the note a reference sends you to offers the way back"
10460        );
10461    }
10462
10463    #[test]
10464    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
10465        // The two queries answer for disjoint places, which is what lets one
10466        // gesture mean "down to the note" in one and "back up" in the other
10467        // without either having to remember which way the reader is going.
10468        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
10469        d.caret = 2; // prose
10470        assert_eq!(d.footnote_definition_at_caret(), None);
10471        d.caret = 9; // the reference
10472        assert_eq!(d.footnote_definition_at_caret(), None);
10473        assert!(
10474            d.footnote_at_caret().is_some(),
10475            "which is the reference's own query"
10476        );
10477    }
10478
10479    #[test]
10480    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
10481        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
10482        // note", which is false and leaves a frontend unable to explain why the
10483        // way back is missing.
10484        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
10485        let mut d = doc_with("fn_def_orphan", src);
10486        d.caret = src.find("orphan").unwrap();
10487        let f = d
10488            .footnote_definition_at_caret()
10489            .expect("an orphan is still a definition");
10490        assert_eq!(f.label, "2");
10491        assert_eq!(f.offset, None);
10492    }
10493
10494    #[test]
10495    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
10496        // One label, cited twice. The first is where the reader most likely came
10497        // from, and the only answer that doesn't depend on how they got here.
10498        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
10499        let mut d = doc_with("fn_def_repeat", src);
10500        d.caret = src.find("the note").unwrap();
10501        let f = d.footnote_definition_at_caret().expect("a definition");
10502        assert_eq!(
10503            f.offset,
10504            Some(5),
10505            "the first `[^a]`'s label, not the second's"
10506        );
10507        assert_eq!(&src[3..7], "[^a]");
10508    }
10509
10510    #[test]
10511    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
10512        // Down and back up, each leg found from the document rather than from a
10513        // memory of the other — so it still works for a reader who scrolled to
10514        // the notes instead of jumping there.
10515        //
10516        // `place_caret` rather than assigning `caret`, because that is what a
10517        // frontend calls: it snaps to a real caret stop, and a jump that lands
10518        // on a byte the caret can't rest on would arrive somewhere the return
10519        // leg no longer answers for. `build_map` first, since snapping is a
10520        // no-op until the map exists — which is exactly how this went unnoticed
10521        // when the offsets pointed at the `[^` markers.
10522        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
10523        d.build_map(None);
10524        d.place_caret(9, false);
10525        let down = d
10526            .footnote_at_caret()
10527            .expect("a reference")
10528            .offset
10529            .expect("a note");
10530        d.place_caret(down, false);
10531        let up = d
10532            .footnote_definition_at_caret()
10533            .expect("a definition")
10534            .offset
10535            .expect("a reference");
10536        d.place_caret(up, false);
10537        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
10538        assert_eq!(
10539            d.footnote_at_caret().expect("back on the reference").label,
10540            "1"
10541        );
10542    }
10543
10544    #[test]
10545    fn insert_link_hands_the_destination_to_twig_raw() {
10546        // Escaping is twig's, and format-specific: Markdown ends a destination
10547        // at the first space and needs the `<…>` form, where djot would read
10548        // those angle brackets as part of the URL.
10549        let mut d = doc_with("link_space", "word\n");
10550        d.anchor = Some(0);
10551        d.caret = 4;
10552        d.insert_link("a b");
10553        assert_eq!(d.source, "[word](<a b>)\n");
10554    }
10555
10556    #[test]
10557    fn insert_link_reports_a_destination_no_format_can_carry() {
10558        let mut d = doc_with("link_bad", "word\n");
10559        d.anchor = Some(0);
10560        d.caret = 4;
10561        d.insert_link("a\nb");
10562        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
10563        assert!(
10564            d.status.is_some(),
10565            "InvalidArgument should reach the status line"
10566        );
10567        assert!(!d.dirty);
10568    }
10569
10570    #[test]
10571    fn insert_link_works_in_wysiwyg_view() {
10572        let mut d = wysiwyg_doc("link_wys", "word here\n");
10573        d.anchor = Some(0);
10574        d.caret = 4;
10575        d.insert_link("http://x.dev");
10576        assert_eq!(d.source, "[word](http://x.dev) here\n");
10577        assert_eq!(d.selected_text(), Some("word"));
10578        // The map the caret has to keep riding is rebuilt each frame; motion
10579        // over the fresh one must still land on a real stop (the debug_assert).
10580        d.build_visual(80);
10581        d.move_right(false);
10582        d.move_left(false);
10583    }
10584
10585    #[test]
10586    fn click_maps_a_row_col_to_a_byte_offset() {
10587        let mut d = doc_with("click", "ab\ncd\n");
10588        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
10589        assert_eq!(d.caret, 4);
10590    }
10591
10592    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
10593    // stop just as the `(row, col)` click path does, so the caret can never come
10594    // to rest in the blank gap between two paragraphs — where it would draw in one
10595    // place and type in another.
10596    #[test]
10597    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
10598        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
10599        // caret stop (stops are 0,1,3,4).
10600        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
10601        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
10602        d.place_caret(2, false);
10603        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
10604        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
10605    }
10606
10607    #[test]
10608    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
10609        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
10610        d.place_caret(0, false); // anchor at the start of "A"
10611        d.place_caret(2, true); // drag into the gap
10612        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
10613        let (s, e) = d.selection().expect("a selection");
10614        assert!(
10615            d.vmap.is_stop(s) && d.vmap.is_stop(e),
10616            "selection {s}..{e} off a stop"
10617        );
10618    }
10619
10620    #[test]
10621    fn place_caret_on_a_real_stop_is_left_untouched() {
10622        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
10623        d.place_caret(3, false); // the start of "B" — a genuine stop
10624        assert_eq!(d.caret, 3);
10625    }
10626
10627    // An *empty paragraph* (two blank lines, an intentional blank line the user
10628    // opened) is a real caret stop, unlike the gap — a click into it must stay.
10629    #[test]
10630    fn place_caret_rests_in_an_empty_paragraph() {
10631        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
10632        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
10633        assert!(d.vmap.is_stop(empty));
10634        d.place_caret(empty, false);
10635        assert_eq!(d.caret, empty);
10636    }
10637
10638    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
10639    // a drag over the word `bold` ends there, and a caret placed there stays.
10640    #[test]
10641    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
10642        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
10643        let mut d = wysiwyg_doc("place_mark_end", src);
10644        let start = src.find("bold").unwrap();
10645        d.place_caret(start, false);
10646        d.place_caret(start + 4, true);
10647        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
10648        d.toggle(InlineKind::Strong);
10649        assert_eq!(d.source, src.replace("**bold**", "bold"));
10650    }
10651
10652    #[test]
10653    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
10654        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
10655        d.caret = 7; // before the `d`
10656        d.move_right(false);
10657        assert_eq!(d.caret, 8, "onto the end of the bold");
10658        assert!(d.active_inline_marks().contains(InlineKind::Strong));
10659        d.move_right(false);
10660        assert_eq!(d.caret, 10, "past the closing `**`");
10661        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
10662        d.move_left(false);
10663        assert_eq!(d.caret, 8);
10664        d.move_left(false);
10665        assert_eq!(d.caret, 7);
10666        // Typing at the inner home extends the bold.
10667        d.caret = 8;
10668        d.insert("!");
10669        assert_eq!(d.source, "a **bold!** b");
10670    }
10671
10672    #[test]
10673    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
10674        // The splice path shifts the home with the block it is in, and the
10675        // re-rendered block finds its own again.
10676        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
10677        d.build_visual_unwrapped();
10678        d.edit(0, 0, "zz");
10679        d.build_visual_unwrapped();
10680        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
10681        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
10682        let at = d.source.find("bold").unwrap();
10683        d.edit(at, at, "very ");
10684        d.build_visual_unwrapped();
10685        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
10686        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
10687    }
10688
10689    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
10690        doc_in(View::Wysiwyg, name, body)
10691    }
10692
10693    /// How many list items the source actually parses into — the check that a
10694    /// marker Leaf wrote is a marker the format agrees is one.
10695    fn list_items(doc: &mut Doc) -> usize {
10696        doc.editor
10697            .nodes()
10698            .unwrap()
10699            .iter()
10700            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
10701            .count()
10702    }
10703
10704    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
10705    /// incremental (`build_spliced` / `build_cached`) path must always match.
10706    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
10707        reference_map_revealing(source, None)
10708    }
10709
10710    /// [`reference_map`] with a reveal line — the ground truth for the
10711    /// `MarkupMode::Full` builds, where the map is a function of the caret's
10712    /// line as well as the text.
10713    fn reference_map_revealing(
10714        source: &str,
10715        reveal: Option<Range<usize>>,
10716    ) -> crate::wysiwyg::VisualMap {
10717        // The same parse `Doc` uses. With twig's plain defaults instead, the two
10718        // sides disagree on what the *document* is before the renderer is even
10719        // reached — a bare `:word` is a text directive to one and prose to the
10720        // other — and the mismatch reads as a splice bug that isn't one.
10721        let mut ed =
10722            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
10723        let nodes = ed.nodes().unwrap();
10724        crate::wysiwyg::build(
10725            &nodes,
10726            source,
10727            None,
10728            false,
10729            &std::collections::HashMap::new(),
10730            reveal,
10731        )
10732    }
10733
10734    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
10735        if a.rows.len() != b.rows.len() {
10736            return true;
10737        }
10738        for (ra, rb) in a.rows.iter().zip(&b.rows) {
10739            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
10740                return true;
10741            }
10742            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
10743                if ga.ch != gb.ch || ga.src != gb.src {
10744                    return true;
10745                }
10746            }
10747        }
10748        false
10749    }
10750
10751    #[test]
10752    fn incremental_build_matches_a_fresh_build_across_edits() {
10753        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
10754        // fast path, gated on twig's `dirty_range`) or falls back to
10755        // `build_cached`. After each edit the map must be byte-identical to a
10756        // from-scratch build — this is the correctness net under the splice.
10757        let docs = [
10758            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
10759            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
10760            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
10761            // A footnote definition is a root beside `doc`, merged back into the
10762            // top-level list by `wysiwyg::top_blocks`. The random edits below
10763            // make and unmake definitions as they go (a deleted `:` turns one
10764            // back into a paragraph, and vice versa), which is exactly the
10765            // structural churn the splice path has to notice and bail out of.
10766            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
10767            // A comment is a top-level block that draws no rows — a layout entry
10768            // at zero rows either side of blocks that do. The edits below type
10769            // into the blocks around it (a splice past a hidden block), and
10770            // break the comment open into prose and back (a structural change).
10771            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
10772            // Link reference definitions: a hidden block that an edit can turn
10773            // into a paragraph (a deleted `:`) and back, and whose own bytes an
10774            // edit can land in.
10775            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
10776        ];
10777        // A deterministic mix: mostly single characters (which stay inside one
10778        // block → splice), plus edits that reshape structure (a paragraph break,
10779        // a heading marker, a code fence → fallback), so both paths are exercised.
10780        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
10781        for src in docs {
10782            let mut d = wysiwyg_doc("diff", src);
10783            d.build_visual_unwrapped();
10784            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
10785
10786            for step in 0..60usize {
10787                let len = d.source.len();
10788                let raw = (step * 13 + 5) % (len + 1);
10789                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
10790                let pre = d.source.clone();
10791                let action;
10792                if step % 3 == 0 && pos < len {
10793                    let end = (pos + 1..=len)
10794                        .find(|&i| d.source.is_char_boundary(i))
10795                        .unwrap();
10796                    action = format!("delete [{pos},{end})");
10797                    d.edit(pos, end, "");
10798                } else {
10799                    let ins = inserts[step % inserts.len()];
10800                    action = format!("insert {ins:?} @ {pos}");
10801                    d.edit(pos, pos, ins);
10802                }
10803                d.build_visual_unwrapped();
10804                if maps_differ(&d.vmap, &reference_map(&d.source)) {
10805                    panic!(
10806                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
10807                        d.source
10808                    );
10809                }
10810            }
10811        }
10812    }
10813
10814    /// A frontend is handed [`Doc::vmap`] and may present it differently:
10815    /// leaf-ratatui splices blank filler rows under an oversized heading so the
10816    /// raster it paints there has somewhere to stand, and leaves them in the map
10817    /// because the caret and the mouse both read it between frames. The splice
10818    /// path addresses that map by *row index*, against the block layout the last
10819    /// build recorded — so handed a map with rows in it that no block owns, it
10820    /// laid the re-rendered block over one of the fillers and carried the rows
10821    /// the block really occupied into the suffix. One stranded copy of the
10822    /// edited line, and everything below it a row further down, per keystroke.
10823    ///
10824    /// A map that isn't the one the layout describes is a map this path can't
10825    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
10826    #[test]
10827    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
10828        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
10829        d.build_visual_unwrapped();
10830
10831        // Stand in for the heading filler rows: two blank rows past the heading
10832        // that no block accounts for. Cloning a real row keeps every field
10833        // plausible — it is the row *count* the splice can't survive.
10834        let filler = d.vmap.rows[0].clone();
10835        d.vmap.rows.insert(1, filler.clone());
10836        d.vmap.rows.insert(1, filler);
10837
10838        // An edit inside the last block: the single-block case the splice path
10839        // is for, and the one the frontend hits on every keystroke.
10840        let at = d.source.len() - 1;
10841        d.edit(at, at, "!");
10842        d.build_visual_unwrapped();
10843
10844        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
10845    }
10846
10847    #[test]
10848    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
10849        // The same correctness net as `incremental_build_matches_a_fresh_build_
10850        // across_edits`, under `MarkupMode::Full` — where the map depends on
10851        // the caret's *line* as well as the text, so the two caches have a new
10852        // way to be wrong. Both are exercised: the block cache can hand back
10853        // rows built for a line that is no longer the revealed one, and the
10854        // splice path can reuse a suffix that still has yesterday's line raw.
10855        //
10856        // Caret motion is interleaved with the edits deliberately, because a
10857        // caret that only ever moved with the edit would never cross a line
10858        // without also dirtying it — the case where a stale reveal survives.
10859        let docs = [
10860            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
10861            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
10862        ];
10863        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
10864        for src in docs {
10865            let mut d = wysiwyg_doc("reveal_diff", src);
10866            d.set_markup_mode(MarkupMode::Full);
10867
10868            for step in 0..60usize {
10869                let len = d.source.len();
10870                let raw = (step * 13 + 5) % (len + 1);
10871                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
10872                let pre = d.source.clone();
10873                let action;
10874                if step % 3 == 0 && pos < len {
10875                    let end = (pos + 1..=len)
10876                        .find(|&i| d.source.is_char_boundary(i))
10877                        .unwrap();
10878                    action = format!("delete [{pos},{end})");
10879                    d.edit(pos, end, "");
10880                } else {
10881                    let ins = inserts[step % inserts.len()];
10882                    action = format!("insert {ins:?} @ {pos}");
10883                    d.edit(pos, pos, ins);
10884                }
10885                // Walk the caret somewhere else in the document, independently
10886                // of where the edit landed.
10887                let want = (step * 29 + 11) % (d.source.len() + 1);
10888                d.caret = (want..=d.source.len())
10889                    .find(|&i| d.source.is_char_boundary(i))
10890                    .unwrap();
10891                d.build_visual_unwrapped();
10892
10893                let want = reference_map_revealing(&d.source, d.reveal_line());
10894                if maps_differ(&d.vmap, &want) {
10895                    panic!(
10896                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
10897                        d.caret, d.source
10898                    );
10899                }
10900            }
10901        }
10902    }
10903
10904    #[test]
10905    fn caret_motion_across_lines_rebuilds_only_under_full() {
10906        // The cache-key change has to earn its keep in both directions: `Full`
10907        // must rebuild when the caret changes line (or the reveal would never
10908        // move), and the hidden modes must *not* (or every arrow key would pay
10909        // for a feature they don't use). The existing `cache_motion` test pins
10910        // the second for the default mode; this pins the pair against a mode
10911        // change alone.
10912        let body = "*one* here\n\n*two* there\n";
10913
10914        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
10915        full.set_markup_mode(MarkupMode::Full);
10916        caret_at(&mut full, "one");
10917        let before = full.revision();
10918        caret_at(&mut full, "two");
10919        assert_eq!(full.revision(), before, "motion is not an edit");
10920        assert!(
10921            drawn_rows(&full).iter().any(|r| r == "*two* there"),
10922            "the map followed the caret: {:?}",
10923            drawn_rows(&full)
10924        );
10925
10926        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
10927        caret_at(&mut hidden, "one");
10928        let key = hidden.vmap_key.clone();
10929        caret_at(&mut hidden, "two");
10930        assert_eq!(
10931            hidden.vmap_key, key,
10932            "a hidden mode rebuilds nothing on motion"
10933        );
10934    }
10935
10936    #[test]
10937    fn wysiwyg_down_crosses_a_paragraph_boundary() {
10938        // Regression: the blank separator row used to share the previous
10939        // paragraph's end offset, so Down got pinned at the boundary (while Up
10940        // still crossed). Both directions must step through it symmetrically.
10941        //
10942        // It's now stepped *over* rather than onto: the blank line between two
10943        // paragraphs is the boundary being drawn, not a line of the document, so
10944        // one press of Down crosses it. The goal column survives the crossing —
10945        // col 3 at the end of "abc" is col 3 at the end of "def".
10946        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
10947        d.caret = 3; // end of "abc" (row 0)
10948        d.move_down(false);
10949        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
10950        assert_eq!(d.caret, 8); // end of "def", col 3 kept
10951        d.move_up(false);
10952        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
10953        assert_eq!(d.caret, 3);
10954    }
10955
10956    #[test]
10957    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
10958        // The second Up and the second Down here run off the ends of the
10959        // document, which is no longer a place a press is swallowed: they carry
10960        // the caret to the start and the end of the text. The claim in the
10961        // middle — that a Down retraces the Up that crossed the paragraph gap —
10962        // is the one this test is for, and it is asserted where it is made.
10963        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
10964        d.caret = 5; // start of "def"
10965        let start = d.caret_pos();
10966        d.move_up(false);
10967        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
10968        d.move_up(false);
10969        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
10970        d.move_down(false);
10971        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
10972        d.move_down(false);
10973        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
10974    }
10975
10976    #[test]
10977    fn wysiwyg_new_paragraph_shows_before_typing() {
10978        // Regression: two Enters at the end of a paragraph produced trailing
10979        // newlines with no AST node, so the caret appeared stuck on the old line
10980        // until a character was typed. It must ride down onto the new line now.
10981        let mut d = doc_with("wys_newpara", "abc\n");
10982        d.view = View::Wysiwyg;
10983        d.caret = 3;
10984        d.insert("\n");
10985        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
10986        assert_eq!(d.source, "abc\n\n\n");
10987        d.build_visual(80);
10988        let (row, _) = d.caret_pos();
10989        assert!(
10990            row >= 2,
10991            "caret should have moved down to the new line, got row {row}"
10992        );
10993        assert!(
10994            d.vmap.num_rows() >= 3,
10995            "the blank lines should render as rows"
10996        );
10997    }
10998
10999    #[test]
11000    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
11001        // The reported bug: Enter at the end of a paragraph that has another
11002        // paragraph below put the caret at the *start of the next paragraph* —
11003        // the empty paragraph it opened had no row, so the caret snapped onto
11004        // "World". It must now sit on its own empty line, with a blank spacer
11005        // above it (the paragraph gap).
11006        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
11007        d.caret = 5; // end of "Hello"
11008        d.newline();
11009        d.build_visual(80);
11010        let (row, col) = d.caret_pos();
11011        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
11012        assert_eq!(
11013            d.vmap.row_width(row),
11014            0,
11015            "caret's row must be empty, not 'World'"
11016        );
11017        assert!(
11018            row >= 2,
11019            "a blank spacer row should sit above the caret, got row {row}"
11020        );
11021        // The row above the caret is a real (empty) gap, and "Hello" stays put.
11022        assert_eq!(
11023            d.vmap.row_width(row - 1),
11024            0,
11025            "the row above the caret is a gap"
11026        );
11027        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
11028        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
11029    }
11030
11031    #[test]
11032    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
11033        // At the document end a single Enter must also show the paragraph gap —
11034        // a blank spacer row above the caret — so the layout already matches how
11035        // it will look once the new paragraph has text.
11036        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
11037        d.caret = 5; // end of "Hello", no trailing newline
11038        d.newline(); // source becomes "Hello\n\n"
11039        d.build_visual(80);
11040        let (row, col) = d.caret_pos();
11041        assert_eq!(col, 0);
11042        assert!(
11043            row >= 2,
11044            "caret should sit below a blank spacer, got row {row}"
11045        );
11046        assert_eq!(
11047            d.vmap.row_width(row - 1),
11048            0,
11049            "the row above the caret is a gap"
11050        );
11051    }
11052
11053    #[test]
11054    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
11055        // The spacer is view-only: typing the new paragraph must not reflow the
11056        // caret onto a different row — the transient view already matched the
11057        // settled one.
11058        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
11059        d.caret = 5;
11060        d.newline();
11061        d.build_visual(80);
11062        let before = d.caret_pos();
11063        d.insert("New");
11064        d.build_visual(80);
11065        let after = d.caret_pos();
11066        assert_eq!(
11067            after.0, before.0,
11068            "typing must not move the caret to another row ({before:?} -> {after:?})"
11069        );
11070    }
11071
11072    #[test]
11073    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
11074        // Return at the end of the block's last line writes an empty line the
11075        // map used to drop, so the caret landed on `after` and the next
11076        // keystroke went into the paragraph below instead of into the code.
11077        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
11078        d.caret = d.source.find("beta").unwrap() + "beta".len();
11079        d.build_visual(80);
11080        let before = d.caret_pos().0;
11081
11082        d.newline();
11083        d.build_visual(80);
11084        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
11085
11086        let (row, col) = d.caret_pos();
11087        assert_eq!(row, before + 1, "the caret moves down one row");
11088        assert_eq!(col, 0, "onto the head of the empty line");
11089        let span = d.vmap.code_blocks[0].rows_span.clone();
11090        assert!(
11091            span.contains(&row),
11092            "caret row {row} is outside the block's rows {span:?}"
11093        );
11094
11095        // The whole point: what is typed next is code.
11096        d.insert("gamma");
11097        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
11098    }
11099
11100    #[test]
11101    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
11102        let fm = "---\ntitle: hi\n---\n";
11103        let body = format!("{fm}# leaf\n\nbody\n");
11104        let mut d = wysiwyg_doc("wys_fm", &body);
11105        // Opening lifts the caret out of the now-hidden frontmatter.
11106        assert_eq!(
11107            d.caret,
11108            fm.len(),
11109            "caret should start at the first real block"
11110        );
11111        // Left at the content start can't step back into frontmatter.
11112        d.move_left(false);
11113        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
11114        // Doc-start lands on the content floor, not offset 0.
11115        d.move_doc_start(false);
11116        assert_eq!(d.caret, fm.len());
11117        // Select-all + copy never include the frontmatter bytes.
11118        d.select_all();
11119        let sel = d.selected_text().unwrap().to_string();
11120        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
11121        assert!(
11122            sel.starts_with("# leaf"),
11123            "selection should begin at content: {sel:?}"
11124        );
11125    }
11126
11127    #[test]
11128    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
11129        // A fresh note is frontmatter and nothing else. With no rendered block
11130        // to floor the caret it opened at offset 0 — before the opening `---` —
11131        // so the first keystroke wrote itself in front of the metadata and the
11132        // file came out as `This---\ntitle: …`.
11133        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
11134        let mut d = wysiwyg_doc("wys_fm_only", fm);
11135        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
11136        // Nothing is rendered, so the caret draws at the origin of an empty view
11137        // — the same place an empty document puts it.
11138        assert_eq!(d.caret_pos(), (0, 0));
11139        d.insert("This");
11140        assert_eq!(d.source, format!("{fm}This"));
11141    }
11142
11143    /// `select_range` is the verb for a range a host already knows the bytes of,
11144    /// so it must not snap — and must still hold every invariant `place_caret`
11145    /// holds, the frontmatter floor above all.
11146    #[test]
11147    fn select_range_takes_the_range_as_given_but_still_floors_it() {
11148        let fm = "---\ntitle: foo\n---\n\n";
11149        let body = format!("{fm}body foo here\n");
11150        let mut d = wysiwyg_doc("wys_select_range", &body);
11151
11152        // The `foo` in the body: taken exactly, not snapped to a caret stop.
11153        let at = body.rfind("foo").unwrap();
11154        d.select_range(at, at + 3);
11155        assert_eq!(d.selection(), Some((at, at + 3)));
11156        assert_eq!(d.selected_text(), Some("foo"));
11157
11158        // The `foo` in the hidden frontmatter: below the floor, so both ends
11159        // come up to it rather than parking the caret in the metadata, where a
11160        // later keystroke would rewrite `title:`.
11161        let hidden = body.find("foo").unwrap();
11162        assert!(hidden < d.vmap.content_start);
11163        d.select_range(hidden, hidden + 3);
11164        assert!(
11165            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
11166            "a range under the floor must not leave the caret in the frontmatter"
11167        );
11168
11169        // Past the end, and mid-character, are both brought back to something
11170        // sliceable rather than panicking the next reader of the range.
11171        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
11172        let mut d = multi;
11173        d.select_range(2, 9_999);
11174        assert_eq!(d.caret, d.source.len());
11175        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
11176        assert!(d.source.is_char_boundary(d.caret));
11177    }
11178
11179    /// The bug `select_range` exists for: a match butting up against a hidden
11180    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
11181    /// the one before the `**`.
11182    #[test]
11183    fn select_range_does_not_snap_off_a_hidden_delimiter() {
11184        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
11185        let at = d.source.find("needle").unwrap();
11186        d.select_range(at, at + 6);
11187        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
11188    }
11189
11190    #[test]
11191    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
11192        // Backspace deletes `prev_boundary..caret` directly; at the first real
11193        // block that boundary is inside the hidden frontmatter, so it must be a
11194        // no-op rather than eating the closing `---`.
11195        let fm = "---\ntitle: hi\n---\n";
11196        let body = format!("{fm}leaf\n");
11197        let mut d = wysiwyg_doc("wys_fm_bs", &body);
11198        assert_eq!(d.caret, fm.len());
11199        d.backspace();
11200        assert_eq!(d.source, body, "backspace must not touch frontmatter");
11201        d.delete_word_back();
11202        assert_eq!(
11203            d.source, body,
11204            "word-delete must not touch frontmatter either"
11205        );
11206    }
11207
11208    #[test]
11209    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
11210        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
11211        // fenced div, really, since core parses these on for every document
11212        // now (`parse_extensions`). The container is a `directive` node, an
11213        // `is_block_container` kind like `block_quote`, so the caret works
11214        // inside its child paragraph exactly as it would inside a quote: typing
11215        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
11216        // untouched.
11217        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
11218        let mut d = wysiwyg_doc("wys_vis", body);
11219        d.caret = body.find("hello").unwrap() + "hello".len();
11220        d.insert("!");
11221        assert_eq!(
11222            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
11223            "typing inside the block edits its content in place"
11224        );
11225        assert!(
11226            d.source.contains(":::vis{.public .family}"),
11227            "opening fence survives"
11228        );
11229        assert!(d.source.contains(":::\nafter"), "closing fence survives");
11230    }
11231
11232    #[test]
11233    fn source_view_still_reaches_frontmatter() {
11234        // The metadata is only *hidden*, never lost: the source view edits and
11235        // selects it in full, and it's always preserved on save.
11236        let fm = "---\ntitle: hi\n---\n";
11237        let body = format!("{fm}# leaf\n");
11238        let mut d = doc_with("src_fm", &body);
11239        d.select_all();
11240        let sel = d.selected_text().unwrap();
11241        assert!(
11242            sel.contains("title"),
11243            "source view should select everything"
11244        );
11245        d.move_doc_start(false);
11246        assert_eq!(d.caret, 0, "source view can reach offset 0");
11247    }
11248
11249    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
11250
11251    #[test]
11252    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
11253        // The border and padding between two cells all share one source offset,
11254        // so a column-stepping caret would sit on `│` and then stall there
11255        // forever. Right must step: end of "Name" -> start of "Qty".
11256        let mut d = wysiwyg_doc("tbl_right", TABLE);
11257        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
11258        d.move_right(false);
11259        assert_eq!(
11260            d.caret,
11261            TABLE.find("Qty").unwrap(),
11262            "should land in the next cell"
11263        );
11264        let (r, c) = d.caret_pos();
11265        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
11266    }
11267
11268    #[test]
11269    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
11270        let mut d = wysiwyg_doc("tbl_left", TABLE);
11271        d.caret = TABLE.find("Qty").unwrap();
11272        d.move_left(false);
11273        assert_eq!(
11274            d.caret,
11275            TABLE.find("Name").unwrap() + 4,
11276            "end of the previous cell"
11277        );
11278    }
11279
11280    #[test]
11281    fn wysiwyg_down_steps_over_a_table_rule() {
11282        // Between the header and the first body row sits a `├───┼───┤` rule.
11283        // It's drawn but holds no caret, so one Down must reach "Pear".
11284        let mut d = wysiwyg_doc("tbl_down", TABLE);
11285        d.caret = TABLE.find("Name").unwrap();
11286        d.move_down(false);
11287        assert_eq!(
11288            d.caret,
11289            TABLE.find("Pear").unwrap(),
11290            "one Down reaches the body row"
11291        );
11292        d.move_down(false);
11293        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
11294    }
11295
11296    #[test]
11297    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
11298        let mut d = wysiwyg_doc("tbl_tab", TABLE);
11299        d.caret = TABLE.find("Name").unwrap();
11300        // A hop lands with the destination cell's whole content selected, the
11301        // caret at its end — so typing replaces the cell like a form field.
11302        assert!(d.cell_hop(true));
11303        assert_eq!(
11304            d.selected_text(),
11305            Some("Qty"),
11306            "the target cell comes up selected"
11307        );
11308        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
11309        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
11310        assert_eq!(d.selected_text(), Some("Pear"));
11311        assert!(d.cell_hop(false));
11312        assert_eq!(d.selected_text(), Some("Qty"));
11313    }
11314
11315    #[test]
11316    fn tab_outside_a_table_is_not_a_cell_hop() {
11317        // `cell_hop` reports false so the frontend can indent as usual.
11318        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
11319        d.caret = 4;
11320        assert!(!d.cell_hop(true));
11321        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
11322    }
11323
11324    #[test]
11325    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
11326        let mut d = wysiwyg_doc("tbl_edge", TABLE);
11327        d.caret = TABLE.rfind("12").unwrap(); // the final cell
11328        assert!(!d.cell_hop(true), "no cell after the last one");
11329        d.caret = TABLE.find("Name").unwrap();
11330        assert!(!d.cell_hop(false), "no cell before the first one");
11331    }
11332
11333    #[test]
11334    fn wysiwyg_vertical_cell_motion_holds_the_column() {
11335        // Down/Up step to the cell above/below in the *same column*, not back to
11336        // the top-left the way a naive row/col motion over the picture would.
11337        let mut d = wysiwyg_doc("tbl_vert", TABLE);
11338        d.caret = TABLE.find("Qty").unwrap();
11339        // Each vertical hop selects the destination cell, holding the column.
11340        assert!(d.cell_move_vertical(true));
11341        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
11342        assert!(d.cell_move_vertical(true));
11343        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
11344        assert!(!d.cell_move_vertical(true), "no row below the last");
11345        assert!(d.cell_move_vertical(false));
11346        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
11347        assert!(d.cell_move_vertical(false));
11348        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
11349        assert!(!d.cell_move_vertical(false), "no row above the header");
11350    }
11351
11352    #[test]
11353    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
11354        let mut d = wysiwyg_doc("tbl_grow", TABLE);
11355        d.caret = TABLE.rfind("12").unwrap();
11356        let rows_before = d.source.matches('\n').count();
11357        assert!(d.cell_tab(true), "acts as a table key");
11358        assert_eq!(
11359            d.source.matches('\n').count(),
11360            rows_before + 1,
11361            "a fresh row was appended"
11362        );
11363        assert!(d.caret_in_table(), "the caret entered the new row");
11364        // The caret sits in the new row's first cell — past the old last cell.
11365        assert!(d.caret > TABLE.rfind("12").unwrap());
11366    }
11367
11368    #[test]
11369    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
11370        let mut d = wysiwyg_doc("tbl_ret", TABLE);
11371        d.caret = TABLE.find("Name").unwrap();
11372        assert!(d.cell_return(), "acts as a table key");
11373        assert_eq!(
11374            d.selected_text(),
11375            Some("Pear"),
11376            "Return drops one cell, selecting it"
11377        );
11378        // From the last row, Return appends a row and enters it.
11379        d.caret = TABLE.rfind("Fig").unwrap();
11380        let rows_before = d.source.matches('\n').count();
11381        assert!(d.cell_return());
11382        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
11383        assert!(d.caret_in_table());
11384    }
11385
11386    #[test]
11387    fn return_and_tab_outside_a_table_decline() {
11388        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
11389        d.caret = 4;
11390        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
11391        assert!(!d.cell_tab(true), "no table: the frontend indents");
11392        assert!(
11393            !d.cell_line_break(),
11394            "no table: the frontend breaks the line"
11395        );
11396    }
11397
11398    #[test]
11399    fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
11400        // A document that ends in a table used to end *inside* it: nothing
11401        // past the last cell was a caret stop, so a click in the blank space
11402        // under the grid snapped back into the table and there was no way to
11403        // write a line after it. The bottom border's end is that stop now.
11404        let mut d = wysiwyg_doc("tbl_trail", TABLE);
11405        let rows = d.vmap.num_rows();
11406        d.click(rows + 3, 0, false);
11407        let end = TABLE.trim_end_matches('\n').len();
11408        assert_eq!(d.caret, end, "the caret stands just past the table");
11409        assert!(!d.caret_in_table(), "past the table is outside it");
11410        assert!(!d.cell_return(), "Return there is the frontend's newline");
11411        d.newline();
11412        d.insert("after");
11413        assert_eq!(
11414            d.source,
11415            format!("{TABLE}\nafter\n"),
11416            "Enter opens a paragraph under the table"
11417        );
11418    }
11419
11420    #[test]
11421    fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
11422        // The stop sits at the end of the table's last source line, and a
11423        // line glued under a table is a row of it — `| Fig | 12 |x` would be a
11424        // three-cell row. So the text gets a paragraph of its own, as it does
11425        // beside a block picture.
11426        let mut d = wysiwyg_doc("tbl_type", TABLE);
11427        d.caret = TABLE.trim_end_matches('\n').len();
11428        d.insert("x");
11429        assert_eq!(d.source, format!("{TABLE}\nx\n"));
11430        assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
11431        // And a paste, which joins the block exactly as typing would.
11432        let mut d = wysiwyg_doc("tbl_paste", TABLE);
11433        d.caret = TABLE.trim_end_matches('\n').len();
11434        d.paste("pasted");
11435        assert_eq!(d.source, format!("{TABLE}\npasted\n"));
11436    }
11437
11438    #[test]
11439    fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
11440        let mut d = wysiwyg_doc("tbl_edge", TABLE);
11441        let last_cell_end = TABLE.rfind("12").unwrap() + 2;
11442        let end = TABLE.trim_end_matches('\n').len();
11443        d.caret = last_cell_end;
11444        d.move_right(false);
11445        assert_eq!(d.caret, end, "Right from the last cell leaves the table");
11446        // Backspace there takes no byte: the one behind the caret is the row's
11447        // closing `|`, which the rich view never drew. It steps back instead.
11448        d.backspace();
11449        assert_eq!(d.source, TABLE, "nothing deleted");
11450        assert_eq!(d.caret, last_cell_end, "back into the last cell");
11451        // Down from the last row lands on the same stop, and Up returns.
11452        d.move_down(false);
11453        assert_eq!(d.caret, end, "Down from the last row leaves the table");
11454        d.move_up(false);
11455        assert_eq!(d.caret, last_cell_end);
11456    }
11457
11458    #[test]
11459    fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
11460        // With prose under the table, the stop is one hop between the last
11461        // cell and the paragraph — the shape a block picture's second stop has.
11462        let src = format!("{TABLE}\nafter\n");
11463        let mut d = wysiwyg_doc("tbl_mid", &src);
11464        d.caret = TABLE.rfind("12").unwrap() + 2;
11465        d.move_right(false);
11466        assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
11467        d.move_right(false);
11468        assert_eq!(d.caret, src.find("after").unwrap());
11469        // Typing at the stop still opens a paragraph, and the text below keeps
11470        // its own.
11471        d.move_left(false);
11472        d.insert("x");
11473        assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
11474    }
11475
11476    #[test]
11477    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
11478        let mut d = wysiwyg_doc("tbl_break", TABLE);
11479        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
11480        assert!(d.cell_line_break(), "acts as a table key");
11481        assert!(
11482            d.source.contains("Pear<br>"),
11483            "spelled as an inline <br>: {}",
11484            d.source
11485        );
11486        assert!(d.caret_in_table(), "still in the cell, past the break");
11487        // The break renders as a real line: the "Pear" cell now draws two lines,
11488        // so the table's picture is one row taller than a single-line table.
11489        d.build_visual(80);
11490        let table = &d.vmap.tables[0];
11491        let cell = &table.grid[1].cells[0]; // first body row, first column
11492        assert!(
11493            cell.glyphs.iter().any(|g| g.ch == '\n'),
11494            "the cell carries the break as a newline glyph for the frontend to split"
11495        );
11496    }
11497
11498    #[test]
11499    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
11500        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
11501        // back as structure — the whole point of routing through insert_line_break
11502        // instead of splicing raw `<br>` bytes.
11503        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
11504        d.caret = TABLE.find("Pear").unwrap() + 4;
11505        assert!(d.cell_line_break());
11506        let kinds: Vec<Kind> = d
11507            .editor
11508            .nodes()
11509            .unwrap()
11510            .iter()
11511            .map(|n| n.kind.clone())
11512            .collect();
11513        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
11514        assert!(
11515            !kinds.contains(&Kind::RawInline),
11516            "still raw HTML: {kinds:?}"
11517        );
11518    }
11519
11520    #[test]
11521    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
11522        // The `<br>` draws as one newline glyph, so Backspace over it must take
11523        // all four bytes — a one-byte delete would strand a visible `<br` in the
11524        // cell (the reported bug).
11525        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
11526        d.caret = TABLE.find("Pear").unwrap() + 4;
11527        assert!(d.cell_line_break());
11528        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
11529        d.backspace(); // caret sits just past the break
11530        assert!(
11531            !d.source.contains("<br"),
11532            "no half-deleted <br left: {}",
11533            d.source
11534        );
11535        assert!(
11536            d.source.contains("| Pear |"),
11537            "the cell is back to one line: {}",
11538            d.source
11539        );
11540    }
11541
11542    #[test]
11543    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
11544        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
11545        d.caret = TABLE.find("Pear").unwrap() + 4;
11546        assert!(d.cell_line_break());
11547        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
11548        d.delete_forward();
11549        assert!(
11550            !d.source.contains("<br"),
11551            "no half-deleted <br: {}",
11552            d.source
11553        );
11554        assert!(
11555            d.source.contains("| Pear |"),
11556            "cell back to one line: {}",
11557            d.source
11558        );
11559    }
11560
11561    #[test]
11562    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
11563        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
11564        // still consumed (a real newline would split the one-line row), but the
11565        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
11566        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
11567        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11568        d.caret = src.find("Pear").unwrap() + 4;
11569        assert!(d.caret_in_table(), "caret should be inside the djot table");
11570        assert!(
11571            d.cell_line_break(),
11572            "the key is consumed, not passed to the frontend"
11573        );
11574        assert_eq!(d.source, src, "the djot cell is left untouched");
11575        assert!(
11576            !d.source.contains("<br>"),
11577            "no non-idiomatic <br> spliced into djot"
11578        );
11579        assert!(
11580            d.status.is_some(),
11581            "the refusal is surfaced on the status line"
11582        );
11583    }
11584
11585    #[test]
11586    fn typing_in_a_cell_edits_that_cell() {
11587        // Editing comes free once offsets map correctly: the caret is a source
11588        // offset, so a normal splice lands inside the pipe table.
11589        let mut d = wysiwyg_doc("tbl_type", TABLE);
11590        d.caret = TABLE.find("Pear").unwrap() + 4;
11591        d.insert("s");
11592        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
11593    }
11594
11595    #[test]
11596    fn motion_and_delete_treat_an_emoji_as_one_character() {
11597        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
11598        // bytes, several codepoints. Right-arrow must clear it in one step, and
11599        // backspace must remove the whole cluster, not a stray joiner.
11600        let family = "👨‍👩‍👧";
11601        let mut d = doc_with("emoji", &format!("a{family}b\n"));
11602        d.caret = 1; // just after 'a', before the emoji
11603        d.move_right(false);
11604        assert_eq!(
11605            d.caret,
11606            1 + family.len(),
11607            "one step clears the whole cluster"
11608        );
11609        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
11610
11611        d.backspace(); // delete the emoji as a unit
11612        assert_eq!(d.source, "ab\n");
11613        assert_eq!(d.caret, 1);
11614    }
11615
11616    #[test]
11617    fn motion_handles_a_combining_accent_as_one_character() {
11618        // "e" + U+0301 (combining acute) renders as one é.
11619        let mut d = doc_with("combining", "e\u{0301}x\n");
11620        d.caret = 0;
11621        d.move_right(false);
11622        assert_eq!(
11623            d.caret,
11624            "e\u{0301}".len(),
11625            "steps past base + combining mark"
11626        );
11627    }
11628
11629    #[test]
11630    fn undo_then_redo_round_trips_an_edit() {
11631        let mut d = doc_with("undo", "hello\n");
11632        d.caret = 5;
11633        d.insert("!");
11634        assert_eq!(d.source, "hello!\n");
11635        d.undo();
11636        assert_eq!(d.source, "hello\n");
11637        assert_eq!(d.caret, 5, "undo restores the caret");
11638        d.redo();
11639        assert_eq!(d.source, "hello!\n");
11640    }
11641
11642    #[test]
11643    fn a_run_of_typing_undoes_as_one_step() {
11644        let mut d = doc_with("coalesce", "\n");
11645        d.caret = 0;
11646        d.insert("a");
11647        d.insert("b");
11648        d.insert("c");
11649        assert_eq!(d.source, "abc\n");
11650        d.undo(); // the whole typed run, not just "c"
11651        assert_eq!(d.source, "\n");
11652        d.undo(); // nothing left — the run was one step
11653        assert_eq!(d.source, "\n");
11654        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
11655    }
11656
11657    // ── IME composition ──────────────────────────────────────────────────────
11658
11659    #[test]
11660    fn a_composition_run_undoes_as_one_step() {
11661        let mut d = doc_with("compose", "\n");
11662        d.caret = 0;
11663        // What an IME does: each step replaces the last one's provisional bytes.
11664        d.edit_composing(0, 0, "k");
11665        d.edit_composing(0, 1, "か");
11666        d.edit_composing(0, 3, "かん");
11667        d.edit_composing(0, 6, "感"); // the commit
11668        d.end_composition();
11669        assert_eq!(d.source, "感\n");
11670        d.undo(); // the whole composition, not its last keystroke
11671        assert_eq!(d.source, "\n");
11672        assert_eq!(d.status.as_deref(), None, "the run was a single step");
11673    }
11674
11675    #[test]
11676    fn two_compositions_are_two_undo_steps() {
11677        let mut d = doc_with("compose_two", "\n");
11678        d.caret = 0;
11679        d.edit_composing(0, 0, "か");
11680        d.edit_composing(0, 3, "蚊");
11681        d.end_composition();
11682        d.edit_composing(3, 3, "き");
11683        d.edit_composing(3, 6, "木");
11684        d.end_composition();
11685        assert_eq!(d.source, "蚊木\n");
11686        d.undo();
11687        assert_eq!(d.source, "蚊\n", "only the second composition");
11688        d.undo();
11689        assert_eq!(d.source, "\n");
11690    }
11691
11692    #[test]
11693    fn a_composition_does_not_fold_into_the_typing_around_it() {
11694        let mut d = doc_with("compose_typing", "\n");
11695        d.caret = 0;
11696        d.insert("a");
11697        d.insert("b");
11698        d.edit_composing(2, 2, "か");
11699        d.edit_composing(2, 5, "蚊");
11700        d.end_composition();
11701        d.insert("c");
11702        assert_eq!(d.source, "ab蚊c\n");
11703        d.undo();
11704        assert_eq!(d.source, "ab蚊\n");
11705        d.undo();
11706        assert_eq!(d.source, "ab\n");
11707        d.undo();
11708        assert_eq!(d.source, "\n");
11709    }
11710
11711    #[test]
11712    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
11713        let mut d = doc_with("compose_spurious", "\n");
11714        d.caret = 0;
11715        d.insert("a");
11716        d.end_composition(); // an IME unmarking unprompted
11717        d.insert("b");
11718        assert_eq!(d.source, "ab\n");
11719        d.undo();
11720        assert_eq!(d.source, "\n", "still one typed run");
11721    }
11722
11723    // ── the clipboard's rich flavor ──────────────────────────────────────────
11724
11725    #[test]
11726    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
11727        let mut d = doc_with("sel_inline", "a **bold** c\n");
11728        d.anchor = Some(2);
11729        d.caret = 10; // `**bold**`, inside the paragraph
11730        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
11731    }
11732
11733    #[test]
11734    fn a_whole_block_selection_keeps_its_paragraph() {
11735        let mut d = doc_with("sel_block", "a **bold** c\n");
11736        d.anchor = Some(0);
11737        d.caret = 12; // the entire paragraph
11738        assert_eq!(
11739            d.selection_html().as_deref(),
11740            Some("<p>a <strong>bold</strong> c</p>")
11741        );
11742    }
11743
11744    #[test]
11745    fn a_multi_block_selection_keeps_its_structure() {
11746        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
11747        d.select_all();
11748        let html = d.selection_html().expect("renders");
11749        assert!(html.contains("<p>para</p>"), "{html:?}");
11750        assert!(html.contains("<li>one</li>"), "{html:?}");
11751    }
11752
11753    #[test]
11754    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
11755        // The fragment `Head` is a paragraph standalone; the *document* says it
11756        // sits inside one block, so the wrapper is an artifact either way.
11757        let mut d = doc_with("sel_heading", "# Head line\n");
11758        d.anchor = Some(2);
11759        d.caret = 6;
11760        assert_eq!(d.selection_html().as_deref(), Some("Head"));
11761    }
11762
11763    #[test]
11764    fn no_selection_publishes_no_html() {
11765        let mut d = doc_with("sel_none", "a b\n");
11766        d.caret = 1;
11767        assert_eq!(d.selection_html(), None);
11768    }
11769
11770    #[test]
11771    fn pasting_html_converts_it_and_is_one_undo_step() {
11772        let mut d = doc_with("paste_html", "x\n");
11773        d.caret = 1;
11774        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
11775        assert_eq!(d.source, "xa **b** c\n");
11776        d.undo();
11777        assert_eq!(d.source, "x\n", "the whole paste, in one step");
11778    }
11779
11780    #[test]
11781    fn pasting_html_replaces_the_selection() {
11782        let mut d = doc_with("paste_html_sel", "keep drop\n");
11783        d.anchor = Some(5);
11784        d.caret = 9;
11785        assert!(d.paste_html("<em>new</em>"));
11786        assert_eq!(d.source, "keep *new*\n");
11787    }
11788
11789    #[test]
11790    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
11791        let mut d = doc_with("paste_html_bad", "x\n");
11792        d.caret = 1;
11793        // twig builds no table from HTML; raw `<table>` in prose is worse than
11794        // the plain flavor the caller still holds.
11795        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
11796        assert_eq!(d.source, "x\n", "declined edits nothing");
11797    }
11798
11799    #[test]
11800    fn copy_then_paste_round_trips_through_the_html_flavor() {
11801        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
11802        d.select_all();
11803        let html = d.selection_html().expect("renders");
11804        let mut into = doc_with("clip_round_dst", "\n");
11805        into.caret = 0;
11806        assert!(into.paste_html(&html));
11807        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
11808    }
11809
11810    #[test]
11811    fn moving_the_caret_starts_a_new_undo_group() {
11812        let mut d = doc_with("break", "\n");
11813        d.caret = 0;
11814        d.insert("a");
11815        d.insert("b"); // "ab\n", caret at 2
11816        d.move_left(false); // breaks the run
11817        d.insert("X"); // "aXb\n"
11818        assert_eq!(d.source, "aXb\n");
11819        d.undo();
11820        assert_eq!(
11821            d.source, "ab\n",
11822            "first undo removes only the post-move insert"
11823        );
11824        d.undo();
11825        assert_eq!(d.source, "\n", "second undo removes the earlier run");
11826    }
11827
11828    #[test]
11829    fn undo_reverses_a_format_toggle() {
11830        let mut d = doc_with("fmt_undo", "a word b\n");
11831        d.anchor = Some(2);
11832        d.caret = 6;
11833        d.toggle(InlineKind::Strong);
11834        assert_eq!(d.source, "a **word** b\n");
11835        d.undo();
11836        assert_eq!(d.source, "a word b\n");
11837    }
11838
11839    #[test]
11840    fn undo_back_to_the_saved_state_clears_dirty() {
11841        let mut d = doc_with("dirty_undo", "hello\n");
11842        assert!(!d.dirty);
11843        d.caret = 5;
11844        d.insert("!");
11845        assert!(d.dirty);
11846        d.undo();
11847        assert!(
11848            !d.dirty,
11849            "undoing to the saved source is not a modification"
11850        );
11851    }
11852
11853    #[test]
11854    fn a_new_edit_invalidates_redo() {
11855        let mut d = doc_with("redo_inv", "\n");
11856        d.caret = 0;
11857        d.insert("a");
11858        d.undo();
11859        d.insert("b"); // diverges — the redo of "a" is now gone
11860        d.redo();
11861        assert_eq!(d.source, "b\n");
11862    }
11863
11864    #[test]
11865    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
11866        let mut d = doc_with("can_undo", "hello\n");
11867        assert!(
11868            !d.can_undo() && !d.can_redo(),
11869            "a fresh document has no history"
11870        );
11871        d.caret = 5;
11872        d.insert("!");
11873        assert!(
11874            d.can_undo() && !d.can_redo(),
11875            "an edit is a step to take back"
11876        );
11877        d.undo();
11878        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
11879        d.redo();
11880        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
11881        d.undo();
11882        d.insert("?");
11883        assert!(
11884            d.can_undo() && !d.can_redo(),
11885            "a fresh edit ends the redo chain"
11886        );
11887        // A coalesced run over-counts steps — the bound is what a menu needs,
11888        // and it reconciles the moment twig reports the history empty.
11889        d.insert("a");
11890        d.insert("b");
11891        while d.can_undo() {
11892            d.undo();
11893        }
11894        assert_eq!(d.source, "hello\n");
11895        assert!(!d.can_undo());
11896        // A reading surface has nothing to undo, whatever the history holds.
11897        d.redo();
11898        d.set_read_only(true);
11899        assert!(!d.can_undo() && !d.can_redo());
11900    }
11901
11902    #[test]
11903    fn undo_on_empty_history_is_a_no_op() {
11904        let mut d = doc_with("undo_empty", "hi\n");
11905        d.undo();
11906        assert_eq!(d.source, "hi\n");
11907        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
11908    }
11909
11910    #[test]
11911    fn a_one_character_paste_is_its_own_undo_step() {
11912        for view in [View::Source, View::Wysiwyg] {
11913            let mut d = doc_in(view, "paste_step", "ab\n");
11914            d.caret = 0;
11915            d.insert("x");
11916            d.insert("y"); // a run of typing
11917            d.paste("z"); // one character, but pasted — not part of that run
11918            assert_eq!(d.source, "xyzab\n");
11919            d.undo();
11920            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
11921            assert_eq!(d.caret, 2, "and hands back the caret it found");
11922            d.undo();
11923            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
11924        }
11925    }
11926
11927    #[test]
11928    fn the_same_character_typed_still_joins_the_run() {
11929        // The other half of the pair: `z` is a keystroke here and a paste above,
11930        // and the two undo differently. Nothing about the *string* says which —
11931        // which is why provenance has to come from the door the caller uses.
11932        for view in [View::Source, View::Wysiwyg] {
11933            let mut d = doc_in(view, "typed_run", "ab\n");
11934            d.caret = 0;
11935            d.insert("x");
11936            d.insert("y");
11937            d.insert("z");
11938            d.undo();
11939            assert_eq!(d.source, "ab\n", "one run, one step");
11940        }
11941    }
11942
11943    #[test]
11944    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
11945        for view in [View::Source, View::Wysiwyg] {
11946            let mut d = doc_in(view, "undo_caret", "hello world\n");
11947            d.caret = 11; // standing at the end of "world", away from the edit
11948            d.edit(0, 5, "goodbye");
11949            assert_eq!(d.source, "goodbye world\n");
11950            d.undo();
11951            assert_eq!(d.source, "hello world\n");
11952            // The undone edit ends at offset 5; the user was at 11.
11953            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
11954        }
11955    }
11956
11957    #[test]
11958    fn undo_restores_the_selection_the_edit_replaced() {
11959        for view in [View::Source, View::Wysiwyg] {
11960            let mut d = doc_in(view, "undo_sel", "a word b\n");
11961            d.anchor = Some(2);
11962            d.caret = 6; // "word" selected
11963            d.insert("X");
11964            assert_eq!(d.source, "a X b\n");
11965            d.undo();
11966            assert_eq!(d.source, "a word b\n");
11967            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
11968        }
11969    }
11970
11971    #[test]
11972    fn redo_restores_the_caret_the_edit_left_behind() {
11973        for view in [View::Source, View::Wysiwyg] {
11974            let mut d = doc_in(view, "redo_caret", "hello world\n");
11975            d.caret = 11;
11976            d.edit(0, 5, "goodbye");
11977            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
11978            d.undo();
11979            d.redo();
11980            assert_eq!(d.source, "goodbye world\n");
11981            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
11982        }
11983    }
11984
11985    #[test]
11986    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
11987        for view in [View::Source, View::Wysiwyg] {
11988            let mut d = doc_in(view, "run_caret", "hi\n");
11989            d.caret = 2;
11990            d.insert("a");
11991            d.insert("b");
11992            d.insert("c");
11993            assert_eq!(d.source, "hiabc\n");
11994            d.undo();
11995            assert_eq!(d.source, "hi\n");
11996            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
11997            d.redo();
11998            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
11999        }
12000    }
12001
12002    #[test]
12003    fn undo_restores_the_caret_across_a_format_toggle() {
12004        // A toggle reaches twig without going through `splice`, so it has to
12005        // record its own step — miss it and every stack depth below it is off by
12006        // one, and undo starts handing back another edit's caret.
12007        for view in [View::Source, View::Wysiwyg] {
12008            let mut d = doc_in(view, "fmt_caret", "a word b\n");
12009            d.caret = 8;
12010            d.anchor = Some(2);
12011            d.caret = 6;
12012            d.toggle(InlineKind::Strong);
12013            assert_eq!(d.source, "a **word** b\n");
12014            d.undo();
12015            assert_eq!(d.source, "a word b\n");
12016            assert_eq!(
12017                d.selection(),
12018                Some((2, 6)),
12019                "the toggled selection comes back"
12020            );
12021        }
12022    }
12023
12024    #[test]
12025    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
12026        // The drift that would never announce itself: twig drops its redo stack
12027        // on any fresh edit, so a leaf redo entry that outlives it would restore
12028        // a caret from the timeline that edit abandoned.
12029        for view in [View::Source, View::Wysiwyg] {
12030            let mut d = doc_in(view, "redo_trunc", "hello world\n");
12031            d.caret = 11;
12032            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
12033            d.undo();
12034            assert_eq!(d.caret, 11);
12035            d.caret = 0;
12036            d.insert("X"); // diverges: A's redo is gone from twig
12037            assert_eq!(d.source, "Xhello world\n");
12038
12039            d.redo();
12040            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
12041            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
12042            d.undo();
12043            assert_eq!(d.source, "hello world\n");
12044            assert_eq!(
12045                d.caret, 0,
12046                "the surviving step's caret, not the dropped one"
12047            );
12048        }
12049    }
12050
12051    #[test]
12052    fn indent_and_outdent_move_the_caret_line_with_its_text() {
12053        for view in [View::Source, View::Wysiwyg] {
12054            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
12055            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
12056            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
12057            // Indentation the caret is standing *in* collapses to the line start
12058            // rather than dragging the caret into the text.
12059            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
12060            // A line with none to give back is left exactly as it was.
12061            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
12062            // Less than a full level gives back what it has.
12063            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12064            // A tab is one level however many spaces it isn't.
12065            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
12066        }
12067    }
12068
12069    #[test]
12070    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
12071        // Why the level is two spaces and not the four both frontends type
12072        // today. Four is markdown's indented-code-block marker, so a Tab on a
12073        // paragraph would silently restyle it as code — a width that changes
12074        // what the document *means* isn't an indent. Pinned because the number
12075        // is the kind of thing a later list-aware pass would reach for.
12076        let mut d = doc_with("indent_kind", "hello\n");
12077        d.caret = 2;
12078        d.indent();
12079        assert_eq!(d.source, "  hello\n");
12080        assert!(
12081            d.nodes().iter().any(|n| n.kind == Kind::Para),
12082            "still prose after a Tab"
12083        );
12084        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
12085
12086        // The four-space level this replaces, for contrast: same text, and twig
12087        // reparses the paragraph into a code block.
12088        let mut wide = doc_with("indent_kind_4", "    hello\n");
12089        wide.build_visual(80);
12090        assert!(
12091            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
12092            "four spaces is a code block, not an indented paragraph"
12093        );
12094    }
12095
12096    #[test]
12097    fn indent_nests_a_list_item_under_its_parent() {
12098        // Tab indents a list item by its own marker width, landing its marker at
12099        // the parent's content column so twig reparses it as a nested list.
12100        for view in [View::Source, View::Wysiwyg] {
12101            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
12102            d.caret = 6; // on the second item
12103            d.indent();
12104            assert_eq!(d.source, "- a\n  - b\n");
12105            let lists = d
12106                .nodes()
12107                .iter()
12108                .filter(|n| n.kind == Kind::BulletList)
12109                .count();
12110            assert_eq!(lists, 2, "the indented item is a nested list");
12111        }
12112    }
12113
12114    #[test]
12115    fn indent_nests_an_ordered_item_at_its_marker_width() {
12116        // An ordered marker `1. ` is three columns wide, so a two-space step
12117        // (which nests a bullet) leaves it flat. Regression: Tab must use the
12118        // marker width, three, so the item actually nests — and the source
12119        // renumbers so the sub-list restarts at 1 and the outer list resumes.
12120        for view in [View::Source, View::Wysiwyg] {
12121            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
12122            d.caret = d.source.find('b').unwrap();
12123            d.indent();
12124            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
12125            let lists = d
12126                .nodes()
12127                .iter()
12128                .filter(|n| n.kind == Kind::OrderedList)
12129                .count();
12130            assert_eq!(lists, 2, "the indented item is a nested ordered list");
12131        }
12132    }
12133
12134    #[test]
12135    fn indent_leaves_a_lists_first_item_put() {
12136        // The first item of a list has no sibling above it to nest under, so Tab
12137        // is a no-op there — the marker stays at column zero rather than being
12138        // shoved into indentation twig can't read as a sub-list.
12139        for view in [View::Source, View::Wysiwyg] {
12140            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
12141            d.caret = 1; // on the FIRST item
12142            d.indent();
12143            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
12144            // The sibling below still nests, proving the guard is per-item.
12145            d.caret = d.source.find('b').unwrap();
12146            d.indent();
12147            assert_eq!(d.source, "- a\n  - b\n");
12148        }
12149    }
12150
12151    #[test]
12152    fn hidden_mode_keeps_typed_markup_literal() {
12153        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
12154        // twig escapes what would open markup, so the source is `\*hi\*` and the
12155        // AST is a plain string. Formatting is the commands' job in this mode.
12156        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
12157        d.insert("*hi*");
12158        assert_eq!(d.source, "\\*hi\\*");
12159        assert!(
12160            d.nodes()
12161                .iter()
12162                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
12163        );
12164    }
12165
12166    #[test]
12167    fn hidden_mode_escapes_a_line_start_block_marker() {
12168        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
12169        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
12170        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
12171        d.insert("# hi");
12172        assert_eq!(d.source, "\\# hi");
12173        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
12174    }
12175
12176    #[test]
12177    fn authoring_modes_keep_typed_markup_live() {
12178        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
12179        // (no escape), the same as source view — escaping is `None`'s alone, and
12180        // it's the axis, not the reveal, that decides.
12181        for (view, mode) in [
12182            (View::Wysiwyg, MarkupMode::Shortcuts),
12183            (View::Wysiwyg, MarkupMode::Full),
12184            (View::Source, MarkupMode::None),
12185        ] {
12186            let mut d = doc_in(view, "live_markup", "");
12187            d.set_markup_mode(mode);
12188            d.insert("*hi*");
12189            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
12190        }
12191    }
12192
12193    #[test]
12194    fn hidden_mode_overwrite_undoes_in_one_step() {
12195        // Typing over a selection escapes the replacement *and* stays a single
12196        // undo — the selection-delete and the literal insert fold together, so
12197        // one undo brings the whole selection back, like a plain overwrite.
12198        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
12199        d.anchor = Some(2);
12200        d.caret = 6; // "word"
12201        d.insert("*");
12202        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
12203        d.undo();
12204        assert_eq!(d.source, "a word b\n");
12205        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
12206    }
12207
12208    #[test]
12209    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
12210        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
12211        // the whole visual character, never stranding the hidden `\`.
12212        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
12213        d.insert("*");
12214        assert_eq!(d.source, "\\*");
12215        d.backspace();
12216        assert_eq!(d.source, "", "the escape backslash went with the *");
12217        // A *literal* backslash (source view, no escape) is an ordinary char.
12218        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
12219        s.caret = 3; // after `b`
12220        s.backspace();
12221        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
12222    }
12223
12224    #[test]
12225    fn hidden_mode_leaves_structural_markup_alone() {
12226        // Enter continues a bullet list by writing a real `- ` marker (an
12227        // `insert_raw`, not the typing path), so Hidden mode's escaping never
12228        // touches it — the list keeps working.
12229        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
12230        d.caret = 6;
12231        d.newline();
12232        d.insert("two");
12233        assert_eq!(d.source, "- item\n- two\n");
12234    }
12235
12236    #[test]
12237    fn markup_mode_defaults_to_none_and_round_trips() {
12238        // Diaryx's default is the clean `None` surface; a markup-fluent
12239        // frontend can climb the ladder, and the choice sticks.
12240        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
12241        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
12242        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
12243            d.set_markup_mode(mode);
12244            assert_eq!(d.markup_mode(), mode);
12245        }
12246    }
12247
12248    #[test]
12249    fn full_mode_reveals_only_the_caret_line() {
12250        // The mode's whole claim: the caret's line shows its raw delimiters and
12251        // every other line stays resolved. Two paragraphs with identical markup
12252        // so the only difference between the rows is where the caret is.
12253        let mut d = doc_in(
12254            View::Wysiwyg,
12255            "reveal_caret_line",
12256            "*one* here\n\n*two* there\n",
12257        );
12258        d.set_markup_mode(MarkupMode::Full);
12259
12260        caret_at(&mut d, "one");
12261        let rows = drawn_rows(&d);
12262        assert!(
12263            rows.iter().any(|r| r == "*one* here"),
12264            "caret's line raw: {rows:?}"
12265        );
12266        assert!(
12267            rows.iter().any(|r| r == "two there"),
12268            "other line resolved: {rows:?}"
12269        );
12270
12271        // Move to the other paragraph: the reveal follows, and the line just
12272        // left goes back to being resolved.
12273        caret_at(&mut d, "two");
12274        let rows = drawn_rows(&d);
12275        assert!(
12276            rows.iter().any(|r| r == "*two* there"),
12277            "caret's line raw: {rows:?}"
12278        );
12279        assert!(
12280            rows.iter().any(|r| r == "one here"),
12281            "left line resolved: {rows:?}"
12282        );
12283    }
12284
12285    #[test]
12286    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
12287        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
12288        // the same treatment as an emphasis's `*`: hidden while the caret is
12289        // elsewhere, shown in full where the caret lands. That falls out of
12290        // `delims` reading the bytes between the mark's span and its content
12291        // span, which is exactly `==🔴 ` and `==`, rather than from a table
12292        // of spellings — so the no-space form `==🟢green==` reveals right too.
12293        let mut d = doc_in(
12294            View::Wysiwyg,
12295            "reveal_coloured_mark",
12296            "a ==🔴 red== one\n\nb ==plain== two\n",
12297        );
12298        d.set_markup_mode(MarkupMode::Full);
12299
12300        caret_at(&mut d, "red");
12301        let rows = drawn_rows(&d);
12302        assert!(
12303            rows.iter().any(|r| r == "a ==🔴 red== one"),
12304            "the caret's line shows the colour it was written with: {rows:?}"
12305        );
12306        assert!(
12307            rows.iter().any(|r| r == "b plain two"),
12308            "and every other line stays resolved: {rows:?}"
12309        );
12310
12311        // Away from it, the emoji goes back to being markup — the reader sees
12312        // the words and the wash.
12313        caret_at(&mut d, "two");
12314        let rows = drawn_rows(&d);
12315        assert!(
12316            rows.iter().any(|r| r == "a red one"),
12317            "resolved again: {rows:?}"
12318        );
12319    }
12320
12321    #[test]
12322    fn hidden_modes_never_reveal_wherever_the_caret_is() {
12323        // The two rungs below `Full` share a rendering: delimiters stay hidden
12324        // even under the caret. `Shortcuts` differing from `None` only in what
12325        // typing does is exactly the point of splitting the axes.
12326        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
12327            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
12328            d.set_markup_mode(mode);
12329            caret_at(&mut d, "one");
12330            let rows = drawn_rows(&d);
12331            assert!(
12332                rows.iter().any(|r| r == "one here"),
12333                "{mode:?} hides: {rows:?}"
12334            );
12335            assert!(
12336                !rows.iter().any(|r| r.contains('*')),
12337                "{mode:?} shows no `*`: {rows:?}"
12338            );
12339        }
12340    }
12341
12342    #[test]
12343    fn revealed_delimiters_are_the_authors_own_spelling() {
12344        // Delimiters are re-read from the source rather than synthesized per
12345        // kind, so a line comes back spelled the way it was written: `_em_` does
12346        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
12347        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
12348        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
12349        d.set_markup_mode(MarkupMode::Full);
12350        caret_at(&mut d, "em");
12351        let rows = drawn_rows(&d);
12352        assert!(
12353            rows.iter().any(|r| r == body.trim_end()),
12354            "the revealed line is its own source: {rows:?}"
12355        );
12356    }
12357
12358    #[test]
12359    fn revealed_heading_shows_its_hashes() {
12360        // The `# ` marker is a block-level prefix, not an inline delimiter, so
12361        // it takes its own path — but it reveals on the same rule.
12362        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
12363        d.set_markup_mode(MarkupMode::Full);
12364
12365        caret_at(&mut d, "Title");
12366        assert!(
12367            drawn_rows(&d).iter().any(|r| r == "# Title"),
12368            "{:?}",
12369            drawn_rows(&d)
12370        );
12371
12372        caret_at(&mut d, "body");
12373        let rows = drawn_rows(&d);
12374        assert!(
12375            rows.iter().any(|r| r == "Title"),
12376            "hashes hidden again: {rows:?}"
12377        );
12378    }
12379
12380    #[test]
12381    fn revealed_delimiters_are_caret_stops() {
12382        // A delimiter that is drawn but can't be reached is worse than one
12383        // that's hidden: the mode exists so the markup can be *edited*. Every
12384        // revealed byte must be somewhere the caret can stand.
12385        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
12386        d.set_markup_mode(MarkupMode::Full);
12387        caret_at(&mut d, "em");
12388        let opener = d.source.find('*').unwrap();
12389        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
12390        assert!(
12391            d.vmap.is_stop(opener + 3),
12392            "the closing `*` is a caret stop"
12393        );
12394    }
12395
12396    #[test]
12397    fn setext_heading_reveals_nothing_across_its_newline() {
12398        // A setext heading's underline is on another line, so it is not the
12399        // caret line's to reveal — and emitting it would inject a `\n` glyph
12400        // that splits the row where the author wrote no break.
12401        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
12402        d.set_markup_mode(MarkupMode::Full);
12403        caret_at(&mut d, "Title");
12404        let rows = drawn_rows(&d);
12405        assert!(
12406            rows.iter().any(|r| r == "Title"),
12407            "title renders alone: {rows:?}"
12408        );
12409        assert!(
12410            !rows.iter().any(|r| r.contains('=')),
12411            "no underline leaks in: {rows:?}"
12412        );
12413    }
12414
12415    #[test]
12416    fn markup_mode_axes_split_the_ladder() {
12417        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
12418        // that authors markup but still hides it, and it's the only rung where
12419        // the two axes disagree.
12420        assert!(!MarkupMode::None.authors());
12421        assert!(!MarkupMode::None.reveals_caret_line());
12422        assert!(MarkupMode::Shortcuts.authors());
12423        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
12424        assert!(MarkupMode::Full.authors());
12425        assert!(MarkupMode::Full.reveals_caret_line());
12426    }
12427
12428    #[test]
12429    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
12430        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
12431        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
12432        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
12433        // nested bullet and `hello` stays prose: the file round-trips instead of
12434        // hiding a heading the user never asked for.
12435        for view in [View::Source, View::Wysiwyg] {
12436            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
12437            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
12438            d.indent();
12439            assert_eq!(d.source, "- hello\n  * \n");
12440            assert!(
12441                d.nodes().iter().all(|n| n.kind != Kind::Heading),
12442                "no heading"
12443            );
12444            // And it's genuinely a nested list, not a flat one.
12445            assert_eq!(
12446                d.nodes()
12447                    .iter()
12448                    .filter(|n| n.kind == Kind::BulletList)
12449                    .count(),
12450                2
12451            );
12452        }
12453    }
12454
12455    #[test]
12456    fn indenting_a_dash_item_with_content_keeps_its_dash() {
12457        // With content, `- x` can't be a setext underline, so there's nothing to
12458        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
12459        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
12460        d.caret = d.source.find('x').unwrap();
12461        d.indent();
12462        assert_eq!(d.source, "- hello\n  - x\n");
12463    }
12464
12465    #[test]
12466    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
12467        // The dash→`*` repair coalesces into the Tab, so a single undo restores
12468        // the whole pre-Tab state rather than stranding a half-collapsed doc.
12469        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
12470        d.caret = d.source.find("- \n").unwrap() + 2;
12471        d.indent();
12472        assert_eq!(d.source, "- hello\n  * \n");
12473        d.undo();
12474        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
12475    }
12476
12477    #[test]
12478    fn indent_leaves_a_nested_lists_first_item_put_too() {
12479        // The guard is about siblings, not depth: the first item of an *inner*
12480        // list (already nested under `a`) still has nothing before it at its own
12481        // level, so Tab can't take it deeper.
12482        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
12483        d.caret = d.source.find('b').unwrap();
12484        d.indent();
12485        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
12486        // But `c` (a sibling of `b`) nests under `b`.
12487        d.caret = d.source.find('c').unwrap();
12488        d.indent();
12489        assert_eq!(d.source, "- a\n  - b\n    - c\n");
12490    }
12491
12492    #[test]
12493    fn backspace_at_a_nested_item_start_outdents_it() {
12494        // Backspace with the caret right after a nested item's marker gives back
12495        // one level of nesting, the mirror of Tab — and renumbers the flattened
12496        // ordered list back to a clean run.
12497        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
12498        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
12499        d.backspace();
12500        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
12501    }
12502
12503    #[test]
12504    fn backspace_at_a_top_level_item_start_strips_the_marker() {
12505        // At the outermost level there's no nesting left to give back, so the same
12506        // keystroke drops the bullet and leaves a plain paragraph.
12507        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
12508        d.caret = d.source.find('b').unwrap(); // right after `- `
12509        d.backspace();
12510        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
12511    }
12512
12513    #[test]
12514    fn backspace_mid_item_still_deletes_a_character() {
12515        // The list behaviour is armed only at the item's content start; anywhere
12516        // else Backspace is the ordinary character delete.
12517        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
12518        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
12519        d.backspace();
12520        assert_eq!(d.source, "- b\n");
12521    }
12522
12523    #[test]
12524    fn backspace_at_a_heading_start_strips_the_marker() {
12525        // The `# ` is markup the rich view hides, so Backspace over it takes the
12526        // whole marker and leaves a paragraph. Deleting a byte of it instead left
12527        // `#Title` — no longer a heading, with the hash now literal text the user
12528        // never typed and has to delete again.
12529        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
12530        d.caret = d.source.find('T').unwrap(); // right after `## `
12531        d.backspace();
12532        assert_eq!(d.source, "Title\n");
12533        assert_eq!(
12534            d.caret, 0,
12535            "the caret stays with the text it was in front of"
12536        );
12537    }
12538
12539    #[test]
12540    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
12541        // Only the heading's own marker goes — the quote (or list) it sits in is
12542        // untouched, exactly as un-heading it should be.
12543        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
12544        d.caret = d.source.find('T').unwrap();
12545        d.backspace();
12546        assert_eq!(d.source, "> Title\n");
12547    }
12548
12549    #[test]
12550    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
12551        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
12552        // behind would surface the same stray hash the marker delete just avoided.
12553        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
12554        d.caret = d.source.find('T').unwrap();
12555        d.backspace();
12556        assert_eq!(d.source, "Title\n");
12557        // And it's one edit: a single undo puts the whole heading back.
12558        d.undo();
12559        assert_eq!(d.source, "# Title #\n");
12560    }
12561
12562    #[test]
12563    fn backspace_mid_heading_still_deletes_a_character() {
12564        // The heading behaviour is armed only at the content's start; anywhere
12565        // else Backspace is the ordinary character delete.
12566        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
12567        d.caret = d.source.find('b').unwrap();
12568        d.backspace();
12569        assert_eq!(d.source, "# b\n");
12570    }
12571
12572    #[test]
12573    fn source_view_backspace_still_edits_the_heading_marker_literally() {
12574        // In source view the `# ` is text on the screen the user is deleting a
12575        // byte of, so it keeps its literal meaning — the same split the list
12576        // ladder and Enter draw between the two views.
12577        let mut d = doc_with("bsp_head_src", "# Title\n");
12578        d.caret = d.source.find('T').unwrap();
12579        d.backspace();
12580        assert_eq!(d.source, "#Title\n");
12581    }
12582
12583    #[test]
12584    fn outdent_unnests_an_ordered_item_in_one_press() {
12585        // Shift+Tab gives back exactly the marker width the indent added, so a
12586        // nested ordered item unnests in a single press, and the flattened list
12587        // renumbers back to a clean 1, 2, 3.
12588        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
12589        d.caret = d.source.find('b').unwrap();
12590        d.outdent();
12591        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
12592        let lists = d
12593            .nodes()
12594            .iter()
12595            .filter(|n| n.kind == Kind::OrderedList)
12596            .count();
12597        assert_eq!(lists, 1, "back to one flat list");
12598    }
12599
12600    #[test]
12601    fn table_insert_row_adds_a_row_below_the_caret() {
12602        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
12603        d.caret = d.source.find('1').unwrap(); // in the body row
12604        d.table_insert_row(true);
12605        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
12606    }
12607
12608    #[test]
12609    fn table_insert_and_delete_column_at_the_caret() {
12610        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
12611        d.caret = d.source.find('a').unwrap(); // column 0
12612        d.table_insert_column(true); // add a column to the right of `a`
12613        assert_eq!(
12614            d.source,
12615            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
12616        );
12617        d.caret = d.source.find('b').unwrap(); // now the third column
12618        d.table_delete_column();
12619        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
12620    }
12621
12622    // ── ragged formats ───────────────────────────────────────────────────────
12623    // No format spells every gesture. HTML writes the inline marks as a tag pair
12624    // and no heading, list, quote or link; Markdown spells five of the eight
12625    // marks — the highlight only because leaf parses with `highlight`, which is
12626    // why the question is asked with the extensions; djot spells all eight and
12627    // no in-cell break. leaf asks twig per
12628    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
12629    // op discover the fact on its own — one of them didn't.
12630
12631    /// An HTML document in the rich view, ready for a gesture.
12632    fn html_doc(body: &str) -> Doc {
12633        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
12634        d.view = View::Wysiwyg;
12635        d.build_visual(80);
12636        d
12637    }
12638
12639    #[test]
12640    fn a_table_gesture_leaves_an_html_table_alone() {
12641        // The regression this guard exists for. twig's table editor consults no
12642        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
12643        // HTML `<table>` as a *pipe table* and reported success: the whole
12644        // element replaced by `| a | b |`, silently, on one press of a toolbar
12645        // button. Every grid op went the same way.
12646        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
12647        // A table of named operations, which is what it looks like.
12648        #[allow(clippy::type_complexity)]
12649        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
12650            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
12651            ("delete row", &|d: &mut Doc| d.table_delete_row()),
12652            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
12653            ("delete column", &|d: &mut Doc| d.table_delete_column()),
12654            ("align", &|d: &mut Doc| {
12655                d.table_set_alignment(Alignment::Right)
12656            }),
12657            ("move row", &|d: &mut Doc| d.table_move_row(true)),
12658            ("move column", &|d: &mut Doc| d.table_move_column(true)),
12659        ];
12660        for (name, op) in ops {
12661            let mut d = html_doc(src);
12662            d.caret = d.source.find('a').unwrap();
12663            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
12664            op(&mut d);
12665            assert_eq!(d.source, src, "{name} rewrote an HTML table");
12666            assert!(
12667                !d.dirty,
12668                "{name} marked the document dirty without editing it"
12669            );
12670            assert!(d.status.is_some(), "{name} refused without saying why");
12671        }
12672    }
12673
12674    #[test]
12675    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
12676        // A task box is a form control in HTML and a footnote has no native
12677        // spelling at all — the two gestures twig 3.5 still spells nothing
12678        // for, now that a quote, a list, a link and an image print through
12679        // its renderer (see the test below).
12680        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
12681        // A table of named operations, which is what it looks like.
12682        #[allow(clippy::type_complexity)]
12683        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
12684            ("task item", &|d: &mut Doc| d.toggle_task_item()),
12685            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
12686            ("footnote", &|d: &mut Doc| d.insert_footnote()),
12687        ];
12688        for (name, op) in ops {
12689            let mut d = html_doc(src);
12690            let at = d.source.find("Hello").unwrap();
12691            d.caret = at;
12692            d.anchor = Some(at + 5); // a selection, for the ops that want one
12693            op(&mut d);
12694            assert_eq!(d.source, src, "{name} edited an HTML document");
12695            assert!(
12696                !d.dirty,
12697                "{name} marked the document dirty without editing it"
12698            );
12699            let status = d.status.as_deref().unwrap_or("");
12700            assert!(
12701                status.contains("html"),
12702                "{name}: the refusal should name the format, got {status:?}"
12703            );
12704        }
12705    }
12706
12707    #[test]
12708    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
12709        // twig 3.5: where HTML has no marker alphabet it prints the fresh
12710        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
12711        // the paragraph as its item, an `<a>` or `<img>` over the selection.
12712        // Until then every one of these was a refusal; now each is a real
12713        // edit, which is what the toolbar's capability flags say too.
12714        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
12715        #[allow(clippy::type_complexity)]
12716        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
12717            (
12718                "quote",
12719                &|d: &mut Doc| d.toggle_blockquote(),
12720                "<blockquote>",
12721            ),
12722            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
12723            (
12724                "ordered list",
12725                &|d: &mut Doc| d.toggle_list(true),
12726                "<ol>\n<li>",
12727            ),
12728            (
12729                "link",
12730                &|d: &mut Doc| d.insert_link("https://example.dev"),
12731                "<a href=\"https://example.dev\">Hello</a>",
12732            ),
12733            (
12734                "image",
12735                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
12736                "<img alt=\"Hello\" src=\"pic.png\">",
12737            ),
12738        ];
12739        for (name, op, expect) in ops {
12740            let mut d = html_doc(src);
12741            let at = d.source.find("Hello").unwrap();
12742            d.caret = at;
12743            d.anchor = Some(at + 5);
12744            op(&mut d);
12745            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
12746            assert!(d.dirty, "{name}: a real edit");
12747            assert_eq!(
12748                d.status, None,
12749                "{name}: a supported gesture reports nothing"
12750            );
12751        }
12752    }
12753
12754    #[test]
12755    fn html_spells_a_heading_as_its_tag_pair() {
12756        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
12757        // along — the one block gesture whose HTML shape it can write. So ⌘2
12758        // in an HTML document is a real edit, and ⌘0 takes it back.
12759        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
12760        let mut d = html_doc(src);
12761        d.caret = d.source.find("Hello").unwrap();
12762        d.toggle_heading(2);
12763        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
12764        assert!(d.dirty);
12765        assert_eq!(d.status, None, "a supported gesture reports nothing");
12766        d.toggle_heading(2);
12767        assert_eq!(d.source, src, "the same level again is back to a paragraph");
12768    }
12769
12770    #[test]
12771    fn html_spells_the_inline_marks_and_the_rule() {
12772        // The other half, and why one per-document flag stopped being enough:
12773        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
12774        // already emits and the parser reads straight back as the same mark —
12775        // and the rule button writes an `<hr>`. Refusing these on the old
12776        // "HTML is parse-only" reading would now be leaf's own limitation.
12777        let mut d = html_doc("<p>Hello world</p>\n");
12778        let at = d.source.find("world").unwrap();
12779        d.caret = at;
12780        d.anchor = Some(at + 5);
12781        d.toggle(InlineKind::Strong);
12782        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
12783        assert!(d.dirty);
12784        assert_eq!(d.status, None, "a supported gesture reports nothing");
12785
12786        // And off again — the toggle reverses, which is the property that makes
12787        // authoring in HTML worth offering rather than a one-way trip.
12788        d.toggle(InlineKind::Strong);
12789        assert_eq!(d.source, "<p>Hello world</p>\n");
12790
12791        let mut d = html_doc("<p>Hello world</p>\n");
12792        d.caret = d.source.find("world").unwrap();
12793        d.insert_thematic_break();
12794        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
12795    }
12796
12797    #[test]
12798    fn a_mark_the_format_cannot_spell_arms_nothing() {
12799        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
12800        // sticky mark for the next text typed. Guarding only the twig call
12801        // leaves that path live, promising a mark the gesture will not write and
12802        // then swallowing the error inside `insert`.
12803        //
12804        // Markdown carries this, on the superscript now rather than on the
12805        // highlight: `^x^` is text there in any configuration, whereas twig
12806        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
12807        // which every leaf document does.
12808        let mut d = doc_with("mark", "Hello world\n");
12809        d.view = View::Wysiwyg;
12810        d.build_visual(80);
12811        d.caret = d.source.find("world").unwrap();
12812        d.toggle(InlineKind::Superscript);
12813        assert!(d.pending_marks.is_empty(), "no mark should be armed");
12814        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
12815        d.insert("X");
12816        assert_eq!(d.source, "Hello Xworld\n");
12817    }
12818
12819    #[test]
12820    fn markdown_authors_a_highlight_and_a_strikethrough() {
12821        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
12822        // write. `==x==` is authorable because leaf's own `parse_extensions`
12823        // turns `highlight` on — twig will only mint bytes this editor's reparse
12824        // reads back — and `~~x~~` because GFM strikethrough is parsed by
12825        // default, so the refusal there was never right for any leaf document.
12826        for (kind, marked) in [
12827            (InlineKind::Mark, "a ==word== b\n"),
12828            (InlineKind::Delete, "a ~~word~~ b\n"),
12829        ] {
12830            let mut d = doc_with("author_mark", "a word b\n");
12831            d.anchor = Some(2);
12832            d.caret = 6;
12833            d.toggle(kind);
12834            assert_eq!(d.source, marked, "{kind:?}");
12835            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
12836            assert!(d.dirty, "{kind:?}");
12837            // The region stays selected, so the second press reverses it — the
12838            // property that separates authoring from a one-way trip.
12839            d.toggle(kind);
12840            assert_eq!(d.source, "a word b\n", "{kind:?}");
12841        }
12842    }
12843
12844    #[test]
12845    fn an_authored_highlight_reads_back_as_a_mark() {
12846        // The round trip the extension gate exists to protect: what the toggle
12847        // writes, the reparse must read back as a `mark` rather than as two
12848        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
12849        // rebuilt map rather than from the source text.
12850        let mut d = doc_with("mark_roundtrip", "a word b\n");
12851        d.view = View::Wysiwyg;
12852        d.build_visual(80);
12853        d.anchor = Some(2);
12854        d.caret = 6;
12855        d.toggle(InlineKind::Mark);
12856        assert_eq!(d.source, "a ==word== b\n");
12857        d.build_visual(80);
12858        let w = d
12859            .vmap
12860            .rows
12861            .iter()
12862            .flat_map(|r| r.glyphs.iter())
12863            .find(|g| g.ch == 'w')
12864            .expect("the highlighted word");
12865        assert_eq!(w.style.role, crate::Role::Mark(None));
12866    }
12867
12868    #[test]
12869    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
12870        // The three states of one gesture, in the order a palette is pressed:
12871        // an uncoloured highlight takes the prefix, a coloured one has it
12872        // replaced, and `None` takes it away with the space that was part of the
12873        // spelling.
12874        let mut d = doc_with("mark_colour", "a ==word== b\n");
12875        d.caret = d.source.find("word").unwrap();
12876        d.set_mark_color(Some(MarkColor::Red));
12877        assert_eq!(d.source, "a ==🔴 word== b\n");
12878        assert_eq!(d.status, None);
12879        assert!(d.dirty);
12880
12881        d.set_mark_color(Some(MarkColor::Blue));
12882        assert_eq!(d.source, "a ==🔵 word== b\n");
12883
12884        d.set_mark_color(None);
12885        assert_eq!(d.source, "a ==word== b\n");
12886    }
12887
12888    #[test]
12889    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
12890        // The prefix is written *before* the word, so an offset in the word has
12891        // to ride its width — a caret that stayed put would be a caret that
12892        // walked backwards through the text it was standing in.
12893        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
12894        let word = d.source.find("word").unwrap();
12895        d.caret = word + 2; // between `wo` and `rd`
12896        d.set_mark_color(Some(MarkColor::Red));
12897        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
12898
12899        // And back the other way when the prefix goes.
12900        d.set_mark_color(None);
12901        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
12902    }
12903
12904    #[test]
12905    fn the_colour_at_the_caret_is_what_the_palette_lights() {
12906        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
12907        d.caret = d.source.find("red").unwrap();
12908        assert!(d.caret_in_mark());
12909        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
12910
12911        d.caret = d.source.find("plain").unwrap();
12912        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
12913        assert_eq!(d.mark_color_at_caret(), None);
12914
12915        d.caret = d.source.find(" and ").unwrap() + 2;
12916        assert!(!d.caret_in_mark());
12917        assert_eq!(d.mark_color_at_caret(), None);
12918    }
12919
12920    #[test]
12921    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
12922        // The gesture colours a highlight that exists; it does not make one.
12923        // Two presses is the price of a coloured highlight from bare text, and
12924        // the reason is undo — one press that spliced twice would take two
12925        // presses to take back.
12926        let mut d = doc_with("mark_colour_none", "a word b\n");
12927        d.caret = d.source.find("word").unwrap();
12928        d.set_mark_color(Some(MarkColor::Red));
12929        assert_eq!(d.source, "a word b\n");
12930        assert!(d.status.is_some(), "it should say why");
12931        assert!(!d.dirty);
12932
12933        // Clearing where there is nothing to clear is the same refusal, not a
12934        // quiet success — the caret is in no highlight either way.
12935        d.status = None;
12936        d.set_mark_color(None);
12937        assert_eq!(d.source, "a word b\n");
12938        assert!(d.status.is_some());
12939    }
12940
12941    #[test]
12942    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
12943        // twig answers this one *successfully* with a `Change` describing some
12944        // earlier edit, so a caller that trusted the change would jump the caret
12945        // to wherever that was. Core answers it before asking.
12946        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
12947        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
12948        d.caret = d.source.find("word").unwrap();
12949        let (source, caret) = (d.source.clone(), d.caret);
12950        d.set_mark_color(None);
12951        assert_eq!(d.source, source);
12952        assert_eq!(
12953            d.caret, caret,
12954            "the caret must not ride a change that isn't one"
12955        );
12956        assert_eq!(d.status, None, "and it is not an error either");
12957    }
12958
12959    #[test]
12960    fn djot_spells_the_highlight_and_not_its_colour() {
12961        // The reason the palette is its own capability rather than the Highlight
12962        // button's: `{=word=}` is a highlight djot writes happily, and there is
12963        // no djot spelling for a colour on it.
12964        assert!(Capabilities::of(Format::Djot).mark);
12965        assert!(!Capabilities::of(Format::Djot).mark_color);
12966        assert!(Capabilities::of(Format::Markdown).mark_color);
12967
12968        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
12969        d.caret = d.source.find("word").unwrap();
12970        assert!(
12971            d.caret_in_mark(),
12972            "the caret is in a highlight all the same"
12973        );
12974        d.set_mark_color(Some(MarkColor::Red));
12975        assert_eq!(d.source, "a {=word=} b\n");
12976        assert!(
12977            d.status.as_deref().unwrap_or("").contains("djot"),
12978            "and the refusal names the document's format: {:?}",
12979            d.status
12980        );
12981    }
12982
12983    #[test]
12984    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
12985        // The round trip that matters for a palette: the bytes twig writes are
12986        // bytes its own reparse reads back as a colour, so the swatch that was
12987        // pressed is the swatch that lights afterwards.
12988        let mut d = doc_with("mark_colour_undo", "a word b\n");
12989        d.anchor = Some(2);
12990        d.caret = 6;
12991        d.toggle(InlineKind::Mark);
12992        d.caret = d.source.find("word").unwrap();
12993        d.set_mark_color(Some(MarkColor::Green));
12994        assert_eq!(d.source, "a ==🟢 word== b\n");
12995        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
12996
12997        // One splice, one step: the colour comes off and the highlight stays.
12998        d.undo();
12999        assert_eq!(d.source, "a ==word== b\n");
13000        d.undo();
13001        assert_eq!(d.source, "a word b\n");
13002    }
13003
13004    #[test]
13005    fn every_colour_leaf_names_is_one_twig_writes() {
13006        // The two enums are one vocabulary, and this is what says so: each of
13007        // leaf's colours writes an emoji twig's reparse reads back as *that*
13008        // colour, so `twig_mark_color`'s table cannot quietly pair red with
13009        // orange.
13010        for color in MarkColor::ALL {
13011            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
13012            d.caret = d.source.find("word").unwrap();
13013            d.set_mark_color(Some(color));
13014            assert_eq!(d.status, None, "{color:?}");
13015            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
13016        }
13017    }
13018
13019    #[test]
13020    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
13021        // The two presses a coloured highlight is made of, in the state the
13022        // first one leaves: `toggle` selects the whole `==word==` and puts the
13023        // caret one past the closing `==`, which is *not* in the mark. Asking at
13024        // the caret alone would refuse to colour the highlight just written —
13025        // the selection's start is what answers.
13026        let mut d = doc_with("mark_colour_fresh", "a word b\n");
13027        d.anchor = Some(2);
13028        d.caret = 6;
13029        d.toggle(InlineKind::Mark);
13030        assert_eq!(d.source, "a ==word== b\n");
13031        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
13032
13033        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
13034        d.set_mark_color(Some(MarkColor::Yellow));
13035        assert_eq!(d.source, "a ==🟡 word== b\n");
13036        assert_eq!(d.status, None);
13037    }
13038
13039    #[test]
13040    fn one_press_highlights_a_selection_and_colours_it() {
13041        // What a toolbar swatch means over a plain selection, and the undo it
13042        // has to have: one press, one step. Two steps would leave an uncoloured
13043        // highlight behind on the way back, which is a state the author never
13044        // asked for and never saw.
13045        let mut d = doc_with("highlight_one", "a word b\n");
13046        d.anchor = Some(2);
13047        d.caret = 6;
13048        d.highlight(Some(MarkColor::Purple));
13049        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
13050        assert_eq!(d.status, None);
13051
13052        d.undo();
13053        assert_eq!(d.source, "a word b\n", "one press, one undo");
13054    }
13055
13056    #[test]
13057    fn one_press_on_an_existing_highlight_only_recolours_it() {
13058        // The other half: inside a highlight there is nothing to make, so the
13059        // compound is the plain gesture and the text is untouched.
13060        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
13061        d.caret = d.source.find("word").unwrap();
13062        d.highlight(Some(MarkColor::Blue));
13063        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
13064        d.undo();
13065        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
13066    }
13067
13068    #[test]
13069    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
13070        // `None` means "no colour", and over bare text that is the Highlight
13071        // button's own job. The fold must not happen here — there is no second
13072        // splice, and folding would take the *previous* edit into this one.
13073        let mut d = doc_with("highlight_none", "a word b and more\n");
13074        d.caret = d.source.find("more").unwrap() + 4; // after "more"
13075        d.insert("!"); // an earlier edit for a wrong fold to swallow
13076        d.anchor = Some(2);
13077        d.caret = 6;
13078        d.highlight(None);
13079        assert_eq!(d.source, "a ==word== b and more!\n");
13080
13081        d.undo();
13082        assert_eq!(
13083            d.source, "a word b and more!\n",
13084            "only the highlight came off"
13085        );
13086        d.undo();
13087        assert_eq!(
13088            d.source, "a word b and more\n",
13089            "and the edit before it survived"
13090        );
13091    }
13092
13093    #[test]
13094    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
13095        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
13096        // a colour cannot be armed with it — so the compound declines rather
13097        // than leaving half a promise.
13098        let mut d = doc_with("highlight_bare", "a word b\n");
13099        d.caret = 4;
13100        d.highlight(Some(MarkColor::Red));
13101        assert_eq!(d.source, "a word b\n");
13102        assert!(d.pending_marks.is_empty(), "and nothing armed");
13103        assert!(d.status.is_some());
13104    }
13105
13106    #[test]
13107    fn a_read_only_document_takes_no_colour() {
13108        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
13109        d.caret = d.source.find("word").unwrap();
13110        d.set_read_only(true);
13111        d.set_mark_color(Some(MarkColor::Red));
13112        assert_eq!(d.source, "a ==word== b\n");
13113    }
13114
13115    #[test]
13116    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
13117        // The other door into `toggle`: no selection, so nothing reaches twig
13118        // until `insert` realises the armed mark. It is armed now — the guard
13119        // above asks `Doc::supports`, which asks with the extensions — and what
13120        // it writes is the same `==…==`.
13121        let mut d = doc_with("sticky_mark", "xy\n");
13122        d.caret = 1;
13123        d.toggle(InlineKind::Mark);
13124        assert!(d.pending_marks.contains(InlineKind::Mark));
13125        d.insert("Z");
13126        assert_eq!(d.source, "x==Z==y\n");
13127    }
13128
13129    #[test]
13130    fn html_documents_still_take_typed_text() {
13131        // The guard covers *markup* gestures and must not touch plain editing:
13132        // twig's splicer is language-neutral, and typing into an HTML document
13133        // is the thing that does work today.
13134        let mut d = html_doc("<p>Hello world</p>\n");
13135        d.caret = d.source.find("world").unwrap();
13136        d.insert("big ");
13137        assert_eq!(d.source, "<p>Hello big world</p>\n");
13138        assert!(d.dirty);
13139        d.backspace();
13140        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
13141        d.undo();
13142        d.undo();
13143        assert_eq!(d.source, "<p>Hello world</p>\n");
13144    }
13145
13146    #[test]
13147    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
13148        // `authorable` only separates "there is a door in" from "there is not",
13149        // and HTML is on the near side of that line — which is exactly why a
13150        // toolbar must not be built from it.
13151        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
13152        assert!(html.authorable());
13153        assert!(
13154            !Doc::from_source("<r>x</r>".into(), Format::Xml)
13155                .unwrap()
13156                .authorable()
13157        );
13158
13159        let caps = html.capabilities();
13160        assert!(caps.bold && caps.italic && caps.code && caps.mark);
13161        assert!(caps.thematic_break && caps.cell_line_break);
13162        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
13163        // quote, a list, a code block's language, a link and an image — each
13164        // printed as a fresh node where HTML has no marker to rewrite. A task
13165        // box is a form control and a footnote has no spelling, so those two
13166        // are what keeps the record ragged.
13167        assert!(caps.heading && caps.blockquote && caps.bullet_list);
13168        assert!(caps.link && caps.image && caps.code_language);
13169        assert!(!caps.task && !caps.footnote);
13170        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
13171        // twig's table editor would happily re-emit as `| a | b |`.
13172        assert!(!caps.table);
13173
13174        // The two lightweight formats spell everything leaf offers — and still
13175        // differ from each other, which is the other half of why one boolean
13176        // can't serve.
13177        for fmt in [Format::Markdown, Format::Djot] {
13178            let caps = Capabilities::of(fmt);
13179            assert!(
13180                caps.heading && caps.blockquote && caps.ordered_list,
13181                "{fmt:?}"
13182            );
13183            assert!(
13184                caps.task && caps.link && caps.image && caps.table,
13185                "{fmt:?}"
13186            );
13187        }
13188        // Both spell the highlight and the strikethrough: djot natively, and
13189        // Markdown because `Capabilities` asks with `parse_extensions` rather
13190        // than with twig's defaults — `==x==` is text under those, and a mark
13191        // under the `highlight` leaf always parses with.
13192        for fmt in [Format::Markdown, Format::Djot] {
13193            let caps = Capabilities::of(fmt);
13194            assert!(caps.mark && caps.strike, "{fmt:?}");
13195        }
13196        // What still separates them, now that the highlight doesn't: djot has
13197        // no in-cell break, and Markdown spells neither of the scripts.
13198        assert!(Capabilities::of(Format::Djot).superscript);
13199        assert!(!Capabilities::of(Format::Markdown).superscript);
13200        assert!(Capabilities::of(Format::Markdown).cell_line_break);
13201        assert!(!Capabilities::of(Format::Djot).cell_line_break);
13202
13203        // A parse-only format answers no to every one of them, so the coarse
13204        // predicate and the record agree there.
13205        let caps = Capabilities::of(Format::Xml);
13206        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
13207    }
13208
13209    #[test]
13210    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
13211        // The guard exists to name the *document's* format rather than twig's
13212        // internals, so the message has to survive being one leaf writes itself.
13213        // Checked against a gesture twig also refuses, since that is the pair
13214        // most at risk of drifting apart — the task box, once the code
13215        // language stopped being one (twig 3.5).
13216        let mut d = html_doc("<p>Hello</p>\n");
13217        d.caret = d.source.find("Hello").unwrap();
13218        d.toggle_task_item();
13219        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
13220        assert!(!d.dirty);
13221    }
13222
13223    #[test]
13224    fn table_set_alignment_respells_the_delimiter() {
13225        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13226        d.caret = d.source.find('b').unwrap();
13227        d.table_set_alignment(Alignment::Right);
13228        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
13229    }
13230
13231    #[test]
13232    fn each_empty_table_cell_has_its_own_editable_home() {
13233        // Regression: an empty cell has no twig content_span, so both cells of a
13234        // `|  |  |` row collapsed onto the row's start (before the first `│`).
13235        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
13236        // tell the cells apart. Each empty cell must now have a distinct home
13237        // inside it.
13238        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
13239        let (c0, c1) = {
13240            let cells = &d.vmap.tables[0].grid[1].cells;
13241            (cells[0].start, cells[1].start)
13242        };
13243        assert!(
13244            c0 < c1,
13245            "the two empty cells have distinct homes: {c0} < {c1}"
13246        );
13247        d.caret = c0;
13248        d.insert("x");
13249        assert_eq!(
13250            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
13251            "typed inside the cell"
13252        );
13253    }
13254
13255    #[test]
13256    fn arrows_step_into_each_empty_table_cell() {
13257        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
13258        let (c0, c1) = {
13259            let cells = &d.vmap.tables[0].grid[1].cells;
13260            (cells[0].start, cells[1].start)
13261        };
13262        d.caret = d.source.find('b').unwrap(); // in the header's second cell
13263        let mut seen = std::collections::HashSet::new();
13264        for _ in 0..6 {
13265            d.move_right(false);
13266            seen.insert(d.caret);
13267        }
13268        assert!(
13269            seen.contains(&c0),
13270            "right arrow reaches the first empty cell"
13271        );
13272        assert!(
13273            seen.contains(&c1),
13274            "right arrow reaches the second empty cell"
13275        );
13276    }
13277
13278    #[test]
13279    fn table_op_off_a_table_is_a_no_op_with_a_status() {
13280        let mut d = doc_with("tbl_none", "just text\n");
13281        d.caret = 3;
13282        d.table_insert_row(true);
13283        assert_eq!(d.source, "just text\n", "nothing changed");
13284        assert!(d.status.is_some(), "a status explains why");
13285        assert!(!d.caret_in_table());
13286    }
13287
13288    #[test]
13289    fn enter_in_an_ordered_list_renumbers_the_following_items() {
13290        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
13291        // the renumber pass keeps them sequential, matching what the view draws.
13292        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
13293        d.caret = d.source.find('a').unwrap() + 1; // end of item a
13294        d.newline();
13295        d.insert("x");
13296        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
13297    }
13298
13299    #[test]
13300    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
13301        for view in [View::Source, View::Wysiwyg] {
13302            let mut d = doc_in(view, "outdent_noop", "hello\n");
13303            d.caret = 2;
13304            d.outdent();
13305            assert_eq!(d.source, "hello\n");
13306            assert!(!d.dirty, "a no-op is not a modification");
13307            d.undo();
13308            assert_eq!(
13309                d.status.as_deref(),
13310                Some("nothing to undo"),
13311                "spends no undo step"
13312            );
13313            assert_eq!(d.source, "hello\n");
13314        }
13315    }
13316
13317    #[test]
13318    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
13319        for view in [View::Source, View::Wysiwyg] {
13320            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
13321            d.anchor = Some(0);
13322            d.caret = 7; // through "two"
13323            d.indent();
13324            assert_eq!(
13325                d.source, "  one\n\n  two\n",
13326                "the blank line keeps no trailing pad"
13327            );
13328            // Selected, so a second Tab lands on the same lines rather than on
13329            // whatever the shifted offsets now cover.
13330            assert_eq!(d.selection(), Some((0, 12)));
13331            d.indent();
13332            assert_eq!(d.source, "    one\n\n    two\n");
13333        }
13334    }
13335
13336    #[test]
13337    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
13338        for view in [View::Source, View::Wysiwyg] {
13339            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
13340            d.anchor = Some(0);
13341            d.caret = 15;
13342            d.outdent();
13343            assert_eq!(d.source, "two\none\nnone\n");
13344        }
13345    }
13346
13347    #[test]
13348    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
13349        for view in [View::Source, View::Wysiwyg] {
13350            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
13351            d.anchor = Some(0);
13352            d.caret = 7;
13353            d.indent();
13354            assert_eq!(d.source, "  one\n\n  two\n");
13355            d.undo();
13356            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
13357            assert_eq!(
13358                d.selection(),
13359                Some((0, 7)),
13360                "with the selection it was aimed at"
13361            );
13362            d.redo();
13363            assert_eq!(d.source, "  one\n\n  two\n");
13364            assert_eq!(
13365                d.selection(),
13366                Some((0, 12)),
13367                "redo replays the caret the indent placed, not the one splice left"
13368            );
13369        }
13370    }
13371
13372    #[test]
13373    fn vertical_motion_keeps_the_column() {
13374        let mut d = doc_with("move", "abcd\nef\n");
13375        d.caret = 3; // "abc|d" on row 0, col 3
13376        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
13377        assert_eq!(d.caret, 7); // just after "ef"
13378    }
13379
13380    // ── goal column ──────────────────────────────────────────────────────────
13381
13382    #[test]
13383    fn vertical_motion_goal_column_survives_a_short_line() {
13384        // Regression: re-deriving the column from the clamped position on
13385        // every step permanently forgets it once a short line clamps it.
13386        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
13387        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
13388        assert_eq!(
13389            g("abcd|ef\nxy\nghijkl\n", |d| {
13390                d.move_down(false); // clamps to end of "xy"
13391                d.move_down(false); // restores col 4 on the long line
13392            }),
13393            "abcdef\nxy\nghij|kl\n"
13394        );
13395    }
13396
13397    #[test]
13398    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
13399        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
13400        assert_eq!(d.goal_col, None);
13401        d.caret = 4; // row 0, col 4
13402        d.move_down(false); // clamps into "xy"; goal stays the original col
13403        assert_eq!(d.goal_col, Some(4));
13404        assert_eq!(d.caret_pos(), (1, 2));
13405
13406        // A horizontal motion drops the goal column...
13407        d.move_left(false);
13408        assert_eq!(d.goal_col, None);
13409
13410        // ...so the next vertical motion picks up the *new* column (1), not
13411        // the stale one (4).
13412        d.move_down(false);
13413        assert_eq!(d.goal_col, Some(1));
13414        assert_eq!(d.caret_pos(), (2, 1));
13415    }
13416
13417    #[test]
13418    fn editing_clears_the_goal_column() {
13419        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
13420        d.caret = 4;
13421        d.move_down(false);
13422        assert_eq!(d.goal_col, Some(4));
13423        d.insert("Z");
13424        assert_eq!(d.goal_col, None);
13425    }
13426
13427    #[test]
13428    fn vertical_motion_on_an_empty_document_is_a_no_op() {
13429        let mut d = doc_with("empty_vert", "");
13430        d.move_down(false);
13431        assert_eq!(d.caret, 0);
13432        d.move_up(false);
13433        assert_eq!(d.caret, 0);
13434    }
13435
13436    // ── the document's edges ─────────────────────────────────────────────────
13437
13438    #[test]
13439    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
13440        // The reproduction, and the disagreement: Down on the last line ran to
13441        // the end of the document in the source view — by accident, an
13442        // out-of-range row clamping to the end of the string — and did nothing
13443        // whatever in the view leaf opens in. One rule now, in both.
13444        for (view, tag) in VIEWS {
13445            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
13446            d.caret = 1;
13447            d.move_down(false);
13448            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
13449            d.move_up(false);
13450            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
13451        }
13452    }
13453
13454    #[test]
13455    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
13456        // Down off the bottom is a motion like any other, so it latches a goal
13457        // column — and Up comes back to the column the caret left, not to the
13458        // one the document's end happened to be in.
13459        for (view, tag) in VIEWS {
13460            let gap = if view == View::Source { "\n" } else { "\n\n" };
13461            let src = format!("abcdef{gap}ghijkl");
13462            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
13463            d.caret = 2; // row 0, col 2
13464            d.move_down(false);
13465            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
13466            d.move_down(false);
13467            assert_eq!(
13468                d.caret,
13469                src.len(),
13470                "{tag}: Down off the bottom reaches the end"
13471            );
13472            d.move_up(false);
13473            assert_eq!(
13474                d.caret_pos().1,
13475                2,
13476                "{tag}: Up returns to the column Down left"
13477            );
13478        }
13479    }
13480
13481    #[test]
13482    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
13483        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
13484        // Up that did nothing still armed a goal column, and the next Down aimed
13485        // at a column the caret had never been in.
13486        for (view, tag) in VIEWS {
13487            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
13488            d.caret = 0;
13489            d.move_up(false);
13490            assert_eq!(d.caret, 0, "{tag}: already at the start");
13491            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
13492
13493            d.caret = d.source.len();
13494            d.move_down(false);
13495            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
13496            assert_eq!(
13497                d.goal_col, None,
13498                "{tag}: a no-op Down latched a goal column"
13499            );
13500        }
13501    }
13502
13503    // ── soft wrap ────────────────────────────────────────────────────────────
13504    // Every other test here builds the map at 80 columns, where no fixture is
13505    // long enough to fold. A wrap is where one offset belongs to two rows at
13506    // once, and it broke everything that asks the caret what row it is on.
13507
13508    /// The wrapped fixture these cases share, folded at 12 columns into
13509    /// `one two ` / `three four ` / `five six ` / `seven eight`.
13510    fn wrapped_doc(name: &str) -> Doc {
13511        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
13512        d.build_visual(12);
13513        d
13514    }
13515
13516    #[test]
13517    fn home_and_end_work_from_a_wrapped_row() {
13518        // The reproduction: offset 19 is the `f` of "five", the first character
13519        // of the third row — and also the offset the second row ends at. It
13520        // resolved to the *second* row, so End aimed at a place the caret was
13521        // already in and did nothing, while Home walked backwards onto a row the
13522        // caret had left.
13523        let mut d = wrapped_doc("wrap_home_end");
13524        d.caret = 19;
13525        assert_eq!(
13526            d.caret_pos(),
13527            (2, 0),
13528            "the wrap boundary opens the third row"
13529        );
13530        d.move_end(false);
13531        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
13532        d.move_home(false);
13533        assert_eq!(d.caret, 19, "Home left the row the caret was on");
13534    }
13535
13536    #[test]
13537    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
13538        // The row's end is the last offset that is only ever its own: the offset
13539        // past it opens the row below, and aiming there would send a second
13540        // press on to *that* row's end, and a third to the next — End walking
13541        // down the paragraph rather than sitting where it landed.
13542        let mut d = wrapped_doc("wrap_end_twice");
13543        d.caret = 12; // inside "three", on the second row
13544        d.move_end(false);
13545        assert_eq!(
13546            d.caret, 18,
13547            "the end of `three four`, before the space the wrap ate"
13548        );
13549        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
13550        d.move_end(false);
13551        assert_eq!(d.caret, 18, "a second End moved the caret");
13552        d.move_home(false);
13553        assert_eq!(d.caret, 8, "Home takes the row's own start");
13554    }
13555
13556    #[test]
13557    fn vertical_motion_crosses_a_soft_wrap() {
13558        // Down aimed at the row below's column 0, an offset that resolved *up*
13559        // to the row above's end — so it landed on the offset it already had and
13560        // the caret could never leave a paragraph's first row.
13561        let mut d = wrapped_doc("wrap_down");
13562        d.caret = 0;
13563        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
13564            d.move_down(false);
13565            assert_eq!(d.caret, want, "Down stalled");
13566            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
13567        }
13568        d.move_down(false);
13569        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
13570
13571        // ...and back up, one row per press. The goal column is the end of the
13572        // last row, past every other row's width, so each press clamps to the
13573        // row's own last offset rather than to the one that opens the next.
13574        let mut d = wrapped_doc("wrap_up");
13575        d.caret = 39;
13576        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
13577            d.move_up(false);
13578            assert_eq!(d.caret, want, "Up stalled");
13579            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
13580        }
13581    }
13582
13583    #[test]
13584    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
13585        // The kills take the same line Home and End do, so in WYSIWYG they take
13586        // the visual row — and a soft wrap has no newline in it to delete, so
13587        // nothing is joined by reaching the end of one.
13588        let mut d = wrapped_doc("wrap_kill");
13589        d.caret = 19; // the `f` of "five", opening the third row
13590        d.delete_to_line_end();
13591        // The space the wrap ate goes with the row it was drawn on: sparing it
13592        // would leave "four  seven", two spaces where the row had been.
13593        assert_eq!(d.source, "one two three four seven eight");
13594
13595        // Backwards from the row's last caret position — which is *before* that
13596        // space, so this one survives, being on the far side of the caret.
13597        let mut d = wrapped_doc("wrap_kill_back");
13598        d.caret = 27;
13599        d.delete_to_line_start();
13600        assert_eq!(d.source, "one two three four  seven eight");
13601    }
13602
13603    // ── document start / end ────────────────────────────────────────────────
13604
13605    #[test]
13606    fn move_doc_start_and_end_jump_to_the_edges() {
13607        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
13608        assert_eq!(
13609            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
13610            "|hello\nworld\n"
13611        );
13612        assert_eq!(
13613            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
13614            "hello\nworld\n|"
13615        );
13616        // Already at the edge: a no-op.
13617        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
13618        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
13619    }
13620
13621    #[test]
13622    fn move_doc_start_and_end_extend_the_selection() {
13623        assert_eq!(
13624            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
13625                .move_doc_end(true)),
13626            "hello wor[ld\n|]"
13627        );
13628        assert_eq!(
13629            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
13630                .move_doc_start(true)),
13631            "[|hello wor]ld\n"
13632        );
13633    }
13634
13635    #[test]
13636    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
13637        let mut d = doc_with("empty_edges", "");
13638        d.move_doc_end(false);
13639        assert_eq!(d.caret, 0);
13640        d.move_doc_start(false);
13641        assert_eq!(d.caret, 0);
13642    }
13643
13644    // ── arrow collapses an active selection ─────────────────────────────────
13645
13646    #[test]
13647    fn arrow_collapses_selection_to_its_near_edge() {
13648        let mut d = doc_with("collapse", "hello world\n");
13649
13650        // Forward selection (anchor before caret): Right -> end, Left -> start.
13651        d.anchor = Some(2);
13652        d.caret = 7;
13653        d.move_right(false);
13654        assert_eq!((d.caret, d.anchor), (7, None));
13655
13656        d.anchor = Some(2);
13657        d.caret = 7;
13658        d.move_left(false);
13659        assert_eq!((d.caret, d.anchor), (2, None));
13660
13661        // Backward selection (anchor after caret): edges are the same
13662        // regardless of which end the caret started on.
13663        d.anchor = Some(7);
13664        d.caret = 2;
13665        d.move_right(false);
13666        assert_eq!((d.caret, d.anchor), (7, None));
13667
13668        d.anchor = Some(7);
13669        d.caret = 2;
13670        d.move_left(false);
13671        assert_eq!((d.caret, d.anchor), (2, None));
13672    }
13673
13674    #[test]
13675    fn arrow_with_extend_keeps_growing_the_selection() {
13676        let mut d = doc_with("collapse_extend", "hello world\n");
13677        d.anchor = Some(2);
13678        d.caret = 7;
13679        d.move_right(true); // extend: no collapse, caret steps one further
13680        assert_eq!((d.caret, d.anchor), (8, Some(2)));
13681    }
13682
13683    #[test]
13684    fn arrow_without_a_selection_moves_one_character_as_before() {
13685        let mut d = doc_with("no_collapse", "hello\n");
13686        d.caret = 2;
13687        d.move_right(false);
13688        assert_eq!(d.caret, 3);
13689        d.move_left(false);
13690        assert_eq!(d.caret, 2);
13691    }
13692
13693    /// Press Right until it stops, collecting the offsets walked through. Every
13694    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
13695    /// two stops sharing one source offset can't be moved between, so the caret
13696    /// stalls on the first of them and the walk never reaches the rest.
13697    fn walk_right(d: &mut Doc) -> Vec<usize> {
13698        let mut seen = vec![d.caret];
13699        for _ in 0..2000 {
13700            let before = d.caret;
13701            d.move_right(false);
13702            if d.caret == before {
13703                break;
13704            }
13705            seen.push(d.caret);
13706        }
13707        seen
13708    }
13709
13710    #[test]
13711    fn the_caret_crosses_a_soft_break() {
13712        // A newline inside a paragraph is a `soft_break`, which twig gives no
13713        // span of its own — the space it renders as used to borrow the offset of
13714        // the character before it, and a caret can't move without changing
13715        // offset. Right must walk clean off the end of the first line.
13716        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
13717        d.caret = 0;
13718        let seen = walk_right(&mut d);
13719        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
13720    }
13721
13722    #[test]
13723    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
13724        // The paragraph holds one soft break. Folded (the default) it lays out as
13725        // a single reflowed row; Preserve re-lays it as a row per source line.
13726        // The setter must invalidate the cached map for the change to show, and
13727        // again on the way back — so a round trip returns to the folded layout.
13728        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
13729        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
13730        d.build_visual(80);
13731        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
13732
13733        d.set_line_flow(LineFlow::Preserve);
13734        d.build_visual(80);
13735        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
13736
13737        d.set_line_flow(LineFlow::Fold);
13738        d.build_visual(80);
13739        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
13740    }
13741
13742    #[test]
13743    fn the_caret_still_crosses_a_preserved_soft_break() {
13744        // Preserve renders the soft break as a row boundary rather than a space,
13745        // but the caret must still reach every offset — the break's own offset is
13746        // the first row's end stop, so Right walks clean off the end of line one
13747        // onto line two, exactly as it does when the break is folded.
13748        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
13749        d.set_line_flow(LineFlow::Preserve);
13750        d.build_visual(80);
13751        d.caret = 0;
13752        let seen = walk_right(&mut d);
13753        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
13754    }
13755
13756    #[test]
13757    fn the_caret_walks_a_code_block() {
13758        // Every glyph of a code block used to map to the block's start, so the
13759        // whole block was a single offset and the caret couldn't move inside it.
13760        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
13761        let mut d = wysiwyg_doc("code_walk", src);
13762        d.caret = 0;
13763        let seen = walk_right(&mut d);
13764        // The fences are markup: hidden, and no caret stop. The code between
13765        // them is reached a character at a time.
13766        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
13767        for off in code.clone() {
13768            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
13769        }
13770        assert!(seen.contains(&code.end), "no stop after the last line");
13771    }
13772
13773    #[test]
13774    fn the_caret_walks_an_indented_code_block() {
13775        // An indented block's text has the four-space indent stripped, so it
13776        // isn't a verbatim slice and its lines have to be re-found. The caret
13777        // lands on the code, never in the indent.
13778        let src = "    indented\n    code\n";
13779        let mut d = wysiwyg_doc("indent_code_walk", src);
13780        d.caret = 0;
13781        let seen = walk_right(&mut d);
13782        assert!(seen.contains(&src.find("indented").unwrap()));
13783        assert!(seen.contains(&src.find("code").unwrap()));
13784        assert!(
13785            !seen.contains(&0) || seen[0] == 0,
13786            "the caret starts where it was put"
13787        );
13788        // Nothing in the stripped indent is a stop.
13789        for off in [1, 2, 3] {
13790            assert!(!seen.contains(&off), "landed in the indent at {off}");
13791        }
13792    }
13793
13794    #[test]
13795    fn the_caret_leaves_a_tight_heading() {
13796        // "# H" with text directly under it: the heading row's end and the
13797        // separator row's end are the same offset. Right used to find the
13798        // separator's copy, set the caret to where it already was, and stop.
13799        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
13800        d.caret = 2; // the "H"
13801        let seen = walk_right(&mut d);
13802        assert!(
13803            seen.len() > 2,
13804            "Right stalled at the heading's end: {seen:?}"
13805        );
13806        assert!(
13807            seen.contains(&8),
13808            "never reached the end of \"text\": {seen:?}"
13809        );
13810    }
13811
13812    #[test]
13813    fn the_caret_skips_the_gap_between_two_paragraphs() {
13814        // The blank line between two paragraphs is the boundary itself. The
13815        // caret used to be able to sit on it, and typing there landed in the
13816        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
13817        // soft break, so the text visibly snapped back up.
13818        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
13819        d.caret = 1; // the end of "A"
13820        d.move_right(false);
13821        assert_eq!(d.caret, 3, "Right stopped in the gap");
13822        d.insert("x");
13823        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
13824    }
13825
13826    #[test]
13827    fn down_from_a_paragraph_lands_on_the_next_one() {
13828        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
13829        d.caret = 0;
13830        d.move_down(false);
13831        assert_eq!(d.caret, 3, "Down stopped in the gap");
13832    }
13833
13834    #[test]
13835    fn clicking_the_gap_lands_on_real_text() {
13836        // A click can still *reach* the gap — it's drawn, so it's clickable.
13837        // It has to resolve to somewhere the caret can be.
13838        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
13839        d.click(1, 0, false); // the gap row
13840        assert!(
13841            d.caret == 1 || d.caret == 3,
13842            "click left the caret in the gap at {}",
13843            d.caret
13844        );
13845        d.insert("x");
13846        // Either edge of the boundary is a fair place to land; inside it isn't.
13847        assert!(
13848            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
13849            "click in the gap typed into the boundary: {:?}",
13850            d.source
13851        );
13852    }
13853
13854    #[test]
13855    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
13856        // Enter inserts a paragraph break, which leaves a blank line spare on
13857        // either side of a new one. That middle line is a real empty paragraph:
13858        // the caret lands there, and typing makes a paragraph rather than
13859        // extending a neighbour.
13860        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
13861        d.caret = 1;
13862        d.newline();
13863        assert_eq!(d.source, "A\n\n\n\nB\n");
13864        d.build_visual(80);
13865        let (row, _) = d.caret_pos();
13866        assert!(
13867            d.vmap.row_is_navigable(row),
13868            "the caret landed on a gap row"
13869        );
13870        d.insert("x");
13871        assert_eq!(
13872            d.source, "A\n\nx\n\nB\n",
13873            "the new paragraph merged into a neighbour"
13874        );
13875    }
13876
13877    #[test]
13878    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
13879        let mut d = wysiwyg_doc("gap_eof", "A\n");
13880        d.caret = 1;
13881        d.newline();
13882        d.build_visual(80);
13883        let (row, _) = d.caret_pos();
13884        assert!(
13885            d.vmap.row_is_navigable(row),
13886            "the caret landed on a gap row"
13887        );
13888        d.insert("x");
13889        assert!(
13890            d.source.starts_with("A\n\n") && d.source.contains('x'),
13891            "typing at the end merged into A: {:?}",
13892            d.source
13893        );
13894    }
13895
13896    #[test]
13897    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
13898        // A paragraph broken over two source lines is one paragraph. Selecting
13899        // it must not stop at the newline inside it — that newline is markup the
13900        // rich-text view exists to hide.
13901        let src = "one two\nthree four\n\nnext\n";
13902        let mut d = wysiwyg_doc("triple_para", src);
13903        d.select_block_at(2);
13904        assert_eq!(
13905            d.selected_text(),
13906            Some("one two\nthree four"),
13907            "stopped at the soft break"
13908        );
13909    }
13910
13911    #[test]
13912    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
13913        // The reader scrolls down past the caret's row. Nothing moved the
13914        // caret, so the view must stay where it was put — the old code revealed
13915        // the caret every frame, which dragged the view straight back and made
13916        // the document unscrollable past the caret.
13917        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
13918        d.caret = 0;
13919        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
13920        d.scroll = 4; // the wheel
13921        d.follow_caret(0, 3, 9);
13922        assert_eq!(
13923            d.scroll, 4,
13924            "the wheel was overruled by a caret that never moved"
13925        );
13926    }
13927
13928    #[test]
13929    fn moving_the_caret_brings_the_view_back_to_it() {
13930        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
13931        d.caret = 0;
13932        d.follow_caret(0, 3, 9);
13933        d.scroll = 6; // scrolled away
13934        d.move_right(false); // ...and now the caret moves
13935        let (row, _) = d.caret_pos();
13936        d.follow_caret(row, 3, 9);
13937        assert!(
13938            d.scroll <= row && row < d.scroll + 3,
13939            "caret row {row} off screen at scroll {}",
13940            d.scroll
13941        );
13942    }
13943
13944    #[test]
13945    fn scrolling_stops_at_the_last_row() {
13946        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
13947        d.caret = 0;
13948        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
13949        d.scroll = 999; // the wheel, spun hard
13950        d.follow_caret(0, 3, 3);
13951        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
13952    }
13953
13954    #[test]
13955    fn every_cell_of_a_wide_table_is_reachable() {
13956        // A table whose cells are far wider than the surface: the columns are
13957        // cut to fit and the text wraps inside them, so no cell hangs off the
13958        // right edge where the caret can never go.
13959        let src = "| Ingredient | Notes |\n|---|---|\n\
13960                   | flour milled coarse | sift it twice before folding it in |\n";
13961        let mut d = wysiwyg_doc("wide_table_walk", src);
13962        d.build_visual(30);
13963        d.caret = 0;
13964        let seen = walk_right(&mut d);
13965        for word in ["Ingredient", "Notes", "coarse", "folding"] {
13966            let at = src.find(word).unwrap();
13967            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
13968        }
13969    }
13970
13971    // ── view parity ──────────────────────────────────────────────────────────
13972    // `doc_with` pins the source view, so everything above tests a view users
13973    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
13974    // deletion golden cases through *both*, plus the WYSIWYG cases the two
13975    // can't share: where the source carries markup the rendered text is a
13976    // different string, and the views agreeing would itself be the bug.
13977
13978    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
13979
13980    /// Run `action` in both views on one `|`-marked fixture and assert they
13981    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
13982    /// source verbatim, so the two views are looking at the same text and any
13983    /// disagreement is one of them having lost the plot.
13984    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
13985        let (src, caret) = parse_caret(marked);
13986        let run = |view: View, tag: &str| {
13987            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
13988            d.caret = caret;
13989            action(&mut d);
13990            render_caret(&d)
13991        };
13992        let source = run(VIEWS[0].0, VIEWS[0].1);
13993        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
13994        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
13995        source
13996    }
13997
13998    #[test]
13999    fn word_motion_agrees_across_the_views_on_plain_prose() {
14000        let g = both_views;
14001        assert_eq!(
14002            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
14003            "hello |world"
14004        );
14005        assert_eq!(
14006            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
14007            "|hello world"
14008        );
14009        assert_eq!(
14010            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
14011            "hello| world"
14012        );
14013        assert_eq!(
14014            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
14015            "hello world|"
14016        );
14017        assert_eq!(
14018            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
14019            "foo|.bar"
14020        );
14021        assert_eq!(
14022            g("par_ext", "hello |world", |d| d.move_word_right(true)),
14023            "hello [world|]"
14024        );
14025    }
14026
14027    #[test]
14028    fn word_deletion_agrees_across_the_views_on_plain_prose() {
14029        let g = both_views;
14030        assert_eq!(
14031            g("par_db", "hello world|", |d| d.delete_word_back()),
14032            "hello |"
14033        );
14034        assert_eq!(
14035            g("par_df", "hello |world", |d| d.delete_word_forward()),
14036            "hello |"
14037        );
14038        assert_eq!(
14039            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
14040            "|bar baz"
14041        );
14042        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
14043    }
14044
14045    #[test]
14046    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
14047        let g = both_views;
14048        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
14049        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
14050        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
14051        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
14052    }
14053
14054    #[test]
14055    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
14056        // The reproduction: the stop table was built one stop per `char`, so
14057        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
14058        // source view, which steps by grapheme, can't reach and backspace can't
14059        // survive. The two views must land on the same offset.
14060        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
14061        for (view, tag) in VIEWS {
14062            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
14063            d.caret = 1;
14064            d.move_right(false);
14065            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
14066
14067            // ...and the edit that used to sever a joiner off the front of it.
14068            d.backspace();
14069            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
14070            assert_eq!(d.caret, 1);
14071        }
14072    }
14073
14074    #[test]
14075    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
14076        for (view, tag) in VIEWS {
14077            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
14078            d.caret = 0;
14079            d.move_right(false);
14080            assert_eq!(
14081                d.caret,
14082                "e\u{0301}".len(),
14083                "{tag} stopped on the combining mark"
14084            );
14085        }
14086    }
14087
14088    #[test]
14089    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
14090        // The general form: whatever route the caret takes through a document
14091        // full of clusters, it never lands between the codepoints of one — so no
14092        // motion-then-backspace sequence can leave a dangling joiner behind.
14093        use unicode_segmentation::UnicodeSegmentation;
14094
14095        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
14096        let mut d = wysiwyg_doc("cluster_walk", src);
14097        d.caret = 0;
14098        let boundaries: Vec<usize> = src
14099            .grapheme_indices(true)
14100            .map(|(i, _)| i)
14101            .chain(std::iter::once(src.len()))
14102            .collect();
14103        for off in walk_right(&mut d) {
14104            assert!(
14105                boundaries.contains(&off),
14106                "Right stopped at {off}, inside a grapheme cluster"
14107            );
14108        }
14109    }
14110
14111    #[test]
14112    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
14113        // The reproduction: ⌥→ from inside the opening `**` computed its
14114        // boundary over the raw source and landed on byte 8 — inside the
14115        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
14116        // "bold". The caret drew past the bold word and sat inside it.
14117        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
14118        d.caret = 2;
14119        d.move_word_right(false);
14120        assert!(
14121            d.vmap.is_stop(d.caret),
14122            "landed at {}, not a caret stop",
14123            d.caret
14124        );
14125        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
14126        // The rendered row is "a bold c": column 6 is the space just past "bold",
14127        // and now the caret is really there rather than only drawn there.
14128        assert_eq!(d.caret_pos(), (0, 6));
14129
14130        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
14131        d.move_word_left(false);
14132        assert_eq!(d.caret, 4);
14133        assert_eq!(d.caret_pos(), (0, 2));
14134    }
14135
14136    #[test]
14137    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
14138        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
14139        // inside the closing `**`, and left "a ** c\n" — delimiters with no
14140        // opener. Glyph space covers the word alone, which would leave
14141        // "a **** c": markup wrapped around nothing. The word and the styling
14142        // that was only ever the word's go together.
14143        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
14144        d.caret = 10;
14145        d.delete_word_back();
14146        assert_eq!(d.source, "a  c\n");
14147        assert_eq!(d.caret, 2);
14148
14149        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
14150        d.caret = 4; // the "b"
14151        d.delete_word_forward();
14152        assert_eq!(d.source, "a  c\n");
14153    }
14154
14155    #[test]
14156    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
14157        let src = "a ***bold*** c\n";
14158        let mut d = wysiwyg_doc("wys_word_del_nest", src);
14159        d.caret = src.find(" c").unwrap();
14160        d.delete_word_back();
14161        assert_eq!(
14162            d.source, "a  c\n",
14163            "the emph inside the strong empties it too"
14164        );
14165
14166        let src = "a `code` c\n";
14167        let mut d = wysiwyg_doc("wys_word_del_code", src);
14168        d.caret = src.find(" c").unwrap();
14169        d.delete_word_back();
14170        assert_eq!(d.source, "a  c\n");
14171    }
14172
14173    #[test]
14174    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
14175        // Only an *emptied* node goes. Take one word of two and the `**` still
14176        // has a job to do — over the word that's left, with the space the delete
14177        // pushed against the opening delimiter moved out in front of it, or the
14178        // run would be no run at all (`** words**` is literal asterisks — see
14179        // the mark-edge rule on `splice`).
14180        let src = "a **two words** c\n";
14181        let mut d = wysiwyg_doc("wys_word_del_partial", src);
14182        d.caret = src.find(" words").unwrap();
14183        d.delete_word_back();
14184        assert_eq!(d.source, "a  **words** c\n");
14185    }
14186
14187    #[test]
14188    fn source_view_word_motion_still_walks_the_markup() {
14189        // The other half of the decision: in the source view the `**` are
14190        // characters like any other — they're on the screen, so word motion has
14191        // to stop at them and a word-delete has to leave them behind. Only
14192        // WYSIWYG hides them, so only WYSIWYG steps over them.
14193        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
14194        assert_eq!(
14195            g("src_word_motion", "a |**bold** c\n", |d| d
14196                .move_word_right(false)),
14197            "a **bold|** c\n"
14198        );
14199        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
14200        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
14201        assert_eq!(
14202            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
14203            "a **| c\n"
14204        );
14205    }
14206
14207    #[test]
14208    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
14209        // The single invariant both bugs violated: the caret draws and edits at
14210        // the same place only when it's on a stop. `debug_assert_on_a_stop`
14211        // makes the same claim in-place; this pins it from the outside, over a
14212        // document with every kind of thing the map has to be careful about.
14213        // At two widths: the wide one every other test builds at, where no
14214        // fixture folds, and one narrow enough that they all do. A soft wrap is
14215        // where an offset stops being on exactly one row, and testing only the
14216        // width that never wraps is how the caret came to be pinned at the first
14217        // one Down reached.
14218        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
14219                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
14220        // A table of named operations, which is what it looks like.
14221        #[allow(clippy::type_complexity)]
14222        let motions: [(&str, fn(&mut Doc)); 8] = [
14223            ("right", |d| d.move_right(false)),
14224            ("left", |d| d.move_left(false)),
14225            ("word_right", |d| d.move_word_right(false)),
14226            ("word_left", |d| d.move_word_left(false)),
14227            ("down", |d| d.move_down(false)),
14228            ("up", |d| d.move_up(false)),
14229            ("home", |d| d.move_home(false)),
14230            ("end", |d| d.move_end(false)),
14231        ];
14232        for width in [80, 12] {
14233            let mut d = wysiwyg_doc("stop_invariant", src);
14234            d.build_visual(width);
14235            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14236            assert!(stops.len() > 20, "fixture should have plenty of stops");
14237            for start in stops {
14238                for (name, motion) in &motions {
14239                    d.caret = start;
14240                    d.anchor = None;
14241                    motion(&mut d);
14242                    assert!(
14243                        d.vmap.is_stop(d.caret),
14244                        "{name} from {start} at width {width} landed at {} — not a caret stop",
14245                        d.caret
14246                    );
14247                }
14248            }
14249        }
14250    }
14251
14252    #[test]
14253    fn no_wysiwyg_motion_is_a_dead_end() {
14254        // Down held to the bottom of a document reaches the bottom, and Up held
14255        // to the top reaches the top — from anywhere, at a width that wraps. The
14256        // invariant above says a motion lands somewhere legal; this one says it
14257        // gets somewhere at all, which is what a caret pinned at a wrap boundary
14258        // was quietly failing to do while every assertion around it held.
14259        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
14260                   - item one two three four five\n\nlast\n";
14261        for width in [80, 12] {
14262            let mut d = wysiwyg_doc("no_dead_end", src);
14263            d.build_visual(width);
14264            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14265            let (first, last) = (stops[0], stops[stops.len() - 1]);
14266            for &start in &stops {
14267                for (name, motion, want) in [
14268                    (
14269                        "down",
14270                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
14271                        last,
14272                    ),
14273                    ("up", |d: &mut Doc| d.move_up(false), first),
14274                ] {
14275                    d.caret = start;
14276                    d.anchor = None;
14277                    d.goal_col = None;
14278                    // Every row, plus the presses the edges take, plus slack.
14279                    for _ in 0..d.vmap.num_rows() + 4 {
14280                        motion(&mut d);
14281                    }
14282                    assert_eq!(
14283                        d.caret, want,
14284                        "{name} held from {start} at width {width} never arrived"
14285                    );
14286                }
14287            }
14288        }
14289    }
14290    // ── display columns ──────────────────────────────────────────────────────
14291    // A `col` is a terminal cell, not a character. The two are the same number
14292    // for the ASCII the fixtures above are written in, which is how they came
14293    // apart in the first place: `你` is one character drawn in two cells, so a
14294    // column counted in characters names a cell the text isn't in — one earlier
14295    // for every wide character to its left.
14296
14297    #[test]
14298    fn a_wide_character_is_two_columns_wide() {
14299        // The reproduction: `你` is one char and two cells, so the caret just
14300        // past it drew at column 1 — inside the character it had already left.
14301        for (view, tag) in VIEWS {
14302            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
14303            d.caret = "你".len();
14304            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
14305            d.caret = "你好".len();
14306            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
14307        }
14308    }
14309
14310    #[test]
14311    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
14312        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
14313        // two-cell — measuring six cells one at a time, but the character they
14314        // spell is drawn in two. Width belongs to the cluster, not the glyph,
14315        // and the frontends measure it the same way.
14316        let family = "👨‍👩‍👧";
14317        for (view, tag) in VIEWS {
14318            let src = format!("a{family}b\n");
14319            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
14320            d.caret = 1 + family.len();
14321            assert_eq!(
14322                d.caret_pos(),
14323                (0, 3),
14324                "{tag}: 'a' is one cell, the family two"
14325            );
14326        }
14327    }
14328
14329    #[test]
14330    fn both_cells_of_a_wide_character_mean_the_character() {
14331        // Clicking the far half of `好` is still clicking `好`: half a character
14332        // is not a place the caret can be, so it comes to rest at the
14333        // character's start — the column it would have been drawn at anyway.
14334        for (view, tag) in VIEWS {
14335            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
14336            for col in [2, 3] {
14337                d.caret = 0;
14338                d.click(0, col, false);
14339                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
14340                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
14341            }
14342            // Past the last cell is the line's end, as it is for ASCII.
14343            d.click(0, 9, false);
14344            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
14345        }
14346    }
14347
14348    #[test]
14349    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
14350        // The mapping is only a mapping if it inverts: the cell the caret is
14351        // drawn in has to be the cell that brings it back to the same offset.
14352        // Over a fixture where a character may be one cell or two, and one
14353        // codepoint or five.
14354        use unicode_segmentation::UnicodeSegmentation;
14355
14356        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
14357
14358        let mut d = doc_in(View::Source, "roundtrip_source", src);
14359        // Every offset the source view's caret can occupy: it steps by grapheme
14360        // cluster, so those are its boundaries.
14361        for (off, _) in src
14362            .grapheme_indices(true)
14363            .chain(std::iter::once((src.len(), "")))
14364        {
14365            d.caret = off;
14366            let (row, col) = d.caret_pos();
14367            d.click(row, col, false);
14368            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
14369        }
14370
14371        // And in WYSIWYG, where the offsets the caret can occupy are the map's
14372        // stops rather than every boundary.
14373        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
14374        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14375        assert!(stops.len() > 20, "fixture should have plenty of stops");
14376        for off in stops {
14377            d.caret = off;
14378            let (row, col) = d.caret_pos();
14379            d.click(row, col, false);
14380            assert_eq!(
14381                d.caret, off,
14382                "wysiwyg: {off} → ({row}, {col}) → {}",
14383                d.caret
14384            );
14385        }
14386    }
14387
14388    #[test]
14389    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
14390        // Down from under `世` lands under the glyph in that cell, not two
14391        // characters further along the line. The goal is a column, so a line of
14392        // wide characters and a line of ASCII line up the way they're drawn.
14393        //
14394        // The gap differs by view: a bare newline inside a paragraph is a soft
14395        // break, which WYSIWYG draws as a space on a single row. The views share
14396        // a grid only where the source's lines are the renderer's rows too.
14397        for (view, tag) in VIEWS {
14398            let gap = if view == View::Source { "\n" } else { "\n\n" };
14399            let src = format!("你好世{gap}abcdef\n");
14400            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
14401            d.caret = "你好".len();
14402            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
14403            d.move_down(false);
14404            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
14405            assert!(
14406                d.source[d.caret..].starts_with('e'),
14407                "{tag}: landed on the wrong glyph"
14408            );
14409        }
14410    }
14411
14412    #[test]
14413    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
14414        // Down from column 3 onto `你好`, whose characters start at columns 0
14415        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
14416        // between the cells of one character, so the caret rests on it — and on
14417        // its start, which is the only offset there that is a caret stop.
14418        for (view, tag) in VIEWS {
14419            let gap = if view == View::Source { "\n" } else { "\n\n" };
14420            let src = format!("abcdef{gap}你好\n");
14421            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
14422            let line = src.find('你').unwrap();
14423            d.caret = 3;
14424            d.move_down(false);
14425            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
14426            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
14427        }
14428    }
14429
14430    #[test]
14431    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
14432        // The column the cell's text is laid out in is measured in cells, so the
14433        // caret walking that text has to be too — the two agreeing is the whole
14434        // point of the grid staying square.
14435        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
14436        let at = d.source.find("你").unwrap();
14437        d.caret = at;
14438        let (row, col) = d.caret_pos();
14439        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
14440        // cells further along.
14441        assert_eq!(col, 2, "the cell's first character");
14442        d.move_right(false);
14443        assert_eq!(
14444            d.caret_pos(),
14445            (row, 4),
14446            "`好` is drawn past `你`'s two cells"
14447        );
14448        assert_eq!(d.caret, at + "你".len());
14449    }
14450
14451    // ── active inline marks ───────────────────────────────────────────────────
14452
14453    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
14454    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
14455        let (src, caret) = parse_caret(marked);
14456        let mut d = doc_in(view, name, &src);
14457        d.caret = caret;
14458        d.active_inline_marks().iter().collect()
14459    }
14460
14461    /// The marks over the selection `[start, end)`.
14462    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
14463        let mut d = doc_in(view, name, src);
14464        d.anchor = Some(start);
14465        d.caret = end;
14466        d.active_inline_marks().iter().collect()
14467    }
14468
14469    #[test]
14470    fn a_caret_in_a_mark_reports_it() {
14471        for (view, tag) in VIEWS {
14472            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
14473            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
14474            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
14475            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
14476            // Plain text under no mark lights nothing — the toolbar's resting state.
14477            assert_eq!(m("a| **bold** b"), [], "{tag}");
14478            assert!(m("plain t|ext").is_empty(), "{tag}");
14479        }
14480    }
14481
14482    #[test]
14483    fn nested_marks_all_report() {
14484        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
14485        // the ancestor chain is a chain, and every mark on it is in force.
14486        for (view, tag) in VIEWS {
14487            assert_eq!(
14488                marks(
14489                    view,
14490                    &format!("marks_nested_{tag}"),
14491                    "**bold and *bo|th*** end"
14492                ),
14493                [InlineKind::Strong, InlineKind::Emph],
14494                "{tag}"
14495            );
14496        }
14497    }
14498
14499    #[test]
14500    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
14501        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
14502        // the first byte of its text and the byte after its last — both inside
14503        // the mark's span, both places typing lands inside the bold. The offset
14504        // past the closing delimiter is the next text, and reports nothing.
14505        let src = "a **bold** b";
14506        let inner_start = src.find("bold").unwrap(); // 4
14507        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
14508        for (view, tag) in VIEWS {
14509            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
14510            for off in [2, 3, inner_start, inner_end, 9] {
14511                d.caret = off;
14512                assert!(
14513                    d.active_inline_marks().contains(InlineKind::Strong),
14514                    "{tag}: offset {off} is inside the strong span"
14515                );
14516            }
14517            for off in [0, 1, 10, 11, 12] {
14518                d.caret = off;
14519                assert!(
14520                    !d.active_inline_marks().contains(InlineKind::Strong),
14521                    "{tag}: offset {off} is outside the strong run"
14522                );
14523            }
14524        }
14525    }
14526
14527    #[test]
14528    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
14529        // Regression: twig resolves an offset that is one node's end and the
14530        // next one's start to the node that *starts* there, so `**bold**|\n`
14531        // isn't bold. With nothing following there's no tie to break and the
14532        // chain still ended at the mark, which made a trailing `\n` — not the
14533        // text — decide whether the caret after a bold word reported bold. It's
14534        // the offset past the mark either way, and typing there is plain either
14535        // way. A blank document typed into is exactly this shape.
14536        for (view, tag) in VIEWS {
14537            let m = |name: String, marked| marks(view, &name, marked);
14538            assert_eq!(
14539                m(format!("marks_eob_{tag}"), "**bold**|"),
14540                [],
14541                "{tag}: no trailing newline"
14542            );
14543            assert_eq!(
14544                m(format!("marks_eol_{tag}"), "**bold**|\n"),
14545                [],
14546                "{tag}: with one"
14547            );
14548            // And the last offset that *is* in the mark still is.
14549            assert_eq!(
14550                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
14551                [InlineKind::Strong],
14552                "{tag}"
14553            );
14554        }
14555    }
14556
14557    #[test]
14558    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
14559        let src = "a **bold** b";
14560        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
14561        for (view, tag) in VIEWS {
14562            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
14563            // The whole bold word, and a slice of it.
14564            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
14565            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
14566            // Ending exactly at the closing delimiter's start is still all-bold:
14567            // an exclusive end sits *past* the last selected character, so the
14568            // question is asked of the character, not the boundary.
14569            assert_eq!(
14570                m(b, d_ + 2),
14571                [InlineKind::Strong],
14572                "{tag}: through the close"
14573            );
14574            // Half in, half out: Bold lit here would claim a press turns it off.
14575            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
14576            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
14577        }
14578    }
14579
14580    #[test]
14581    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
14582        // Both ends are bold, but the space between them isn't — two runs are two
14583        // nodes, which is exactly what the node id catches and a kind-only
14584        // comparison would not.
14585        let src = "**one** **two**";
14586        for (view, tag) in VIEWS {
14587            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
14588            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
14589        }
14590    }
14591
14592    #[test]
14593    fn marks_read_the_document_as_it_is_edited() {
14594        // The point of asking twig every frame instead of caching: the answer has
14595        // to follow the toggle that changed it.
14596        let mut d = wysiwyg_doc("marks_live", "one two\n");
14597        d.anchor = Some(0);
14598        d.caret = 3;
14599        assert!(d.active_inline_marks().is_empty(), "plain to start");
14600        d.toggle(InlineKind::Strong);
14601        assert_eq!(d.source, "**one** two\n");
14602        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
14603        assert!(d.active_inline_marks().contains(InlineKind::Strong));
14604        d.toggle(InlineKind::Strong);
14605        assert!(d.active_inline_marks().is_empty(), "and off again");
14606    }
14607
14608    #[test]
14609    fn a_link_is_not_an_inline_mark() {
14610        // `link`/`str` are inline nodes, but nothing on the inline toolbar
14611        // toggles them — a set with a "link mark" in it would have no button.
14612        for (view, tag) in VIEWS {
14613            assert_eq!(
14614                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
14615                [],
14616                "{tag}"
14617            );
14618        }
14619    }
14620
14621    // ── blank documents ───────────────────────────────────────────────────────
14622
14623    #[test]
14624    fn a_blank_document_is_untitled_empty_and_markdown() {
14625        let mut d = Doc::blank().unwrap();
14626        assert!(d.is_untitled());
14627        assert_eq!(d.path, PathBuf::new());
14628        assert_eq!(
14629            d.file_name(),
14630            "untitled",
14631            "the header has to show something"
14632        );
14633        assert_eq!(d.format_name(), "markdown");
14634        assert_eq!(d.source, "");
14635        assert!(!d.dirty, "nothing typed yet is nothing to lose");
14636        assert_eq!(d.disk_state(), DiskState::Untitled);
14637        // And it's a document you can be in: the default view renders it.
14638        d.build_visual(80);
14639        assert_eq!(d.caret, 0);
14640    }
14641
14642    #[test]
14643    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
14644        let mut d = Doc::blank().unwrap();
14645        d.insert("hello");
14646        assert!(d.dirty);
14647        d.save();
14648        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
14649        assert!(d.dirty, "it must not come away believing it saved");
14650        assert!(d.is_untitled(), "and it still has no file");
14651    }
14652
14653    #[test]
14654    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
14655        let p = temp_path("blank_save_as");
14656        let mut d = Doc::blank().unwrap();
14657        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
14658        // be kept literal (`\#`); this test is about save-as, not escaping (which
14659        // has its own test), so it types nothing that escaping would touch.
14660        d.insert("hi");
14661        d.save_as(p.clone());
14662        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
14663        assert!(!d.is_untitled());
14664        assert!(!d.dirty);
14665        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
14666        assert_eq!(
14667            d.disk_state(),
14668            DiskState::Unchanged,
14669            "the watermark is stamped"
14670        );
14671        // And ⌘S is a plain save from here on.
14672        d.insert("!");
14673        d.save();
14674        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
14675        let _ = std::fs::remove_file(&p);
14676    }
14677
14678    // ── a file that isn't there yet ───────────────────────────────────────────
14679
14680    /// A unique path in the temp dir with the given extension, guaranteed not to
14681    /// exist — what `leaf notes.md` is handed when the file has never been made.
14682    fn missing_path(name: &str, ext: &str) -> PathBuf {
14683        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
14684        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
14685        let mut p = std::env::temp_dir();
14686        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
14687        let _ = std::fs::remove_file(&p);
14688        p
14689    }
14690
14691    #[test]
14692    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
14693        let p = missing_path("named", "md");
14694        let mut d = Doc::open_or_create(p.clone()).unwrap();
14695
14696        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
14697        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
14698        assert!(
14699            !d.is_untitled(),
14700            "it has the name the user asked for — ^S must not detour to Save As"
14701        );
14702        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
14703        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
14704        assert!(!p.exists(), "and opening it wrote nothing");
14705        // And it's a document you can be in.
14706        d.build_visual(80);
14707        assert_eq!(d.caret, 0);
14708    }
14709
14710    #[test]
14711    fn a_new_file_is_created_by_its_first_save() {
14712        let p = missing_path("first_save", "md");
14713        let mut d = Doc::open_or_create(p.clone()).unwrap();
14714        d.insert("hello\n");
14715        assert!(d.dirty);
14716        d.save();
14717
14718        assert_eq!(
14719            std::fs::read_to_string(&p).unwrap(),
14720            "hello\n",
14721            "a plain ^S wrote it — no Save As, no name to invent"
14722        );
14723        assert!(!d.dirty);
14724        assert_eq!(d.disk_state(), DiskState::Unchanged);
14725        let _ = std::fs::remove_file(&p);
14726    }
14727
14728    #[test]
14729    fn a_new_file_takes_its_format_from_the_extension() {
14730        // The one thing `blank` can't do: with no name it has to assume Markdown,
14731        // and typing djot into a Markdown parse is the wrong buffer.
14732        let dj = missing_path("format", "dj");
14733        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
14734        let md = missing_path("format", "md");
14735        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
14736    }
14737
14738    #[test]
14739    fn a_new_file_reports_itself_missing_until_it_is_saved() {
14740        // Not `Untitled` — that's the answer for a document with no path, and it
14741        // would tell a frontend there is nothing a save could collide with. Here
14742        // there is a path, and the file simply isn't at it yet.
14743        let p = missing_path("disk_state", "md");
14744        let mut d = Doc::open_or_create(p.clone()).unwrap();
14745        assert_eq!(d.disk_state(), DiskState::Missing);
14746
14747        // Somebody else creates it while the buffer is open: that's an overwrite
14748        // the frontend has to be able to prompt about, exactly as for an opened
14749        // file. Their bytes, not ours, so `Changed`.
14750        std::fs::write(&p, "theirs\n").unwrap();
14751        assert_eq!(d.disk_state(), DiskState::Changed);
14752
14753        // Saving makes the file ours and re-stamps the watermark.
14754        d.insert("ours\n");
14755        d.save();
14756        assert_eq!(d.disk_state(), DiskState::Unchanged);
14757        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
14758        let _ = std::fs::remove_file(&p);
14759    }
14760
14761    #[test]
14762    fn open_or_create_still_opens_a_file_that_is_there() {
14763        let d = doc_with("open_or_create_existing", "body\n");
14764        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
14765        assert_eq!(reopened.source, "body\n");
14766        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
14767    }
14768
14769    #[test]
14770    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
14771        // A mistyped flag or a stray argument must not become a buffer promising
14772        // to save somewhere — the same refusal `open` gives a real file.
14773        let mut p = std::env::temp_dir();
14774        p.push("leaf_test_new_bad_ext.wat");
14775        assert!(Doc::open_or_create(p).is_err());
14776        let mut none = std::env::temp_dir();
14777        none.push("leaf_test_new_no_ext");
14778        assert!(Doc::open_or_create(none).is_err());
14779    }
14780
14781    #[test]
14782    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
14783        // Opening reads nothing, so there is nothing to fail on yet; the write is
14784        // where it fails, and it says so rather than claiming a save.
14785        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
14786        let mut d = Doc::open_or_create(p).unwrap();
14787        d.insert("x");
14788        d.save();
14789        assert!(
14790            d.status.as_deref().unwrap().starts_with("save failed:"),
14791            "got {:?}",
14792            d.status
14793        );
14794        assert!(d.dirty, "it must not come away believing it saved");
14795    }
14796
14797    // ── save as ───────────────────────────────────────────────────────────────
14798
14799    /// A unique path in the temp dir that no fixture wrote — a Save As target.
14800    fn temp_path(name: &str) -> PathBuf {
14801        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
14802        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
14803        let mut p = std::env::temp_dir();
14804        p.push(format!("leaf_test_target_{name}_{seq}.md"));
14805        let _ = std::fs::remove_file(&p);
14806        p
14807    }
14808
14809    #[test]
14810    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
14811        let mut d = doc_with("save_as_move", "original\n");
14812        let old = d.path.clone();
14813        let new = temp_path("save_as_move");
14814        d.insert("edited: ");
14815        d.save_as(new.clone());
14816
14817        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
14818        assert_eq!(
14819            std::fs::read_to_string(&old).unwrap(),
14820            "original\n",
14821            "Save As doesn't touch the file it came from"
14822        );
14823        assert_eq!(d.path, new, "the document moved");
14824        assert!(!d.dirty);
14825        assert_eq!(
14826            d.status.as_deref(),
14827            Some(&*format!("saved {}", d.file_name()))
14828        );
14829
14830        // Every later save follows it, which is the whole difference from a copy.
14831        d.caret = 0;
14832        d.insert("re-");
14833        d.save();
14834        assert_eq!(
14835            std::fs::read_to_string(&new).unwrap(),
14836            "re-edited: original\n"
14837        );
14838        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
14839        let _ = std::fs::remove_file(&new);
14840    }
14841
14842    #[test]
14843    fn save_as_overwrites_an_existing_target() {
14844        // The picker already asked; asking again down here is the same question
14845        // twice, and the second one has no way to be answered.
14846        let new = temp_path("save_as_over");
14847        std::fs::write(&new, "theirs\n").unwrap();
14848        let mut d = doc_with("save_as_over", "ours\n");
14849        d.save_as(new.clone());
14850        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
14851        let _ = std::fs::remove_file(&new);
14852    }
14853
14854    #[test]
14855    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
14856        let mut d = doc_with("save_as_fail", "body\n");
14857        let old = d.path.clone();
14858        d.insert("x");
14859        // A directory that doesn't exist: the write can't land.
14860        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
14861        d.save_as(bad);
14862
14863        assert_eq!(
14864            d.path, old,
14865            "the document must not move to a file that isn't there"
14866        );
14867        assert!(d.dirty, "and must not believe it saved");
14868        assert!(
14869            d.status.as_deref().unwrap().starts_with("save failed:"),
14870            "the same failure a plain save reports, got {:?}",
14871            d.status
14872        );
14873        // The original is still the document's file, and still saveable.
14874        d.save();
14875        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
14876        assert!(!d.dirty);
14877    }
14878
14879    #[test]
14880    fn save_as_renames_without_reparsing_the_format() {
14881        // `.dj` on the name doesn't make the buffer djot: it was parsed as
14882        // Markdown and still is, and saying otherwise would be a conversion the
14883        // user never asked for (and an undo history thrown away to do it).
14884        let mut d = doc_with("save_as_format", "**b**\n");
14885        let mut new = temp_path("save_as_format");
14886        new.set_extension("dj");
14887        d.save_as(new.clone());
14888        assert_eq!(d.format_name(), "markdown");
14889        let _ = std::fs::remove_file(&new);
14890    }
14891
14892    // ── external change / reload ──────────────────────────────────────────────
14893
14894    #[test]
14895    fn an_untouched_file_reports_unchanged() {
14896        let mut d = doc_with("disk_clean", "body\n");
14897        assert_eq!(d.disk_state(), DiskState::Unchanged);
14898        // Editing the buffer is not editing the file.
14899        d.insert("x");
14900        assert_eq!(d.disk_state(), DiskState::Unchanged);
14901        assert!(d.dirty);
14902        // Saving re-stamps the watermark rather than reporting our own bytes back.
14903        d.save();
14904        assert_eq!(d.disk_state(), DiskState::Unchanged);
14905    }
14906
14907    #[test]
14908    fn a_file_written_underneath_reports_changed() {
14909        let mut d = doc_with("disk_changed", "body\n");
14910        std::fs::write(&d.path, "someone else\n").unwrap();
14911        assert_eq!(d.disk_state(), DiskState::Changed);
14912        // Dirty *and* changed is the clobber: both halves are readable, and
14913        // leaf-core takes neither side.
14914        d.insert("x");
14915        assert!(d.dirty && d.disk_state() == DiskState::Changed);
14916        // Saving anyway is allowed — the frontend asked, or chose not to.
14917        d.save();
14918        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
14919        assert_eq!(d.disk_state(), DiskState::Unchanged);
14920    }
14921
14922    #[test]
14923    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
14924        // The hash is what makes this honest: the file was written (a fresh
14925        // mtime), and nothing about the document is stale.
14926        let d = doc_with("disk_same_bytes", "body\n");
14927        std::fs::write(&d.path, "body\n").unwrap();
14928        assert_eq!(d.disk_state(), DiskState::Unchanged);
14929    }
14930
14931    #[test]
14932    fn a_deleted_file_reports_missing() {
14933        let mut d = doc_with("disk_missing", "body\n");
14934        std::fs::remove_file(&d.path).unwrap();
14935        assert_eq!(d.disk_state(), DiskState::Missing);
14936        // A save recreates it, and the document is whole again.
14937        d.save();
14938        assert_eq!(d.disk_state(), DiskState::Unchanged);
14939        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
14940    }
14941
14942    #[test]
14943    fn reload_replaces_the_document_with_the_file() {
14944        for (view, tag) in VIEWS {
14945            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
14946            d.insert("edited ");
14947            assert!(d.dirty);
14948            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
14949            d.reload();
14950
14951            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
14952            assert!(!d.dirty, "{tag}: the file is what we have");
14953            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
14954            assert_eq!(
14955                d.status.as_deref(),
14956                Some(&*format!("reloaded {}", d.file_name()))
14957            );
14958            // The reloaded tree is live, not the old parse.
14959            d.caret = d.source.find("three").unwrap();
14960            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
14961        }
14962    }
14963
14964    #[test]
14965    fn reload_clamps_the_caret_and_drops_the_selection() {
14966        let mut d = doc_with("reload_caret", "a long first line\n");
14967        d.caret = 12;
14968        d.anchor = Some(4);
14969        std::fs::write(&d.path, "short\n").unwrap();
14970        d.reload();
14971        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
14972        assert_eq!(
14973            d.anchor, None,
14974            "a selection over bytes that changed is a lie"
14975        );
14976        assert!(d.selection().is_none());
14977
14978        // A caret the file still has room for stays put.
14979        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
14980        d.caret = 2;
14981        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
14982        d.reload();
14983        assert_eq!(d.caret, 2);
14984    }
14985
14986    /// A silent reload is something that happened *to* a reader — a formatter,
14987    /// a `git checkout` — so it has to be undoable like anything else that
14988    /// changes the document, and undoable as one step rather than as however
14989    /// many the file happens to differ by.
14990    #[test]
14991    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
14992        let mut d = doc_with("reload_undo", "body\n");
14993        d.insert("x");
14994        assert_eq!(d.source, "xbody\n");
14995        std::fs::write(&d.path, "replaced\n").unwrap();
14996        d.reload();
14997        assert_eq!(d.source, "replaced\n");
14998        assert!(!d.dirty, "a reload lands clean");
14999
15000        // One ^Z takes the whole swap off, and hands back the unsaved work it
15001        // replaced — which is unsaved again, because the file no longer says it.
15002        d.undo();
15003        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
15004        assert!(d.dirty, "and what it comes back to is unsaved");
15005        // …and the history under it is still there.
15006        d.undo();
15007        assert_eq!(
15008            d.source, "body\n",
15009            "the typing before the reload undoes too"
15010        );
15011        // Redo walks back up through the reload.
15012        d.redo();
15013        d.redo();
15014        assert_eq!(d.source, "replaced\n");
15015    }
15016
15017    /// A file rewritten with the bytes it already had is not an edit, so it
15018    /// must not leave an undo step behind for something nobody did.
15019    #[test]
15020    fn reloading_identical_bytes_pushes_no_undo_step() {
15021        let mut d = doc_with("reload_same", "body\n");
15022        d.insert("x");
15023        std::fs::write(&d.path, "xbody\n").unwrap();
15024        d.reload();
15025        assert_eq!(d.source, "xbody\n");
15026        assert!(!d.dirty, "the file now says what the buffer does");
15027        d.undo();
15028        assert_eq!(
15029            d.source, "body\n",
15030            "one step back is the typing, not a no-op"
15031        );
15032    }
15033
15034    #[test]
15035    fn a_reload_that_cant_read_leaves_the_document_alone() {
15036        let mut d = doc_with("reload_gone", "body\n");
15037        d.insert("x");
15038        std::fs::remove_file(&d.path).unwrap();
15039        d.reload();
15040        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
15041        assert!(d.dirty);
15042        assert!(
15043            d.status.as_deref().unwrap().starts_with("reload failed:"),
15044            "{:?}",
15045            d.status
15046        );
15047
15048        // And an untitled document has nothing to reload from.
15049        let mut d = Doc::blank().unwrap();
15050        d.insert("typed");
15051        d.reload();
15052        assert_eq!(d.source, "typed");
15053        assert_eq!(d.status.as_deref(), Some("no file to reload"));
15054    }
15055
15056    #[test]
15057    fn a_read_only_document_refuses_every_door() {
15058        let mut d = doc_with("readonly", "one two three\n");
15059        d.insert("x");
15060        assert!(d.dirty, "writable first, so the undo step exists");
15061        d.set_read_only(true);
15062        let before = d.source.clone();
15063        d.insert("y");
15064        d.backspace();
15065        d.undo();
15066        d.redo();
15067        assert_eq!(d.source, before, "no door moved a byte");
15068        d.set_read_only(false);
15069        d.undo();
15070        assert_ne!(d.source, before, "off again, the same doors work");
15071    }
15072
15073    /// The doors that go to twig's own verbs rather than through the splice.
15074    /// Typed text in the rendered view under the default markup mode is the
15075    /// everyday one — it is what a keystroke in leaf-web or the Apple views
15076    /// becomes — and it walked straight past the gate.
15077    #[test]
15078    fn a_read_only_document_refuses_the_doors_around_the_splice() {
15079        let mut d = wysiwyg_doc(
15080            "readonly-doors",
15081            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
15082        );
15083        d.set_markup_mode(MarkupMode::None);
15084        d.set_read_only(true);
15085        let before = d.source.clone();
15086        d.place_caret(3, false);
15087        d.insert("y");
15088        d.insert_link("https://example.com");
15089        d.insert_image("a.png", "alt");
15090        d.insert_thematic_break();
15091        d.insert_footnote();
15092        d.place_caret(0, false);
15093        d.place_caret(3, true);
15094        d.toggle(InlineKind::Strong);
15095        d.toggle_heading(2);
15096        d.set_block(BlockKind::Paragraph);
15097        d.toggle_list(false);
15098        d.toggle_blockquote();
15099        d.toggle_task_item();
15100        d.newline();
15101        d.indent();
15102        d.set_code_language("rust");
15103        let in_cell = d.source.find("| c").unwrap() + 2;
15104        d.place_caret(in_cell, false);
15105        assert!(d.caret_in_table(), "the caret is in the grid");
15106        assert!(!d.cell_line_break(), "the cell break reports the refusal");
15107        assert_eq!(d.source, before, "no door moved a byte");
15108        assert!(!d.dirty, "nothing to save");
15109        d.set_read_only(false);
15110        d.place_caret(3, false);
15111        d.insert("y");
15112        assert_ne!(d.source, before, "off again, the same doors work");
15113    }
15114
15115    #[test]
15116    fn a_selection_quote_carries_its_context_on_char_boundaries() {
15117        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
15118        let start = d.source.find("exact").unwrap();
15119        d.place_caret(start, false);
15120        d.place_caret(start + "exact".len(), true);
15121        let q = d.selection_quote(3).unwrap();
15122        assert_eq!(q.exact, "exact");
15123        assert_eq!(
15124            q.prefix, "你好 ",
15125            "chars, not bytes — the multibyte pair counts as two"
15126        );
15127        assert_eq!(q.suffix, " 世界");
15128        assert_eq!(&d.source[q.start..q.end], "exact");
15129        // At the edges the context clips rather than erring.
15130        d.place_caret(0, false);
15131        d.place_caret(6, true);
15132        let q = d.selection_quote(40).unwrap();
15133        assert_eq!(q.prefix, "");
15134        assert_eq!(q.exact, "before");
15135        // No selection is no quote.
15136        d.place_caret(0, false);
15137        assert!(d.selection_quote(3).is_none());
15138    }
15139
15140    #[test]
15141    fn highlights_are_kept_sorted_and_answer_point_queries() {
15142        let mut d = doc_with("hl", "one two three\n");
15143        d.set_highlights(vec![
15144            Highlight {
15145                start: 8,
15146                end: 13,
15147                id: "b".into(),
15148                color: None,
15149                marker: None,
15150            },
15151            Highlight {
15152                start: 0,
15153                end: 3,
15154                id: "a".into(),
15155                color: Some("#ffe066".into()),
15156                marker: None,
15157            },
15158            Highlight {
15159                start: 5,
15160                end: 5,
15161                id: "empty".into(),
15162                color: None,
15163                marker: None,
15164            },
15165        ]);
15166        assert_eq!(
15167            d.highlights()
15168                .iter()
15169                .map(|h| h.id.as_str())
15170                .collect::<Vec<_>>(),
15171            ["a", "b"],
15172            "sorted by start, the empty range dropped"
15173        );
15174        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
15175        assert_eq!(d.highlight_at(3), None, "end is exclusive");
15176        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
15177        d.set_highlights(Vec::new());
15178        assert!(d.highlights().is_empty(), "a replace is a replace");
15179    }
15180
15181    /// `Highlight::covering` and the cursor over it are what both painters ask
15182    /// per glyph, so they have to answer the same as the scan they replaced —
15183    /// including in the gaps, which is where most glyphs are.
15184    #[test]
15185    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
15186        let hl = |start: usize, end: usize, id: &str| Highlight {
15187            start,
15188            end,
15189            id: id.into(),
15190            color: None,
15191            marker: None,
15192        };
15193        // Disjoint, as search hits are: in a range, in a gap, and past the end.
15194        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
15195        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
15196        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
15197        assert_eq!(
15198            Highlight::covering(&hits, 105),
15199            None,
15200            "a gap covers nothing"
15201        );
15202        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
15203        assert_eq!(Highlight::covering(&hits, 9_999), None);
15204        assert_eq!(Highlight::covering(&[], 0), None);
15205
15206        // Nested: first by start, so a hit inside an annotation still resolves
15207        // to the annotation — and the range that stops short doesn't mask it.
15208        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
15209        assert_eq!(
15210            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
15211            Some("outer")
15212        );
15213        assert_eq!(
15214            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
15215            Some("outer")
15216        );
15217    }
15218
15219    /// The cursor is an optimisation, so the only thing worth asserting is that
15220    /// it is not also a change of answer — at every offset, over a list with a
15221    /// nest in it, walked forwards and then backwards.
15222    #[test]
15223    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
15224        let hl = |start: usize, end: usize, id: &str| Highlight {
15225            start,
15226            end,
15227            id: id.into(),
15228            color: None,
15229            marker: None,
15230        };
15231        let mut list = vec![
15232            hl(0, 20, "outer"),
15233            hl(5, 10, "inner"),
15234            hl(30, 33, "hit"),
15235            hl(40, 43, "hit"),
15236        ];
15237        list.sort_by_key(|h| (h.start, h.end));
15238
15239        let mut cursor = HighlightCursor::new(&list);
15240        for offset in 0..50 {
15241            assert_eq!(
15242                cursor.at(offset).map(|h| h.id.as_str()),
15243                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15244                "cursor disagrees at {offset}"
15245            );
15246        }
15247        // Backwards: the cursor re-seats rather than answering from where it
15248        // had got to, so a painter that revisits a row is still told the truth.
15249        for offset in (0..50).rev() {
15250            assert_eq!(
15251                cursor.at(offset).map(|h| h.id.as_str()),
15252                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15253                "cursor disagrees walking back at {offset}"
15254            );
15255        }
15256    }
15257
15258    // ── the presentation vocabulary ─────────────────────────────────────────
15259
15260    /// A document in `format`, for the gesture tests that want more than the
15261    /// Markdown `doc_with` writes.
15262    fn fmt_doc(body: &str, format: Format) -> Doc {
15263        Doc::from_source(body.to_string(), format).unwrap()
15264    }
15265
15266    /// Alignment is a block property, so the gesture is `set_block_attrs` on
15267    /// the caret's block whatever is selected — and each format spells it its
15268    /// own way: djot's `{…}` line above the block, a `<div>` around it in
15269    /// Markdown (the format has nowhere else to put it), the tag in HTML.
15270    #[test]
15271    fn set_alignment_spells_the_class_the_format_s_own_way() {
15272        let mut dj = fmt_doc("hello\n", Format::Djot);
15273        dj.caret = 1;
15274        dj.set_alignment(Some(Align::Center));
15275        assert_eq!(dj.source, "{.center}\nhello\n");
15276        assert!(dj.dirty);
15277        assert_eq!(dj.status, None);
15278
15279        let mut md = fmt_doc("hello\n", Format::Markdown);
15280        md.caret = 1;
15281        md.set_alignment(Some(Align::Right));
15282        assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
15283
15284        let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
15285        html.caret = html.source.find("hello").unwrap();
15286        html.set_alignment(Some(Align::Justify));
15287        assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
15288    }
15289
15290    /// Each gesture edits **one key and keeps the rest** — twig's contract is
15291    /// replace-not-merge, so leaf reads the node's attributes, edits its own
15292    /// key out of them, and passes the list back whole. A document from
15293    /// elsewhere passes through the editor unharmed.
15294    #[test]
15295    fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
15296        let mut d = fmt_doc(
15297            "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
15298            Format::Djot,
15299        );
15300        d.caret = d.source.find("hello").unwrap();
15301        d.set_alignment(Some(Align::Right));
15302        // `center` goes, `lead` stays, and neither the id nor the spacing is
15303        // touched.
15304        // The serializer picks the order; what matters is which keys survive.
15305        assert!(d.source.contains(".lead"), "{:?}", d.source);
15306        assert!(d.source.contains(".right"), "{:?}", d.source);
15307        assert!(!d.source.contains(".center"), "{:?}", d.source);
15308        assert!(d.source.contains("#intro"), "{:?}", d.source);
15309        assert!(
15310            d.source.contains("data-line-height=\"1.5\""),
15311            "{:?}",
15312            d.source
15313        );
15314        assert_eq!(d.alignment_at_caret(), Some(Align::Right));
15315        assert_eq!(d.line_spacing_at_caret(), Some(LineSpacing::OneHalf));
15316
15317        // And the other way round: the spacing gesture leaves the classes be.
15318        d.set_line_spacing(Some(LineSpacing::Double));
15319        assert!(d.source.contains(".lead"), "{:?}", d.source);
15320        assert!(d.source.contains(".right"), "{:?}", d.source);
15321        assert_eq!(d.line_spacing_at_caret(), Some(LineSpacing::Double));
15322    }
15323
15324    /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
15325    /// owns go out of `class`, and a block left with nothing at all is spelled
15326    /// bare again — in Markdown by unwrapping the div twig wrapped it in.
15327    #[test]
15328    fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
15329        let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
15330        dj.caret = dj.source.find("hello").unwrap();
15331        dj.set_alignment(None);
15332        assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
15333        assert_eq!(dj.alignment_at_caret(), None);
15334
15335        let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
15336        bare.caret = bare.source.find("hello").unwrap();
15337        bare.set_alignment(None);
15338        assert_eq!(
15339            bare.source, "hello\n",
15340            "the last key takes the line with it"
15341        );
15342
15343        let mut md = fmt_doc("hello\n", Format::Markdown);
15344        md.caret = 1;
15345        md.set_alignment(Some(Align::Center));
15346        assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
15347        md.caret = md.source.find("hello").unwrap();
15348        md.set_line_spacing(Some(LineSpacing::OneFifteen));
15349        assert_eq!(
15350            md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
15351            "the second key rewrites the div rather than nesting a second"
15352        );
15353        md.caret = md.source.find("hello").unwrap();
15354        md.set_alignment(None);
15355        md.caret = md.source.find("hello").unwrap();
15356        md.set_line_spacing(None);
15357        assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
15358    }
15359
15360    /// Size, face and colour are the run's over a selection and the block's
15361    /// with none — so "make this paragraph larger" is a click with the caret in
15362    /// it rather than a select-all first.
15363    #[test]
15364    fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
15365        // With a selection: a span, in each format's own spelling.
15366        let mut dj = fmt_doc("a big b\n", Format::Djot);
15367        dj.anchor = Some(2);
15368        dj.caret = 5;
15369        dj.set_font_size(Some(SizeStep::Large));
15370        assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
15371        assert_eq!(dj.font_size_at_caret(), Some(SizeStep::Large));
15372
15373        let mut md = fmt_doc("a big b\n", Format::Markdown);
15374        md.anchor = Some(2);
15375        md.caret = 5;
15376        md.set_text_color(Some(MarkColor::Blue));
15377        assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
15378        assert_eq!(md.text_color_at_caret(), Some(MarkColor::Blue));
15379
15380        // Without one: the caret's block, through the block gesture.
15381        let mut block = fmt_doc("a big b\n", Format::Djot);
15382        block.caret = 3;
15383        block.set_font_family(Some(FontFamily::Monospace));
15384        assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
15385        assert_eq!(block.font_family_at_caret(), Some(FontFamily::Monospace));
15386    }
15387
15388    /// twig re-styles the span a range already lies in rather than nesting a
15389    /// second, and an empty set unwraps it — so a second press of the menu
15390    /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
15391    /// means "the theme's own" takes the span away.
15392    #[test]
15393    fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
15394        let mut d = fmt_doc("a big b\n", Format::Djot);
15395        d.anchor = Some(2);
15396        d.caret = 5;
15397        d.set_font_size(Some(SizeStep::Large));
15398        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
15399
15400        // The selection `wrap_range_attrs` left behind covers the whole span;
15401        // colouring it now keeps the size, because the gesture reads the span's
15402        // attributes before it edits its own key.
15403        d.set_text_color(Some(MarkColor::Red));
15404        assert_eq!(
15405            d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
15406            "one span, both keys"
15407        );
15408        assert_eq!(d.font_size_at_caret(), Some(SizeStep::Large));
15409        assert_eq!(d.text_color_at_caret(), Some(MarkColor::Red));
15410
15411        d.set_text_color(None);
15412        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
15413        d.set_font_size(None);
15414        assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
15415        assert_eq!(d.font_size_at_caret(), None);
15416    }
15417
15418    /// The queries read the nearest node that names the property: the span the
15419    /// caret is in, then its block, then the `div`s around it.
15420    #[test]
15421    fn a_presentation_query_reads_the_nearest_node_that_names_it() {
15422        let mut d = fmt_doc(
15423            "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
15424            Format::Djot,
15425        );
15426        // In the span: its own size, the block's face and alignment.
15427        d.caret = d.source.find('y').unwrap();
15428        assert_eq!(d.font_size_at_caret(), Some(SizeStep::XxLarge));
15429        assert_eq!(d.font_family_at_caret(), Some(FontFamily::Serif));
15430        assert_eq!(d.alignment_at_caret(), Some(Align::Center));
15431        assert_eq!(d.line_spacing_at_caret(), None);
15432        assert_eq!(d.text_color_at_caret(), None);
15433
15434        // Outside it: the block's size.
15435        d.caret = d.source.find('x').unwrap();
15436        assert_eq!(d.font_size_at_caret(), Some(SizeStep::Small));
15437
15438        // And through a Markdown div, which is where a Markdown block's
15439        // attributes live.
15440        let mut md = fmt_doc(
15441            "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
15442            Format::Markdown,
15443        );
15444        md.caret = md.source.find("hello").unwrap();
15445        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
15446        assert_eq!(md.font_size_at_caret(), Some(SizeStep::Large));
15447
15448        // A document that names none of it answers `None` everywhere, which is
15449        // "the theme's own" and what every toolbar draws unlit.
15450        let mut plain = doc_with("plain_presentation", "hello\n");
15451        plain.caret = 1;
15452        assert_eq!(plain.alignment_at_caret(), None);
15453        assert_eq!(plain.line_spacing_at_caret(), None);
15454        assert_eq!(plain.font_size_at_caret(), None);
15455        assert_eq!(plain.font_family_at_caret(), None);
15456        assert_eq!(plain.text_color_at_caret(), None);
15457    }
15458
15459    /// A djot fenced div is anonymous the way an attributed span is, and is a
15460    /// block all the same — the *form* is the whole of what tells them apart.
15461    /// Read as a span it poisoned both halves: the run gesture copied the div's
15462    /// entire attribute set onto the span it minted, duplicating the `id`, and
15463    /// the run and block queries answered off a node the walker draws nothing
15464    /// for.
15465    #[test]
15466    fn a_djot_fenced_div_is_not_an_attributed_span() {
15467        let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
15468        let mut d = fmt_doc(src, Format::Djot);
15469        let at = d.source.find("world").unwrap();
15470        d.anchor = Some(at);
15471        d.caret = at + "world".len();
15472        d.set_text_color(Some(MarkColor::Red));
15473        assert_eq!(
15474            d.source,
15475            "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
15476            "the span carries its own key and nothing of the div's"
15477        );
15478
15479        // And the queries stop at the block: a djot div is not a `<div>`, the
15480        // walker lends its keys to nothing inside it, and a query that said
15481        // otherwise would tick a menu entry no glyph on screen obeys.
15482        assert_eq!(d.text_color_at_caret(), Some(MarkColor::Red));
15483        assert_eq!(d.font_size_at_caret(), None);
15484        assert_eq!(d.alignment_at_caret(), None);
15485    }
15486
15487    /// Clearing a property the block does not name and a `div` around it does
15488    /// would write nothing and change nothing — twig's `set_block_attrs`
15489    /// reaches one node, and the div is not it. The gesture says so instead of
15490    /// leaving the author pressing an entry that never ticks.
15491    #[test]
15492    fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
15493        // Markdown, two paragraphs in one div: not the sole-child shape twig
15494        // writes, so `block_attrs_at_caret` reads the paragraph and the
15495        // paragraph names none of it.
15496        let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
15497        let mut md = fmt_doc(src, Format::Markdown);
15498        md.caret = md.source.find("hello").unwrap();
15499        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
15500
15501        md.set_alignment(None);
15502        assert_eq!(md.source, src, "nothing written");
15503        assert!(!md.dirty);
15504        assert_eq!(
15505            md.status.as_deref(),
15506            Some("alignment: set on the div around the block")
15507        );
15508        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
15509
15510        // The same for a `data-` key, at both levels — the block pair and the
15511        // run three, the run three at a bare caret being the block gesture.
15512        md.set_line_spacing(None);
15513        assert_eq!(md.source, src);
15514        assert_eq!(
15515            md.status.as_deref(),
15516            Some("line spacing: set on the div around the block")
15517        );
15518        md.set_font_size(None);
15519        assert_eq!(md.source, src);
15520        assert_eq!(
15521            md.status.as_deref(),
15522            Some("size: set on the div around the block")
15523        );
15524
15525        // HTML has no sole-child fold at all: a block's attributes go on the
15526        // block, so the div around one is always out of reach.
15527        let html_src = "<div class=\"center\"><p>hi</p></div>\n";
15528        let mut html = fmt_doc(html_src, Format::Html);
15529        html.caret = html.source.find("hi").unwrap();
15530        assert_eq!(html.alignment_at_caret(), Some(Align::Center));
15531        html.set_alignment(None);
15532        assert_eq!(html.source, html_src);
15533        assert!(!html.dirty);
15534        assert_eq!(
15535            html.status.as_deref(),
15536            Some("alignment: set on the div around the block")
15537        );
15538
15539        // And it is a refusal, not a rule against clearing: a block that names
15540        // the property itself still loses it, div or no div.
15541        let mut own = fmt_doc(
15542            "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
15543            Format::Html,
15544        );
15545        own.caret = own.source.find("hi").unwrap();
15546        own.set_alignment(None);
15547        assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
15548        assert_eq!(own.status, None);
15549    }
15550
15551    /// An edited key is rewritten **where it stands**. The proposal's worked
15552    /// example is the test: a paragraph that came in as `id="intro"
15553    /// class="lead center" data-line-height="1.5"` and is right-aligned goes
15554    /// out as the same list with one token changed. Removing the key and
15555    /// pushing it back shuffled a document's attributes on every press.
15556    #[test]
15557    fn an_edited_key_keeps_its_place_among_the_attributes() {
15558        let mut html = fmt_doc(
15559            "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
15560            Format::Html,
15561        );
15562        html.caret = html.source.find("hello").unwrap();
15563        html.set_alignment(Some(Align::Right));
15564        assert_eq!(
15565            html.source,
15566            "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
15567        );
15568
15569        // A `data-` key the same way, and a key the block did not have still
15570        // goes on the end.
15571        html.caret = html.source.find("hello").unwrap();
15572        html.set_line_spacing(Some(LineSpacing::Double));
15573        assert_eq!(
15574            html.source,
15575            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
15576        );
15577        html.caret = html.source.find("hello").unwrap();
15578        html.set_font_size(Some(SizeStep::Large));
15579        assert_eq!(
15580            html.source,
15581            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
15582        );
15583
15584        // Djot writes the same list in its own spelling, and the order is the
15585        // author's there too.
15586        let mut dj = fmt_doc(
15587            "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
15588            Format::Djot,
15589        );
15590        dj.caret = dj.source.find("hello").unwrap();
15591        dj.set_alignment(Some(Align::Right));
15592        assert_eq!(
15593            dj.source,
15594            "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
15595        );
15596    }
15597
15598    /// A page break is a block, so twig alone lands one after the caret's whole
15599    /// block; the paragraph is parted at the caret first, exactly as
15600    /// `insert_thematic_break` parts it, and each format spells the directive
15601    /// its own way.
15602    #[test]
15603    fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
15604        let mut md = doc_with("page_break_md", "hello world\n");
15605        md.caret = 5;
15606        md.insert_page_break();
15607        assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
15608        assert!(md.dirty);
15609        assert_eq!(md.status, None);
15610
15611        let mut dj = fmt_doc("hello world\n", Format::Djot);
15612        dj.caret = 5;
15613        dj.insert_page_break();
15614        assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
15615
15616        // At a block's end there is no second half to mint, so the break simply
15617        // follows the block — the rule the rule button already has.
15618        let mut end = doc_with("page_break_end", "hello\n");
15619        end.caret = 5;
15620        end.insert_page_break();
15621        assert_eq!(end.source, "hello\n\n::page-break\n");
15622
15623        // And it reaches the map as the placeholder row a frontend paginates on.
15624        end.view = View::Wysiwyg;
15625        end.build_visual(80);
15626        assert_eq!(
15627            end.vmap
15628                .rows
15629                .iter()
15630                .find_map(|r| r.leaf_directive.as_ref())
15631                .map(|m| m.name.as_str()),
15632            Some(PAGE_BREAK)
15633        );
15634    }
15635
15636    /// The vocabulary's capabilities, per format. The two block properties are
15637    /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
15638    /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
15639    /// keeps an id and a role and has no slot for a `data-` key.
15640    #[test]
15641    fn the_presentation_capabilities_are_ragged_per_format() {
15642        for fmt in [Format::Markdown, Format::Djot, Format::Html] {
15643            let c = Capabilities::of(fmt);
15644            assert!(c.alignment, "{fmt:?} alignment");
15645            assert!(c.line_spacing, "{fmt:?} line spacing");
15646            assert!(c.font_size, "{fmt:?} size");
15647            assert!(c.font_family, "{fmt:?} face");
15648            assert!(c.text_color, "{fmt:?} colour");
15649        }
15650        // Markdown spells both only under the extensions leaf parses with — a
15651        // `<div>` and a `<span>` read back as containers under `html_elements`,
15652        // and `::page-break` as a directive under `directives`. Ask twig's own
15653        // defaults and the answer is no, which is why `Capabilities` is built
15654        // with `supports_with`.
15655        assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
15656        assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
15657        assert!(!Format::Markdown.supports(Gesture::InsertDirective));
15658
15659        let adoc = Capabilities::of(Format::Asciidoc);
15660        assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
15661        assert!(
15662            !adoc.font_size && !adoc.font_family && !adoc.text_color,
15663            "AsciiDoc has no inline spelling that keeps a data- key"
15664        );
15665
15666        // XML spells none of it, and neither page break.
15667        let xml = Capabilities::of(Format::Xml);
15668        assert!(!xml.alignment && !xml.font_size && !xml.page_break);
15669        assert!(Capabilities::of(Format::Markdown).page_break);
15670        assert!(Capabilities::of(Format::Djot).page_break);
15671
15672        // And those two *only*, though twig spells the gesture in HTML and
15673        // AsciiDoc as well: it spells it differently there —
15674        // `<page-break></page-break>` and `<<<` — and the walker reads neither,
15675        // so the button would write a break that draws as nothing at all in
15676        // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
15677        // what leaf can show, not what twig can write. See
15678        // `docs/tasks/page-break-in-html-and-asciidoc.md`.
15679        let exts = parse_extensions();
15680        assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
15681        assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
15682        assert!(!Capabilities::of(Format::Html).page_break);
15683        assert!(!Capabilities::of(Format::Asciidoc).page_break);
15684    }
15685
15686    /// A format that cannot spell a property refuses in its own words and
15687    /// writes nothing — the guard every other gesture has.
15688    #[test]
15689    fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
15690        let src = "<doc><p>hello</p></doc>\n";
15691        #[allow(clippy::type_complexity)]
15692        let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
15693            ("alignment", &|d: &mut Doc| {
15694                d.set_alignment(Some(Align::Center))
15695            }),
15696            ("line spacing", &|d: &mut Doc| {
15697                d.set_line_spacing(Some(LineSpacing::Double))
15698            }),
15699            ("size", &|d: &mut Doc| {
15700                d.set_font_size(Some(SizeStep::Large))
15701            }),
15702            ("face", &|d: &mut Doc| {
15703                d.set_font_family(Some(FontFamily::Serif))
15704            }),
15705            ("colour", &|d: &mut Doc| {
15706                d.set_text_color(Some(MarkColor::Red))
15707            }),
15708            ("page break", &|d: &mut Doc| d.insert_page_break()),
15709        ];
15710        for (name, op) in ops {
15711            let mut d = fmt_doc(src, Format::Xml);
15712            let at = d.source.find("hello").unwrap();
15713            d.caret = at;
15714            d.anchor = Some(at + 5);
15715            op(&mut d);
15716            assert_eq!(d.source, src, "{name} edited an XML document");
15717            assert!(!d.dirty, "{name} marked the document dirty");
15718            let status = d.status.as_deref().unwrap_or("");
15719            assert!(
15720                status.contains("xml"),
15721                "{name}: the refusal should name the format, got {status:?}"
15722            );
15723        }
15724
15725        // AsciiDoc is the ragged one: the block gesture works where the run
15726        // gesture does not, and a *selection* is what tells the two apart.
15727        let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
15728        adoc.anchor = Some(0);
15729        adoc.caret = 5;
15730        adoc.set_font_size(Some(SizeStep::Large));
15731        assert_eq!(adoc.source, "hello world\n", "no inline spelling");
15732        assert!(adoc.status.is_some());
15733    }
15734
15735    /// A read-only document takes none of it, and a caret on a blank line has
15736    /// no block to carry an attribute — both say so rather than writing.
15737    #[test]
15738    fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
15739        let mut ro = fmt_doc("hello\n", Format::Djot);
15740        ro.read_only = true;
15741        ro.caret = 1;
15742        ro.set_alignment(Some(Align::Center));
15743        assert_eq!(ro.source, "hello\n");
15744
15745        let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
15746        blank.caret = 2; // the empty line between the two paragraphs
15747        blank.set_alignment(Some(Align::Center));
15748        assert_eq!(blank.source, "a\n\n\nb\n");
15749        assert!(
15750            blank.status.as_deref().unwrap_or("").contains("no block"),
15751            "got {:?}",
15752            blank.status
15753        );
15754    }
15755
15756    /// A block attribute gesture keeps the caret on the **text** it was on, not
15757    /// on the byte offset it had. Markdown has nowhere to put a paragraph's
15758    /// attributes but a `<div>` around it, and twig splices the div and the
15759    /// block it wraps as one region — so a caret that kept its offset landed in
15760    /// the markup, and every press after the first answered "no block at the
15761    /// caret" with the toolbar's queries reading nothing.
15762    #[test]
15763    fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
15764        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
15765        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
15766        md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
15767
15768        // Wrapping: the div and two blank lines open above the block.
15769        md.set_alignment(Some(Align::Center));
15770        assert_eq!(
15771            md.source,
15772            "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
15773        );
15774        assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
15775        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
15776
15777        // Re-styling: the attribute line changes length under the same caret,
15778        // and the second press reaches the same block rather than nothing.
15779        md.set_alignment(Some(Align::Right));
15780        assert_eq!(
15781            md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
15782            "a second press re-styles the div"
15783        );
15784        assert_eq!(md.status, None);
15785        assert_eq!(word(&md), 2);
15786
15787        // A second key on the same div — the line grows, the caret rides it.
15788        md.set_line_spacing(Some(LineSpacing::Double));
15789        assert_eq!(
15790            md.source,
15791            "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
15792        );
15793        assert_eq!(word(&md), 2);
15794        assert_eq!(md.line_spacing_at_caret(), Some(LineSpacing::Double));
15795
15796        // Unwrapping: the line shrinks, and then the div goes altogether.
15797        md.set_alignment(None);
15798        assert_eq!(
15799            md.source,
15800            "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
15801        );
15802        assert_eq!(word(&md), 2);
15803        md.set_line_spacing(None);
15804        assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
15805        assert_eq!(word(&md), 2, "the caret came back down with the block");
15806        assert_eq!(md.alignment_at_caret(), None);
15807        assert_eq!(md.status, None);
15808    }
15809
15810    /// The same rule in djot, where the spelling is a `{…}` line *above* the
15811    /// block rather than a wrapper around it: inserting it pushes the block
15812    /// down, re-styling it changes the line's length, and clearing the last key
15813    /// takes the line away again. The caret rides all three.
15814    #[test]
15815    fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
15816        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
15817        let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
15818        dj.caret = dj.source.find("brown").unwrap() + 2;
15819
15820        dj.set_alignment(Some(Align::Center));
15821        assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
15822        assert_eq!(word(&dj), 2);
15823        assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
15824
15825        dj.set_line_spacing(Some(LineSpacing::Double));
15826        assert_eq!(
15827            dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
15828            "a second press edits the line the first wrote"
15829        );
15830        assert_eq!(word(&dj), 2);
15831
15832        dj.set_alignment(None);
15833        assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
15834        assert_eq!(word(&dj), 2);
15835
15836        dj.set_line_spacing(None);
15837        assert_eq!(dj.source, "the quick brown fox\n");
15838        assert_eq!(word(&dj), 2);
15839        assert_eq!(dj.status, None);
15840    }
15841
15842    /// The run gestures with no selection are the block gesture, so they keep
15843    /// the caret the same way — and a heading keeps it inside the heading's own
15844    /// text, past the `# ` its content span starts after. A selection rides
15845    /// along whole: a block gesture is not a run gesture, and what was selected
15846    /// before the press is still selected after it.
15847    #[test]
15848    fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
15849        // No selection: the run gesture goes through the block door.
15850        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
15851        md.caret = md.source.find("brown").unwrap() + 2;
15852        md.set_font_size(Some(SizeStep::Large));
15853        assert_eq!(
15854            md.source,
15855            "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
15856        );
15857        assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
15858        assert_eq!(md.font_size_at_caret(), Some(SizeStep::Large));
15859        md.set_font_size(Some(SizeStep::Small));
15860        assert_eq!(
15861            md.font_size_at_caret(),
15862            Some(SizeStep::Small),
15863            "the second press reached the same block"
15864        );
15865
15866        // A selection: alignment is the block's whatever is selected, and the
15867        // words stay selected.
15868        let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
15869        let at = sel.source.find("brown").unwrap();
15870        sel.anchor = Some(at);
15871        sel.caret = at + 5;
15872        sel.set_alignment(Some(Align::Center));
15873        let now = sel.source.find("brown").unwrap();
15874        assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
15875
15876        // A heading: the content span starts past the `# `.
15877        let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
15878        h.caret = h.source.find("there").unwrap() + 1;
15879        h.set_alignment(Some(Align::Right));
15880        assert_eq!(
15881            h.source,
15882            "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
15883        );
15884        assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
15885        assert_eq!(h.alignment_at_caret(), Some(Align::Right));
15886    }
15887}