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, FontFace, FontSize, LineHeight, MarkColor, TextColor};
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        let runs = self.run_span_ids();
2053        self.editor
2054            .ancestors_at(off)
2055            .unwrap_or_default()
2056            .into_iter()
2057            .filter(|m| hides_delims(m, &runs))
2058            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2059            .map(|m| m.span.end)
2060            .max()
2061            .unwrap_or(off)
2062    }
2063
2064    /// The offset a *delete* aimed at the character before `off` should stop at,
2065    /// when `off` is the start of a run's text and the bytes behind it are that
2066    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2067    /// byte behind the caret at the start of a bold word is not a character the
2068    /// writer can see, let alone one they aimed Backspace at: taking it leaves
2069    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2070    /// delete steps over the whole delimiter to the visible character in front of
2071    /// it instead. Walks out to the *outermost* mark opening there, so
2072    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2073    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2074        let off = off.min(self.source.len());
2075        let runs = self.run_span_ids();
2076        self.editor
2077            .ancestors_at(off)
2078            .unwrap_or_default()
2079            .into_iter()
2080            .filter(|m| hides_delims(m, &runs))
2081            .filter(|m| {
2082                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2083            })
2084            .map(|m| m.span.start)
2085            .min()
2086            .unwrap_or(off)
2087    }
2088
2089    /// `off` moved *inside* the run whose closing delimiters end there — the
2090    /// other offset the rich view draws in the same place, since a `**` renders
2091    /// no glyph of its own. `**bold**` has a caret home on each side of its
2092    /// closing delimiter, one column apart on screen and eight bytes and a whole
2093    /// run apart in the file, and a plain ← lands on the outer one whenever a
2094    /// space follows the phrase. The inner one is what the writer is pointing at
2095    /// there: the end of their bold word. Walks in through every mark closing at
2096    /// that point, innermost last, so `***both***` lands inside both. A no-op
2097    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2098    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2099        let mut off = off.min(self.source.len());
2100        let runs = self.run_span_ids();
2101        loop {
2102            let inner = self
2103                .editor
2104                .ancestors_at(prev_boundary(&self.source, off))
2105                .unwrap_or_default()
2106                .into_iter()
2107                .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2108                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2109                .filter(|&end| end < off)
2110                .max();
2111            match inner {
2112                Some(end) => off = end,
2113                None => return off,
2114            }
2115        }
2116    }
2117
2118    /// The mirror at the opening edge: `off` moved inside the run whose
2119    /// delimiters *start* there, onto the first character of its text. See
2120    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2121    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2122        let mut off = off.min(self.source.len());
2123        let runs = self.run_span_ids();
2124        loop {
2125            let inner = self
2126                .editor
2127                .ancestors_at(off)
2128                .unwrap_or_default()
2129                .into_iter()
2130                .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2131                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2132                .filter(|&start| start > off)
2133                .min();
2134            match inner {
2135                Some(start) => off = start,
2136                None => return off,
2137            }
2138        }
2139    }
2140
2141    /// The ids of the document's attributed run spans — the inline
2142    /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2143    /// which sees an ancestor chain and so only a kind. Read once per gesture,
2144    /// not once per step of a walk.
2145    fn run_span_ids(&mut self) -> Vec<NodeId> {
2146        self.nodes()
2147            .iter()
2148            .filter(|n| wysiwyg::is_run_span(n))
2149            .map(|n| n.id)
2150            .collect()
2151    }
2152
2153    /// The attributed span whose text is exactly `content` — the whole of
2154    /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2155    /// and sits between the tags of `<span …></span>` — as the whole range
2156    /// spelling the span: the node's span, widened to its attribute block
2157    /// where the format writes that outside the node, as djot's
2158    /// `[i]{data-size="large"}` does. `None` for any other range, including
2159    /// part of a span's text.
2160    ///
2161    /// An empty span has an interior of no bytes, or no known interior at
2162    /// all: twig gives Markdown's `<span …></span>` the first and djot's
2163    /// `[]{…}` the second, and the chain already says the offset is inside.
2164    fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2165        let runs = self.run_span_ids();
2166        let m = self
2167            .editor
2168            .ancestors_at(content.start)
2169            .unwrap_or_default()
2170            .into_iter()
2171            .filter(|m| runs.contains(&NodeId(m.node_id)))
2172            .find(|m| match &m.content_span {
2173                Some(c) => *c == content,
2174                None => content.is_empty(),
2175            })?;
2176        let mut range = m.span;
2177        if let Some(attrs) = self
2178            .editor
2179            .document()
2180            .ok()
2181            .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2182        {
2183            range.start = range.start.min(attrs.start);
2184            range.end = range.end.max(attrs.end);
2185        }
2186        Some(range)
2187    }
2188
2189    /// The attributed block whose whole text is exactly `content` — the `T`
2190    /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2191    /// `{.center}\nT` — as the range a delete that takes that text takes with
2192    /// it: the whole `<div>` when the block is all the div holds, or the
2193    /// `{…}` line down to the end of the text. The block version of
2194    /// [`run_span_of_content`](Self::run_span_of_content), for the same
2195    /// reason: a paragraph with no text is no block, so the div would stand
2196    /// around nothing and the `{…}` line above nothing, and a from-scratch
2197    /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2198    /// `None` for a block with more text, a div holding more, a heading (an
2199    /// empty `# ` is still a heading), and a format whose attributes are the
2200    /// block's own tag (HTML's `<p class="center"></p>` is still a
2201    /// paragraph).
2202    fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2203        if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2204            return None;
2205        }
2206        let nodes = self.nodes();
2207        let block = nodes
2208            .iter()
2209            .filter(|n| n.kind == Kind::Para)
2210            .find(|n| n.content_span.as_ref() == Some(&content))?;
2211        match self.format {
2212            Format::Djot => {
2213                let attrs = self
2214                    .editor
2215                    .document()
2216                    .ok()
2217                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2218                (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2219            }
2220            _ => {
2221                let div = block
2222                    .parent
2223                    .and_then(|p| nodes.iter().find(|n| n.id == p))
2224                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2225                let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2226                alone.then(|| div.span.clone())
2227            }
2228        }
2229    }
2230
2231    /// The inline mark kinds whose span covers `off`, each with that span — the
2232    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2233    /// ids instead. Used to shed a mark by stepping past the end of its run.
2234    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2235        let off = off.min(self.source.len());
2236        self.editor
2237            .ancestors_at(off)
2238            .unwrap_or_default()
2239            .into_iter()
2240            .filter(|m| off < m.span.end)
2241            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2242            .collect()
2243    }
2244
2245    /// Insert clipboard `text` at the caret, replacing the selection if there is
2246    /// one — always its own undo step, whatever its length.
2247    ///
2248    /// Provenance is the whole point, and only the caller has it. `insert` reads
2249    /// a lone character as a keystroke and folds it into the run around it,
2250    /// which is right for typing and wrong for a one-character paste: that paste
2251    /// would vanish mid-run on an undo it was never part of, and the characters
2252    /// the user actually typed would go with it. Length can't tell the two
2253    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2254    /// caller comes through is what says which happened.
2255    pub fn paste(&mut self, text: &str) {
2256        // Pasting against a block picture or a table's end joins the block
2257        // exactly as typing does, and for the same reason — see
2258        // `open_paragraph_at_block_edge`.
2259        self.open_paragraph_at_block_edge(text);
2260        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2261        self.splice(s, e, text, EditKind::Other);
2262    }
2263
2264    /// Replace `[start, end)` with `text` as one step of an IME composition —
2265    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2266    /// folds into a single undo.
2267    ///
2268    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2269    /// dozen calls here, each replacing the last one's provisional bytes, and an
2270    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2271    /// unspools backwards through kana — the intermediate states were never text
2272    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2273    /// look like any other edit), so the door the caller comes through is what
2274    /// says so, exactly as it is for [`paste`](Self::paste) versus
2275    /// [`insert`](Self::insert).
2276    ///
2277    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2278    /// composition folds into this one.
2279    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2280        self.splice(start, end, text, EditKind::Compose);
2281    }
2282
2283    /// Close the open composition run, so the next one is its own undo step.
2284    /// Call when the IME commits or withdraws a composition.
2285    ///
2286    /// Only clears a *composition* run: a frontend that reports an end it never
2287    /// began (some IMEs unmark unprompted) would otherwise split the run of
2288    /// typing around it into two undo steps for no reason the user can see.
2289    pub fn end_composition(&mut self) {
2290        if self.last_edit_kind == Some(EditKind::Compose) {
2291            self.last_edit_kind = None;
2292        }
2293    }
2294
2295    // ── the clipboard's rich flavor ──────────────────────────────────────────
2296
2297    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2298    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2299    /// nothing is selected, or when the selection doesn't render (the caller
2300    /// still has [`selected_text`](Self::selected_text), which is what to publish
2301    /// as `text/plain` either way).
2302    ///
2303    /// **The fragment is a source substring, and that is the honest limit here.**
2304    /// It's parsed standalone, so a selection whose meaning depends on its
2305    /// surroundings converts as what it literally says rather than what it looks
2306    /// like on screen: half a list item is a paragraph, a row torn out of a table
2307    /// is the text of a row, the `**` of a bold run selected without its closing
2308    /// `**` is two asterisks. Every one of those still *renders* — there's no
2309    /// error to report — it just renders as the fragment and not as the document.
2310    /// Widening the range to whole blocks would publish text the user didn't
2311    /// select, which is a worse lie than a fragment being a fragment; the plain
2312    /// flavor has the same substring, so the two flavors at least agree.
2313    pub fn selection_html(&mut self) -> Option<String> {
2314        let (start, end) = self.selection()?;
2315        let inline = self.selection_is_inline(start, end);
2316        let html = html::render_fragment(&self.source[start..end], self.format)?;
2317        Some(match inline {
2318            true => html::strip_sole_paragraph(html),
2319            false => html,
2320        })
2321    }
2322
2323    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2324    /// format first. Its own undo step, like any [`paste`](Self::paste).
2325    ///
2326    /// Returns whether it landed. `false` means the HTML didn't convert to
2327    /// anything worth pasting — the caller should fall back to the plain flavor
2328    /// rather than treat it as an error. The `html` module has the full list of
2329    /// what that covers: a table twig won't build, markup it doesn't recognise,
2330    /// an empty result.
2331    pub fn paste_html(&mut self, html: &str) -> bool {
2332        match html::parse_fragment(html, self.format) {
2333            Some(source) => {
2334                self.paste(&source);
2335                true
2336            }
2337            None => false,
2338        }
2339    }
2340
2341    /// Does the selection live *inside* a single top-level block?
2342    ///
2343    /// The question [`selection_html`](Self::selection_html) needs and the
2344    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2345    /// whether the user selected one word of a sentence or a whole paragraph, and
2346    /// only the document knows which. Selecting a word and pasting into Docs
2347    /// should extend the line you paste into; selecting the paragraph should make
2348    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2349    /// is an artifact of standalone parsing), and one that covers a whole block —
2350    /// or spans two — keeps its structure.
2351    ///
2352    /// Reads the block from twig rather than guessing from the bytes:
2353    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2354    /// block containing an offset, and two ends inside the same one cannot have
2355    /// crossed a block boundary.
2356    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2357        // The last *character*, not `end - 1`: the selection's end is exclusive
2358        // and may sit mid-codepoint's-worth of bytes past the last char.
2359        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2360            return false;
2361        };
2362        let (Some(head), Some(tail)) =
2363            (self.top_block_span(start), self.top_block_span(start + off))
2364        else {
2365            return false;
2366        };
2367        head == tail && !(start <= head.start && end >= head.end)
2368    }
2369
2370    /// The byte span of the top-level block containing `offset`, or `None` at an
2371    /// offset that belongs to no block (the blank line between two of them).
2372    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2373        self.editor
2374            .ancestors_at(offset)
2375            .ok()?
2376            .get(1)
2377            .map(|m| m.span.clone())
2378    }
2379
2380    // ── indentation ──────────────────────────────────────────────────────────
2381
2382    /// One indent level.
2383    ///
2384    /// Two spaces, not the four both frontends type for Tab today, because in a
2385    /// markdown document four columns isn't a width — it's a *meaning*. Four
2386    /// spaces at the head of a line is markdown's indented-code-block marker, so
2387    /// one Tab on a paragraph would reparse it into code and style it as such;
2388    /// two cannot, and the line stays the prose it was. Two is also exactly
2389    /// where a `- ` bullet's content starts, so an indented line lands under its
2390    /// parent item's text instead of beside it — the column a list-aware indent
2391    /// has to hit anyway, which keeps this width from being relitigated later.
2392    const INDENT: &'static str = "  ";
2393
2394    /// Indent the selected lines — or the caret's line, with no selection — by
2395    /// one level (Tab).
2396    pub fn indent(&mut self) {
2397        self.reindent(true);
2398        // Nesting changes an ordered list's numbering (the nested item restarts,
2399        // its old siblings resume) — keep the source markers in step.
2400        self.renumber_here();
2401        // Nesting an empty `-` item under a text line reparses that text as a
2402        // setext heading; swap the dash for a `*` before it can (a no-op unless
2403        // the collapse actually happened).
2404        self.avoid_setext_collapse();
2405    }
2406
2407    /// Take one indent level back off the selected lines, or the caret's line
2408    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2409    ///
2410    /// A line with *less* than a full level gives back what it has rather than
2411    /// refusing: outdent's job is to walk a line left, and real documents — hand
2412    /// written, or reflowed by some other editor — are full of indentation that
2413    /// was never a clean multiple of anything. Refusing there would strand the
2414    /// line at a depth Shift+Tab couldn't undo.
2415    pub fn outdent(&mut self) {
2416        self.reindent(false);
2417        self.renumber_here();
2418    }
2419
2420    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2421    ///
2422    /// One splice across the whole line range, never one per line: a Tab is one
2423    /// thing the user did, so it has to be one undo step and one reparse. Per
2424    /// line, twig would reparse the document once per line and leave a stack of
2425    /// steps that Shift+⌘Z walks back one line at a time.
2426    fn reindent(&mut self, add: bool) {
2427        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2428        let start = source_line_range(&self.source, sel_start).start;
2429        let end = source_line_range(&self.source, sel_end).end;
2430        let region = self.source[start..end].to_string();
2431        let lines: Vec<&str> = region.split('\n').collect();
2432        // A blank line has no text to move, and padding it would leave nothing
2433        // but trailing whitespace — but Tab on a blank line *is* a request for
2434        // indentation to type into, so the skip only applies where the op has
2435        // other lines to do real work on.
2436        let skip_blank = add && lines.len() > 1;
2437
2438        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2439        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2440        let mut line_off = start;
2441        for (i, full) in lines.iter().enumerate() {
2442            if i > 0 {
2443                out.push('\n');
2444            }
2445            // A list item moves by having its whole leading prefix *replaced*,
2446            // never by having spaces pushed in front of the line. twig spells
2447            // both prefixes, so the quote markers, the parent's indent and an
2448            // ordered marker's extra column all come out right without leaf
2449            // measuring any of them — and a line that only looks like an item
2450            // (a Djot continuation) reports no marker and is left to the plain
2451            // path, where a Tab is just a Tab.
2452            let marker = self.list_marker_on_line(line_off);
2453            let own = marker
2454                .as_ref()
2455                .map(|m| m.marker_start - m.line_start)
2456                .unwrap_or(0);
2457            let delta = if add {
2458                if skip_blank && full.trim().is_empty() {
2459                    out.push_str(full);
2460                    0
2461                } else if marker.is_some() && self.first_item_of_list(line_off) {
2462                    // The first item of a list has no preceding sibling to nest
2463                    // under, so a Tab here can't spell a sub-list — twig would
2464                    // reparse the shoved-over marker as the same list, only
2465                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2466                    // item where it is, the way every list editor refuses to
2467                    // over-indent a list's first line.
2468                    out.push_str(full);
2469                    0
2470                } else if marker.is_some() {
2471                    // Nesting means standing where a *continuation* of this line
2472                    // would stand: past the parent's marker, inside its content
2473                    // column. That is `continuation_prefix`, less a checkbox.
2474                    let new = self.nesting_prefix_at(line_off);
2475                    let delta = new.len() as isize - own as isize;
2476                    out.push_str(&new);
2477                    out.push_str(&full[own..]);
2478                    delta
2479                } else {
2480                    out.push_str(Self::INDENT);
2481                    out.push_str(full);
2482                    Self::INDENT.len() as isize
2483                }
2484            } else if marker.is_some() {
2485                // Unnesting is the mirror: stand where the parent item's own
2486                // line starts, which drops exactly the level it contributed.
2487                let new = self.outdent_prefix_at(line_off);
2488                let delta = new.len() as isize - own as isize;
2489                out.push_str(&new);
2490                out.push_str(&full[own..]);
2491                delta
2492            } else {
2493                // A plain line gives back the ordinary step.
2494                let strip = outdent_width(full, Self::INDENT.len());
2495                out.push_str(&full[strip..]);
2496                -(strip as isize)
2497            };
2498            deltas.push(delta);
2499            line_off += full.len() + 1;
2500        }
2501        // Nothing to give back. Returning before the splice keeps an outdent at
2502        // column zero from spending an undo step on a document it never changed.
2503        if deltas.iter().all(|d| *d == 0) {
2504            return;
2505        }
2506
2507        // Every line's text keeps its offset *within the line*, so the caret is
2508        // remapped by its column, not by its byte offset — which the prefixes on
2509        // the lines above it have already invalidated.
2510        let remap = |off: usize| -> usize {
2511            let (mut old_ls, mut new_ls) = (start, start);
2512            for (line, delta) in lines.iter().zip(&deltas) {
2513                let old_le = old_ls + line.len();
2514                let new_len = (line.len() as isize + delta) as usize;
2515                if off <= old_le {
2516                    let col = (off - old_ls) as isize;
2517                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2518                }
2519                old_ls = old_le + 1;
2520                new_ls += new_len + 1;
2521            }
2522            start + out.len()
2523        };
2524        let placed = match self.selection() {
2525            // Keep the rewritten region selected, the way a container toggle
2526            // keeps its own: it leaves a second Tab aimed at the same lines
2527            // rather than at whatever the shifted offsets now happen to cover.
2528            Some(_) => (start + out.len(), Some(start)),
2529            None => (remap(self.caret), None),
2530        };
2531
2532        // A rolled-back splice leaves the old source in place, where every offset
2533        // computed above addresses text that was never written.
2534        if !self.splice(start, end, &out, EditKind::Other) {
2535            return;
2536        }
2537        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2538        // rewrite is the last line's end — nowhere the caret was. Place it, then
2539        // re-record the caret so this is the state redo restores, not the one
2540        // `splice` left behind from the `Change`.
2541        self.caret = placed.0.min(self.source.len());
2542        self.anchor = placed.1;
2543        self.clamp_caret();
2544        self.record_caret();
2545    }
2546
2547    /// The Enter key.
2548    ///
2549    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2550    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2551    /// caret is in decides what actually gets written.
2552    ///
2553    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2554    ///                            block at the caret and reopens its container
2555    ///   - list item            → likewise: the next item, its indent, quote
2556    ///                            prefix and `[ ]` box all reproduced by twig —
2557    ///                            except an *empty* item, which exits the list
2558    ///   - block quote          → likewise: a new paragraph inside the quote
2559    ///   - heading              → a new *paragraph*, not another heading
2560    ///   - code block           → a literal newline (stay in the block)
2561    ///   - blank line           → a literal newline (one Backspace undoes it)
2562    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2563    ///                            visible line
2564    ///
2565    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2566    /// and it is better at it: it drops the whitespace the caret was sitting in
2567    /// front of instead of stranding it at the head of the second half, and it
2568    /// knows continuations leaf's marker scan never covered — a checklist item
2569    /// continues as an *unchecked* checklist item rather than a plain bullet.
2570    ///
2571    /// The exceptions above are exceptions because `split_block` is either wrong
2572    /// there or refuses: parting a fence yields two fences with the code split
2573    /// between them, parting a heading yields a second heading where every editor
2574    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2575    /// table all report an error rather than a split.
2576    pub fn newline(&mut self) {
2577        if self.view == View::Source {
2578            self.insert_raw("\n");
2579            return;
2580        }
2581        // Enter over a selection replaces it with a paragraph break.
2582        if let Some((s, e)) = self.selection() {
2583            self.splice(s, e, "\n\n", EditKind::Other);
2584            return;
2585        }
2586        // A caret resting exactly between an inline mark's content and its own
2587        // closing delimiter (`**bold**` with nothing after it on the line —
2588        // the WYSIWYG caret's natural end-of-line position) must not splice a
2589        // block break there: every path below eventually does via
2590        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2591        // delimiter would strand it alone on the new line.
2592        self.caret = self.skip_trailing_close_delims(self.caret);
2593        // The block the caret is in. `block_offset_for_caret` nudges off a line
2594        // end (where the caret sits at the doc level); on a bare line (e.g. an
2595        // empty list item) fall back to the caret so the enclosing list/quote is
2596        // still visible in the ancestors.
2597        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2598        let kinds: Vec<Kind> = self
2599            .editor
2600            .ancestors_at(off)
2601            .map(|c| c.into_iter().map(|m| m.kind).collect())
2602            .unwrap_or_default();
2603        let has = |k: Kind| kinds.contains(&k);
2604
2605        if has(Kind::CodeBlock) {
2606            self.insert_raw("\n");
2607            return;
2608        }
2609        // An *empty* list item exits the list — the standard double-Enter — which
2610        // `split_block` reports as an error rather than a split (there is no
2611        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2612        // the AST gate — it answers from the tree, so a `- ` that reads as a
2613        // marker byte-for-byte but opens no item (a setext underline, a Djot
2614        // continuation line) never reaches here.
2615        if let Some(marker) = self.list_marker_on_line(self.caret)
2616            && self.item_is_empty(&marker)
2617        {
2618            self.exit_list(&marker);
2619            return;
2620        }
2621        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2622        // not another full paragraph break — so it moves down one line and one
2623        // Backspace undoes it, not two. (`split_block` errors here too.)
2624        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2625        let line_end = self.source[self.caret..]
2626            .find('\n')
2627            .map_or(self.source.len(), |i| self.caret + i);
2628        if self.source[line_start..line_end].trim().is_empty() {
2629            self.insert_raw("\n");
2630            return;
2631        }
2632        // In `Preserve` flow a soft break is a *visible* line the author means to
2633        // make, so Enter writes a single `\n` and typing continues the same
2634        // paragraph on the next line — the behaviour of an ordinary text editor.
2635        // A second Enter then lands on the blank line above and takes the
2636        // empty-line branch, so double-Enter still promotes to a full paragraph
2637        // break; and Backspace, which deletes a lone `\n` over a soft break,
2638        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2639        // render as an invisible space, so Enter keeps making the paragraph break
2640        // that actually shows.
2641        //
2642        // Only in running prose. A list or a quote has a continuation of its own
2643        // to write, and a `\n` there is not a soft line but a lost container.
2644        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2645        if self.line_flow == LineFlow::Preserve && !in_container {
2646            self.insert_raw("\n");
2647            return;
2648        }
2649        // A heading gets a *paragraph*, never a second heading: Enter at the end
2650        // of a title is how every editor is asked for the body under it, and
2651        // `split_block` would repeat the `#` instead. Whitespace at the split
2652        // point goes with the break rather than opening the new paragraph, which
2653        // is what `split_block` does everywhere else.
2654        if has(Kind::Heading) {
2655            let mut end = self.caret;
2656            while self.source.as_bytes().get(end) == Some(&b' ') {
2657                end += 1;
2658            }
2659            self.splice(self.caret, end, "\n\n", EditKind::Other);
2660            return;
2661        }
2662        self.split_block_here();
2663    }
2664
2665    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2666    /// the caret in the second half.
2667    ///
2668    /// twig reopens whatever the first half was inside of — the bullet with its
2669    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2670    /// reason this replaced the markup leaf used to spell from the line's bytes.
2671    /// It renumbers nothing, though: a new item mid-list is written with its
2672    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2673    /// behind it, folded into the same undo step.
2674    ///
2675    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2676    /// shape still moves the caret down rather than swallowing the keystroke.
2677    fn split_block_here(&mut self) {
2678        // The read-only gate — this door reaches twig without the splice.
2679        if self.read_only {
2680            return;
2681        }
2682        match self.editor.split_block(self.caret) {
2683            Ok(change) => {
2684                self.last_edit_kind = None;
2685                self.refresh();
2686                self.anchor = None;
2687                self.caret = change.new.end;
2688                self.dirty = self.source != self.clean_source;
2689                self.status = None;
2690                self.clamp_caret();
2691                self.record_caret();
2692                // Aimed at the new block's *start*: the caret twig leaves is one
2693                // past the marker it wrote, where there is no list in reach.
2694                self.renumber_at(change.new.start);
2695            }
2696            Err(_) => self.insert_raw("\n\n"),
2697        }
2698    }
2699
2700    /// Whether the item on the marker's line carries no content — the shape
2701    /// double-Enter reads as "I'm done with this list."
2702    fn item_is_empty(&self, line: &ListMarker) -> bool {
2703        let content_start = line.content_start().min(self.source.len());
2704        let line_end = self.source[self.caret..]
2705            .find('\n')
2706            .map(|i| self.caret + i)
2707            .unwrap_or(self.source.len());
2708        self.source[content_start..line_end.max(content_start)]
2709            .trim()
2710            .is_empty()
2711    }
2712
2713    /// Leave the list: replace the empty item's marker with a blank line, so the
2714    /// caret lands in a fresh paragraph below it.
2715    ///
2716    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2717    /// quote), and the caret's new line keeps the `> ` it was already behind —
2718    /// leaving the list without also leaving the quote.
2719    fn exit_list(&mut self, line: &ListMarker) {
2720        let prefix = self.quote_prefix_at(line.marker_start);
2721        let blank = prefix.trim_end();
2722        self.splice(
2723            line.line_start,
2724            self.caret,
2725            &format!("{blank}\n{prefix}"),
2726            EditKind::Other,
2727        );
2728    }
2729
2730    /// What a line continuing the containers at `off` has to open with — the
2731    /// quote markers reproduced, each enclosing item's marker as its width in
2732    /// spaces. Also the column a nested item's marker stands in, which is what
2733    /// makes it Tab's answer.
2734    fn continuation_prefix_at(&mut self, off: usize) -> String {
2735        self.editor
2736            .document()
2737            .and_then(|mut d| d.continuation_prefix(off))
2738            .map(|p| p.text)
2739            .unwrap_or_default()
2740    }
2741
2742    /// The column a *nested list* may open at inside the item at `off` — which
2743    /// is not always where the item's own text continues.
2744    ///
2745    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2746    /// it is markup a rich view hides, and the item's own wrapped text does
2747    /// stand past it. But a nested list may only open at the *list* marker's
2748    /// column, and four columns further in is an indented continuation of the
2749    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2750    /// So the box's own width goes back.
2751    ///
2752    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2753    /// reports the list marker's column apart from the box; `checked` is what
2754    /// says a box is there at all, so only its width is being measured here.
2755    fn nesting_prefix_at(&mut self, off: usize) -> String {
2756        let cont = self.continuation_prefix_at(off);
2757        let Some(item) = self.innermost_list_item(off) else {
2758            return cont;
2759        };
2760        if item.checked.is_none() {
2761            return cont;
2762        }
2763        let box_width = item
2764            .marker_span
2765            .and_then(|m| self.source.get(m))
2766            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2767            .unwrap_or(0);
2768        // The trailing columns are the ones the item's own marker contributed,
2769        // so trimming from the end leaves any quote prefix standing.
2770        cont[..cont.len().saturating_sub(box_width)].to_string()
2771    }
2772
2773    /// Where the line of the item *containing* the item at `off` begins — the
2774    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2775    /// parent contributed. The quote prefix alone for a top-level item, which
2776    /// has no level left to give.
2777    fn outdent_prefix_at(&mut self, off: usize) -> String {
2778        let items: Vec<usize> = self
2779            .editor
2780            .document()
2781            .and_then(|mut d| d.ancestors_at_caret(off))
2782            .map(|c| {
2783                c.into_iter()
2784                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2785                    .map(|m| m.span.start)
2786                    .collect()
2787            })
2788            .unwrap_or_default();
2789        // The second-innermost item is the parent; its own line's indent is the
2790        // target. `list_marker_on_line` gives that line's prefix directly.
2791        let parent = items.len().checked_sub(2).map(|i| items[i]);
2792        match parent.and_then(|p| self.list_marker_on_line(p)) {
2793            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2794            None => self.quote_prefix_at(off),
2795        }
2796    }
2797
2798    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2799    /// inside one, `"> > "` inside two.
2800    ///
2801    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2802    /// the `>` and the space after it are twig's spelling rather than leaf's.
2803    /// The whole line prefix can't answer this: it also carries the indent of
2804    /// whatever the quote holds, which a blank separator line must *not* repeat.
2805    fn quote_prefix_at(&mut self, off: usize) -> String {
2806        let Ok(chain) = self
2807            .editor
2808            .document()
2809            .and_then(|mut d| d.ancestors_at_caret(off))
2810        else {
2811            return String::new();
2812        };
2813        let quotes: Vec<usize> = chain
2814            .iter()
2815            .filter(|m| m.kind == Kind::BlockQuote)
2816            .map(|m| m.node_id as usize)
2817            .collect();
2818        let Ok(nodes) = self.editor.nodes() else {
2819            return String::new();
2820        };
2821        quotes
2822            .iter()
2823            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2824            .filter_map(|s| self.source.get(s))
2825            .collect()
2826    }
2827
2828    /// Whether the item at `off` sits inside another one — the test Backspace
2829    /// uses to choose between outdenting and dropping the marker.
2830    ///
2831    /// Counted from the AST rather than from the line's leading whitespace,
2832    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2833    fn item_is_nested(&mut self, off: usize) -> bool {
2834        self.editor
2835            .document()
2836            .and_then(|mut d| d.ancestors_at_caret(off))
2837            .map(|c| {
2838                c.into_iter()
2839                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2840                    .count()
2841                    > 1
2842            })
2843            .unwrap_or(false)
2844    }
2845
2846    /// The innermost list item containing `probe`, under twig's **caret**
2847    /// containment rule — a block's end is inside it.
2848    ///
2849    /// Half-open containment can't answer this. An empty item's span is exactly
2850    /// its marker, so the caret sitting after `- ` is one past the end and the
2851    /// item it is plainly in tests as out of reach; that is the shape
2852    /// double-Enter has to recognise to leave the list.
2853    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
2854        let chain = self
2855            .editor
2856            .document()
2857            .and_then(|mut d| d.ancestors_at_caret(probe))
2858            .ok()?;
2859        let id = chain
2860            .iter()
2861            .rev()
2862            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
2863            .node_id as usize;
2864        self.editor.nodes().ok()?.get(id).cloned()
2865    }
2866
2867    /// The list marker opening `off`'s line, per twig — `None` when that line
2868    /// opens no list item.
2869    ///
2870    /// [`Document::line_prefix`] is the whole hidden run from the line start:
2871    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
2872    /// and it is `None` on a *continuation* line, which opens nothing. That last
2873    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
2874    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
2875    /// a paragraph and `  - b` is literal text — identical bytes, and only the
2876    /// parser knows which document it is looking at.
2877    ///
2878    /// The item's own marker is separated out via its
2879    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
2880    /// the containers around it contribute and what the item does.
2881    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
2882        let off = off.min(self.source.len());
2883        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
2884        // The prefix belongs to a list only when an item's marker closes it —
2885        // a heading's `# ` or a bare quote's `> ` is a prefix too.
2886        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
2887        let marker = item.marker_span.clone()?;
2888        if marker.end != prefix.end {
2889            return None;
2890        }
2891        Some(ListMarker {
2892            line_start: prefix.start,
2893            marker_start: marker.start,
2894            text: self.source.get(prefix)?.to_string(),
2895        })
2896    }
2897
2898    /// Whether the list item on `line_start`'s line is the **first item** of its
2899    /// list — the one Tab must not nest, because nesting needs a preceding
2900    /// sibling to become the new parent and a first item has none. `false` for a
2901    /// line that isn't a list item, and for an item with a sibling above it (the
2902    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
2903    /// the same in a setext underline that opens no list at all.
2904    fn first_item_of_list(&mut self, line_start: usize) -> bool {
2905        let Some(marker) = self.list_marker_on_line(line_start) else {
2906            return false;
2907        };
2908        // Probe just inside the marker, where the item's own node is in reach —
2909        // the marker offset itself can resolve to the enclosing list, not the
2910        // `list_item`, whose span starts at the marker.
2911        let probe = marker.content_start().min(self.source.len());
2912        let Some(item) = self.innermost_list_item(probe) else {
2913            return false;
2914        };
2915        let Ok(nodes) = self.editor.nodes() else {
2916            return false;
2917        };
2918        match item.parent {
2919            // First when the parent list opens with this very item.
2920            Some(pid) => nodes
2921                .get(pid.0 as usize)
2922                .is_some_and(|p| p.first_child == Some(item.id)),
2923            // A parentless item is trivially the first (and only) one.
2924            None => true,
2925        }
2926    }
2927
2928    pub fn backspace(&mut self) {
2929        if let Some((s, e)) = self.selection() {
2930            self.splice(s, e, "", EditKind::Other);
2931            return;
2932        }
2933        // WYSIWYG: Backspace at the very start of a list item's content is a
2934        // structural key, not a character delete — it walks the "un-indent, then
2935        // un-list" ladder every list editor gives that keystroke (outdent a
2936        // nested item, strip a top-level one's marker to a paragraph). In source
2937        // view the `- ` is visible text the user is deleting a byte of, so it
2938        // keeps its literal meaning there, like Enter does.
2939        if self.view != View::Source && self.backspace_list_start() {
2940            return;
2941        }
2942        // WYSIWYG: and the same at the start of a heading's content — the `# `
2943        // there is markup the rich view hides, not text the user typed.
2944        if self.view != View::Source && self.backspace_heading_start() {
2945            return;
2946        }
2947        // WYSIWYG: and at the start of a block whose presentation is spelled
2948        // as hidden markup before it — djot's `{.center}` line, Markdown's
2949        // `<div class="center">` — Backspace takes that markup, the way it
2950        // takes a heading's `#`, rather than a byte out of it.
2951        if self.view != View::Source && self.backspace_attributed_block_start() {
2952            return;
2953        }
2954        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
2955        // the markup apart under a caret that cannot see it — see
2956        // `delete_around_block_media`.
2957        if self.view != View::Source && self.delete_around_block_media(false) {
2958            return;
2959        }
2960        // WYSIWYG: Backspace at a table's trailing stop steps back into its last
2961        // cell rather than taking the byte behind the caret — the row's closing
2962        // `|`, which the rich view never drew, so the key would have looked like
2963        // it did nothing. The stop before is the last cell's end.
2964        if self.view != View::Source && self.backspace_at_table_end() {
2965            return;
2966        }
2967        // WYSIWYG: at the start of a block's content, the byte behind the caret
2968        // is a block boundary, and Backspace over one is a join — twig's, so
2969        // that what a join is in each format is not this file's to know. After
2970        // the picture and table cases, which are block starts with their own
2971        // answers.
2972        if self.view != View::Source && self.backspace_joins_block() {
2973            return;
2974        }
2975        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
2976        // stop, not a single newline. On a line with no text of its own, the byte
2977        // before the caret is a `\n` that spells part of a block boundary — the gap
2978        // between two blocks, drawn but never a caret home. Removing just it strands
2979        // the caret in that gap and leaves an odd blank line the eye reads as one
2980        // separator but the caret can't land on: the "extra newline" left behind
2981        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
2982        // Deleting to the previous stop instead collapses the whole break at once,
2983        // landing the caret at the end of the block above. Two blank lines in a row
2984        // are one stop apart, so this still removes exactly one — the lone-Enter /
2985        // lone-Backspace symmetry the empty-line case is built on is untouched.
2986        if self.view != View::Source
2987            && self.caret > self.caret_floor()
2988            && self.caret_on_blank_line()
2989            && let Some(stop) = self.vmap.stop_before(self.caret)
2990        {
2991            let stop = stop.max(self.caret_floor());
2992            if stop < self.caret {
2993                if self.source[stop..self.caret].trim().is_empty() {
2994                    self.splice(stop, self.caret, "", EditKind::Delete);
2995                } else {
2996                    // Hidden markup stands between the stop and the caret — a
2997                    // `</div>`, a comment, a link reference definition — and
2998                    // collapsing to the stop would delete it. Take the blank
2999                    // line alone, with the newline that opened it, and land
3000                    // the caret where the collapse would have.
3001                    self.delete_blank_line_to(stop);
3002                }
3003                return;
3004            }
3005        }
3006        if self.caret > self.caret_floor() {
3007            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3008            // takes the whole tag — a single-byte step would leave a broken `<br`
3009            // showing in the cell. Rich view only (source view edits the literal).
3010            if self.view != View::Source
3011                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3012            {
3013                let start = start.max(self.caret_floor());
3014                if start < end {
3015                    self.splice(start, end, "", EditKind::Delete);
3016                    return;
3017                }
3018            }
3019            // Aim the delete at the character the writer can *see* behind the
3020            // caret, never at a delimiter the rich view drew nothing for. Two
3021            // steps, and either can apply: from the far side of a run's closing
3022            // `**` step back into the run (the caret is drawn at the end of its
3023            // word), and at the start of a run's text step out past its opening
3024            // `**` to the character in front of it, leaving the run standing.
3025            // Without them a plain Backspace unspells the phrase it is editing
3026            // and leaves a literal asterisk on screen.
3027            let end = if self.view == View::Source {
3028                self.caret
3029            } else {
3030                let inside = self.step_inside_close_delims(self.caret);
3031                // An attributed span with no text — `<span …></span>` as the
3032                // file was written — is hidden markup around nothing, and a
3033                // byte-step here would take its `>`. Backspace takes the span
3034                // whole, with the character before it: the character the key
3035                // looks aimed at, since the span draws nothing.
3036                if let Some(span) = self.run_span_of_content(inside..inside) {
3037                    let from = if self.source[..span.start].ends_with('\n') {
3038                        span.start
3039                    } else {
3040                        prev_boundary(&self.source, span.start)
3041                    };
3042                    let from = from.max(self.caret_floor());
3043                    self.splice(from, span.end, "", EditKind::Delete);
3044                    return;
3045                }
3046                self.skip_leading_open_delims(inside)
3047                    .max(self.caret_floor())
3048            };
3049            // Never delete back across the floor — that would eat hidden
3050            // frontmatter the WYSIWYG caret can't even see.
3051            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3052            // Take a hidden escape backslash with the char it escapes: the rich
3053            // view draws `\*` as a single `*`, so Backspace over it must delete
3054            // both bytes, never strand the `\` as a lone visible backslash (the
3055            // mirror of the Hidden-mode typing that wrote the escape). Source view
3056            // shows the `\`, so there it is an ordinary character.
3057            if self.view != View::Source
3058                && prev > self.caret_floor()
3059                && self.is_hidden_escape(prev - 1)
3060            {
3061                prev -= 1;
3062            }
3063            // The delete that takes the last of a span's text takes the span
3064            // with it, in the same edit: `<span …>i</span>` losing its `i`
3065            // would leave an empty span the map has no stop inside, so the
3066            // caret would draw at the next stop — a line away — until a
3067            // further key removed the span. Landing on the span's start is
3068            // where the letter was.
3069            if self.view != View::Source
3070                && let Some(span) = self.run_span_of_content(prev..end)
3071            {
3072                self.splice(span.start, span.end, "", EditKind::Delete);
3073                return;
3074            }
3075            // And the same for a block: the letter that was all of a centred
3076            // paragraph's text goes with the `<div>` around it, or the `{…}`
3077            // line above it, leaving a plain blank line where the letter was.
3078            // A paragraph with no text is no block, so the markup would stand
3079            // around nothing, the map would give it no caret home, and the
3080            // next key would take the tag apart.
3081            if self.view != View::Source
3082                && let Some(block) = self.attributed_block_of_content(prev..end)
3083            {
3084                self.splice(block.start, block.end, "", EditKind::Delete);
3085                return;
3086            }
3087            if prev < end {
3088                self.splice(prev, end, "", EditKind::Delete);
3089            }
3090        }
3091    }
3092
3093    /// Remove the blank line the caret is on — its own newline and the one
3094    /// that ended the line before it — and put the caret on `stop`, the caret
3095    /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3096    /// blank line that hidden markup separates from the block above: the
3097    /// navigable blank row after a `</div>` is always one of at least three
3098    /// newlines under the tag (the drawn separators either side of it), so
3099    /// taking two leaves the blank line the tag needs under it.
3100    fn delete_blank_line_to(&mut self, stop: usize) {
3101        let caret = self.caret;
3102        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3103        let line_end = self.source[caret..]
3104            .find('\n')
3105            .map_or(self.source.len(), |i| caret + i);
3106        let from = line_start.saturating_sub(1).max(stop);
3107        let to = (line_end + 1).min(self.source.len());
3108        self.splice(from, to, "", EditKind::Delete);
3109        self.caret = stop;
3110        self.anchor = None;
3111        self.goal_col = None;
3112        self.record_caret();
3113    }
3114
3115    /// Move the caret to the stop before it and consume the key — what
3116    /// Backspace does where the byte behind the caret is hidden markup it
3117    /// has no structural answer for, rather than take that markup apart.
3118    fn step_back_to_stop(&mut self) {
3119        // The map answers about offsets, so it has to be this revision's — see
3120        // `open_paragraph_at_block_edge`.
3121        self.rebuild_map();
3122        if let Some(off) = self
3123            .vmap
3124            .stop_before(self.caret)
3125            .filter(|&o| o >= self.caret_floor())
3126        {
3127            self.caret = off;
3128            self.anchor = None;
3129            self.goal_col = None;
3130        }
3131    }
3132
3133    /// Backspace's presentation behaviour: with the caret exactly at the start
3134    /// of a block's content, and that block's attributes spelled as hidden
3135    /// markup before it, strip the attributes. The peer of
3136    /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3137    /// reasoning: the `{.center}` line above a djot block and the
3138    /// `<div class="center">` around a Markdown one are what the byte behind
3139    /// the caret belongs to, and the rich view draws neither. The ordinary
3140    /// delete took the newline out of `{.center}\nhello` and left
3141    /// `{.center}hello` — the attribute line fused onto the text as prose —
3142    /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3143    ///
3144    /// The whole attribute set goes, the way the whole `#` marker does — the
3145    /// press is over the line that spells it, not over one key of it — and
3146    /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3147    /// djot line and unwraps the Markdown div. Where the block is the first of
3148    /// several in a div, twig has no sole child to unwrap and answers with a
3149    /// no-op, so the caret steps back to the stop before instead, as it does
3150    /// at a table's end. A later child of the div has an ordinary paragraph
3151    /// above it and is not this rule's.
3152    ///
3153    /// Returns whether it acted; `false` leaves Backspace its character delete.
3154    fn backspace_attributed_block_start(&mut self) -> bool {
3155        if !matches!(self.format, Format::Markdown | Format::Djot) {
3156            return false;
3157        }
3158        let caret = self.caret;
3159        let nodes = self.nodes();
3160        let Some(block) = nodes
3161            .iter()
3162            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3163            .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3164        else {
3165            return false;
3166        };
3167        match self.format {
3168            Format::Djot => {
3169                // twig records where the `{…}` block was written, so this is
3170                // the parser's own answer and not a scan for a `{` above the
3171                // block; `None` (a synthesized or merged set) is not a line
3172                // the caret is standing after.
3173                let spelled = self
3174                    .editor
3175                    .document()
3176                    .ok()
3177                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3178                    .is_some_and(|s| s.end <= caret);
3179                if !spelled {
3180                    return false;
3181                }
3182            }
3183            _ => {
3184                let Some(div) = block
3185                    .parent
3186                    .and_then(|p| nodes.iter().find(|n| n.id == p))
3187                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3188                else {
3189                    return false;
3190                };
3191                let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3192                if kids.clone().any(|k| k.span.start < block.span.start) {
3193                    return false;
3194                }
3195                if kids.nth(1).is_some() {
3196                    self.step_back_to_stop();
3197                    return true;
3198                }
3199            }
3200        }
3201        self.write_block_attrs("block attributes", Vec::new());
3202        true
3203    }
3204
3205    /// Backspace at the start of a block's content: join the block into the
3206    /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3207    /// the split Enter makes, spelled the format's way. Two paragraphs join
3208    /// on a soft break; a paragraph under a marker heading joins onto the
3209    /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3210    /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3211    /// taken as one; a quote's or an item's continuation prefix is written.
3212    /// The joined text takes the block above's presentation and containers,
3213    /// which is the rule every editor with a centred paragraph follows.
3214    ///
3215    /// Leaf used to join by deleting the one newline behind the caret, which
3216    /// is the right bytes for two Markdown paragraphs and nothing else: under
3217    /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3218    /// after a div it took the newline under the hidden `</div>`, which drew
3219    /// nothing different and took the tag apart on the next press. What a
3220    /// join is in each format is twig's to know, and now it does.
3221    ///
3222    /// Where twig refuses — the block above is a code block, a table or a
3223    /// rule with no text to join into, or the caret's block would have to
3224    /// leave a div that holds more after it — the caret steps back to the
3225    /// stop before instead, as it does at a table's end: the key moves the
3226    /// caret and takes no markup apart. Where nothing precedes the block, or
3227    /// the format cannot join at all, Backspace keeps its character delete.
3228    ///
3229    /// Returns whether it acted.
3230    fn backspace_joins_block(&mut self) -> bool {
3231        let caret = self.caret;
3232        if caret <= self.caret_floor() {
3233            return false;
3234        }
3235        let Some(text) = self.text_block_opening_at(caret) else {
3236            return false;
3237        };
3238        match self.join_blocks(caret) {
3239            Ok(change) => {
3240                // The caret keeps its place at the start of the text it stood
3241                // on, wherever the join put that text — after a soft break,
3242                // a space, or a quote's prefix. Found by the bytes, as
3243                // `block_content_in` finds a re-spelled block.
3244                let region = &self.source[change.new.clone()];
3245                let at = region
3246                    .find(&text)
3247                    .map_or(change.new.start, |i| change.new.start + i);
3248                self.land_after_join(at);
3249                true
3250            }
3251            Err(twig::Error::NotEditable) => {
3252                self.step_back_to_stop();
3253                true
3254            }
3255            Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3256            Err(e) => {
3257                self.status = Some(format!("join: {e}"));
3258                true
3259            }
3260        }
3261    }
3262
3263    /// Delete at the end of a block's content: join the block after it into
3264    /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3265    /// mirror, and the same twig gesture aimed at the next block. The caret
3266    /// stays where it was, which is where the joined text now begins after
3267    /// the separator. Where twig refuses, the caret steps forward to the next
3268    /// stop instead; where no block follows, Delete keeps its character
3269    /// delete.
3270    fn delete_forward_joins_block(&mut self) -> bool {
3271        let caret = self.caret;
3272        let at_end = self
3273            .nodes()
3274            .iter()
3275            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3276            .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3277        if !at_end {
3278            return false;
3279        }
3280        // The next stop, across a line end, in a block: what Delete at a
3281        // block's end points at. On the same line it is a hidden delimiter's
3282        // far side, which the ordinary delete handles; on a blank line it is
3283        // the empty paragraph the byte delete has always closed.
3284        self.rebuild_map();
3285        let Some(stop) = self.vmap.stop_after(caret) else {
3286            return false;
3287        };
3288        if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3289            return false;
3290        }
3291        match self.join_blocks(stop) {
3292            Ok(change) => {
3293                self.land_after_join(change.old.start);
3294                true
3295            }
3296            Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3297                self.caret = stop;
3298                self.anchor = None;
3299                self.goal_col = None;
3300                true
3301            }
3302            Err(twig::Error::UnsupportedFormat) => false,
3303            Err(e) => {
3304                self.status = Some(format!("join: {e}"));
3305                true
3306            }
3307        }
3308    }
3309
3310    /// The content bytes of the paragraph or heading whose content opens
3311    /// exactly at `off` — the block a Backspace there is at the start of.
3312    fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3313        self.nodes()
3314            .into_iter()
3315            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3316            .find_map(|n| {
3317                let c = n.content_span?;
3318                (c.start == off).then(|| self.source[c].to_string())
3319            })
3320    }
3321
3322    /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3323    /// plumbing every structural gesture has; the caret is the caller's to
3324    /// place from the change, via [`land_after_join`](Self::land_after_join).
3325    fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3326        if self.read_only {
3327            return Err(twig::Error::NotEditable);
3328        }
3329        self.record_caret();
3330        let change = self.editor.join_blocks(offset)?;
3331        self.last_edit_kind = None; // structural edit is its own undo step
3332        self.refresh();
3333        Ok(change)
3334    }
3335
3336    /// Finish a join: the caret at `at`, no selection, the map this
3337    /// revision's before the clamp — see `write_block_attrs` for why.
3338    fn land_after_join(&mut self, at: usize) {
3339        self.caret = at;
3340        self.anchor = None;
3341        self.goal_col = None;
3342        self.dirty = self.source != self.clean_source;
3343        self.status = None;
3344        self.rebuild_map();
3345        self.clamp_caret();
3346        self.record_caret();
3347    }
3348
3349    /// Backspace at a table's trailing stop: move onto the stop before it (the
3350    /// last cell's end) and consume the key. `false` anywhere else. See
3351    /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3352    /// not one to delete.
3353    fn backspace_at_table_end(&mut self) -> bool {
3354        // The map answers about offsets, so it has to be this revision's — see
3355        // `open_paragraph_at_block_edge`.
3356        self.rebuild_map();
3357        if !self.vmap.table_end_stop(self.caret) {
3358            return false;
3359        }
3360        if let Some(off) = self
3361            .vmap
3362            .stop_before(self.caret)
3363            .filter(|&o| o >= self.caret_floor())
3364        {
3365            self.caret = off;
3366            self.anchor = None;
3367            self.goal_col = None;
3368        }
3369        true
3370    }
3371
3372    /// Whether the caret's own source line holds nothing but whitespace — an
3373    /// empty paragraph, or the blank line a block boundary is spelled with. The
3374    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3375    /// no text of its own, so the newline before the caret belongs to the gap
3376    /// between blocks rather than to any word the caret is editing.
3377    fn caret_on_blank_line(&self) -> bool {
3378        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3379        let line_end = self.source[self.caret..]
3380            .find('\n')
3381            .map_or(self.source.len(), |i| self.caret + i);
3382        self.source[line_start..line_end].trim().is_empty()
3383    }
3384
3385    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3386    /// `edge` side — the byte range to delete whole. A table row is one source
3387    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3388    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3389    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3390    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3391    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3392    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
3393    /// apart — no ancestor walk needed. Rich view only; source view shows the
3394    /// literal tag and deletes it a byte at a time.
3395    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3396        let caret = self.caret;
3397        let nodes = self.nodes();
3398        let src = self.source.as_bytes();
3399        nodes
3400            .iter()
3401            .find(|n| {
3402                n.kind == Kind::HardBreak
3403                    && n.span.start < n.span.end
3404                    && src.get(n.span.start) == Some(&b'<')
3405                    && match edge {
3406                        BreakEdge::Backward => n.span.end == caret,
3407                        BreakEdge::Forward => n.span.start == caret,
3408                    }
3409            })
3410            .map(|n| (n.span.start, n.span.end))
3411    }
3412
3413    /// Whether the source byte at `off` is a backslash twig consumed as an escape
3414    /// (hidden in the rich view), as against a literal backslash (drawn). A
3415    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3416    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3417    /// round-trip needed.
3418    fn is_hidden_escape(&self, off: usize) -> bool {
3419        let b = self.source.as_bytes();
3420        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3421    }
3422
3423    /// Backspace's list behaviour: when the caret sits exactly at the start of a
3424    /// list item's content (right after its marker), outdent the item if it's
3425    /// nested, else strip the marker so it becomes a paragraph. Returns whether
3426    /// it acted — `false` leaves Backspace its ordinary character delete.
3427    fn backspace_list_start(&mut self) -> bool {
3428        let Some(marker) = self.list_marker_on_line(self.caret) else {
3429            return false;
3430        };
3431        // Only right after the marker. That the line opens a real item is
3432        // already settled: `list_marker_on_line` answers from the tree.
3433        if self.caret != marker.content_start() {
3434            return false;
3435        }
3436        if self.item_is_nested(marker.marker_start) {
3437            // Nested: give back one level, keeping the marker and carrying the
3438            // caret with it.
3439            self.outdent();
3440        } else {
3441            // Top level: drop the marker, leaving a paragraph, then renumber the
3442            // siblings the removed item was counted among. Only the marker goes —
3443            // a quote prefix in front of it still has a quote to hold up.
3444            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3445            self.renumber_here();
3446        }
3447        true
3448    }
3449
3450    /// Backspace's heading behaviour: with the caret exactly at the start of an
3451    /// ATX heading's content — right after the `#` marker the rich view hides —
3452    /// strip the marker so the line becomes a paragraph. The peer of
3453    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3454    /// reasoning: hidden block markup is structure, so the keystroke over it is
3455    /// structural.
3456    ///
3457    /// Without this the ordinary delete takes the space out of `# Title` and
3458    /// leaves `#Title`, which is no longer a heading at all — the hash the view
3459    /// had been hiding surfaces as literal text the user has to delete a second
3460    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3461    /// other end) goes with the marker for the same reason.
3462    ///
3463    /// Returns whether it acted; `false` leaves Backspace its character delete.
3464    fn backspace_heading_start(&mut self) -> bool {
3465        let caret = self.caret;
3466        // The heading whose content opens exactly at the caret. A bare `#` has no
3467        // content span at all — its content starts (and ends) where the line does.
3468        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3469            let (start, end) = match &n.content_span {
3470                Some(c) => (c.start, c.end),
3471                None => (n.span.end, n.span.end),
3472            };
3473            (n.kind == Kind::Heading && start == caret)
3474                .then(|| (n.span.clone(), end, n.marker_span.clone()))
3475        }) else {
3476            return false;
3477        };
3478        // twig reports the marker's own extent, so there is nothing to walk back
3479        // over and no `#` in this file. A setext heading has no marker — its
3480        // content opens the line — so it falls through to the ordinary delete,
3481        // as does anything else sitting at a content start.
3482        // `m.end == caret` is what excludes a setext heading, whose marker is the
3483        // underline *after* the content rather than a prefix before it.
3484        let Some(marker) = marker.filter(|m| m.end == caret) else {
3485            return false;
3486        };
3487        let start = marker.start;
3488        // A closing `#` sequence is hidden too, so it can't be left behind. Only
3489        // when the tail really is one: trailing spaces alone are nothing to strip.
3490        let tail = &self.source[content_end..span.end];
3491        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
3492            let kept = self.source[caret..content_end].to_string();
3493            self.splice(start, span.end, &kept, EditKind::Other);
3494            // The splice leaves the caret past the text it re-wrote; the caret
3495            // belongs where the content now starts, which is where it already was.
3496            self.caret = start;
3497            self.record_caret();
3498        } else {
3499            self.splice(start, caret, "", EditKind::Other);
3500        }
3501        true
3502    }
3503
3504    pub fn delete_forward(&mut self) {
3505        if let Some((s, e)) = self.selection() {
3506            self.splice(s, e, "", EditKind::Other);
3507        } else if self.caret < self.source.len() {
3508            // The mirror of Backspace's: forward-delete in front of a picture
3509            // would eat the `!` off its markup and leave a link where a photo was.
3510            if self.view != View::Source && self.delete_around_block_media(true) {
3511                return;
3512            }
3513            // And of Backspace's join: at the end of a block's content, Delete
3514            // joins the next block into this one.
3515            if self.view != View::Source && self.delete_forward_joins_block() {
3516                return;
3517            }
3518            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3519            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3520            // strands a broken `<br` in the cell.
3521            if self.view != View::Source
3522                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3523            {
3524                self.splice(start, end, "", EditKind::Delete);
3525                return;
3526            }
3527            // The mirror of Backspace's two steps: from in front of a run's
3528            // opening `**` step into it, onto the first letter of its text, and
3529            // at the end of a run's text step out past its closing `**` to the
3530            // character beyond. Either way Delete takes the character it looks
3531            // like it is pointing at, and never a delimiter drawn as nothing.
3532            // The caret then settles back inside the run it was standing in —
3533            // see `settle_inside_close_delims`.
3534            let from = if self.view == View::Source {
3535                self.caret
3536            } else {
3537                let inside = self.step_inside_open_delims(self.caret);
3538                // The mirror of Backspace's empty-span rule: an attributed
3539                // span with no text goes whole, with the character after it.
3540                if let Some(span) = self.run_span_of_content(inside..inside) {
3541                    let to = if self.source[span.end..].starts_with('\n') {
3542                        span.end
3543                    } else {
3544                        next_boundary(&self.source, span.end)
3545                    };
3546                    self.splice(span.start, to, "", EditKind::Delete);
3547                    return;
3548                }
3549                self.skip_trailing_close_delims(inside)
3550            };
3551            let next = next_boundary(&self.source, from);
3552            // And of its emptying rules: the span goes with its last letter,
3553            // and so does the block's div or `{…}` line.
3554            if self.view != View::Source
3555                && let Some(span) = self.run_span_of_content(from..next)
3556            {
3557                self.splice(span.start, span.end, "", EditKind::Delete);
3558                return;
3559            }
3560            if self.view != View::Source
3561                && let Some(block) = self.attributed_block_of_content(from..next)
3562            {
3563                self.splice(block.start, block.end, "", EditKind::Delete);
3564                return;
3565            }
3566            if from < next {
3567                self.splice(from, next, "", EditKind::Delete);
3568            }
3569        }
3570    }
3571
3572    /// Delete from the caret back to the start of the previous word (⌥⌫ /
3573    /// Ctrl+⌫). Deletes the selection instead when one is active.
3574    pub fn delete_word_back(&mut self) {
3575        if let Some((s, e)) = self.selection() {
3576            self.splice(s, e, "", EditKind::Other);
3577        } else {
3578            // A word back from just past a picture is a word *of its markup*, and
3579            // a word back from in front of one runs through the paragraph break
3580            // into the prose above — dissolving the picture either way. See
3581            // `delete_around_block_media`.
3582            if self.view != View::Source && self.delete_around_block_media(false) {
3583                return;
3584            }
3585            let start = self.word_left_from(self.caret).max(self.caret_floor());
3586            if start < self.caret {
3587                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3588                self.splice(s, e, "", EditKind::Delete);
3589            }
3590        }
3591    }
3592
3593    /// Delete from the caret forward to the end of the next word (⌥⌦ /
3594    /// Ctrl+Del). Deletes the selection instead when one is active.
3595    pub fn delete_word_forward(&mut self) {
3596        if let Some((s, e)) = self.selection() {
3597            self.splice(s, e, "", EditKind::Other);
3598        } else {
3599            // The mirror: a word forward from in front of a picture is its markup.
3600            if self.view != View::Source && self.delete_around_block_media(true) {
3601                return;
3602            }
3603            let end = self.word_right_from(self.caret);
3604            if end > self.caret {
3605                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3606                self.splice(s, e, "", EditKind::Delete);
3607            }
3608        }
3609    }
3610
3611    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3612    /// selection instead when one is active, as every other delete here does.
3613    ///
3614    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3615    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3616    /// stops at the first character and this takes the indentation with it, the
3617    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3618    /// leave an indent behind that nothing can then ask to delete, where a caret
3619    /// left at column 0 is one press of Home away from either.
3620    pub fn delete_to_line_start(&mut self) {
3621        if let Some((s, e)) = self.selection() {
3622            self.splice(s, e, "", EditKind::Other);
3623            return;
3624        }
3625        // Never back across the floor: hidden frontmatter isn't on this line, or
3626        // on any line the WYSIWYG caret can see.
3627        let (start, _) = self.line_span();
3628        let start = start.max(self.caret_floor());
3629        if start < self.caret {
3630            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3631            self.splice(s, e, "", EditKind::Delete);
3632        }
3633    }
3634
3635    /// Kill from the caret to the end of its line (^K). Deletes the selection
3636    /// instead when one is active.
3637    ///
3638    /// At the end of the line it does nothing, rather than pulling the line
3639    /// below up into this one. Joining has no meaning to give it in both views
3640    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3641    /// there is nothing there to delete, while the newline a *source* line ends
3642    /// with is only half of the blank line that separates two paragraphs —
3643    /// deleting one leaves a soft break, which is not the join it looks like.
3644    /// The views agreeing is worth more than emacs' second press, and Delete is
3645    /// already the key that joins.
3646    pub fn delete_to_line_end(&mut self) {
3647        if let Some((s, e)) = self.selection() {
3648            self.splice(s, e, "", EditKind::Other);
3649            return;
3650        }
3651        let (_, end) = self.line_span();
3652        if end > self.caret {
3653            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3654            self.splice(s, e, "", EditKind::Delete);
3655        }
3656    }
3657
3658    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3659    ///
3660    /// A glyph-space range covers what the user can see, which for `**bold**` is
3661    /// the word and never the delimiters around it — so deleting the word on its
3662    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3663    /// word, and the styling was the word's; the two go together. Only the
3664    /// node's delimiters are taken, and those are hidden here anyway, so nothing
3665    /// visible outside the range is lost.
3666    ///
3667    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3668    /// the strong, and only then is the strong empty too.
3669    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3670        if self.view == View::Source {
3671            return (start, end);
3672        }
3673        let nodes = self.nodes();
3674        let (mut s, mut e) = (start, end);
3675        loop {
3676            let mut grew = false;
3677            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3678                let Some(text) = inline_content_span(n, &self.source) else {
3679                    continue;
3680                };
3681                // Some of its text survives, so the node still has a job.
3682                if text.start < s || text.end > e {
3683                    continue;
3684                }
3685                if n.span.start < s || n.span.end > e {
3686                    s = s.min(n.span.start);
3687                    e = e.max(n.span.end);
3688                    grew = true;
3689                }
3690            }
3691            if !grew {
3692                return (s, e);
3693            }
3694        }
3695    }
3696
3697    /// One splice of document text, keeping the **mark-edge rule**: an inline
3698    /// mark's content never begins or ends with whitespace. In Markdown and Djot
3699    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3700    /// is four literal asterisks around a word, and a rich view drawing the
3701    /// document faithfully has no choice but to show them. That is correct
3702    /// rendering of what the file says, and nobody typing a space after a bold
3703    /// word meant to say it.
3704    ///
3705    /// So the space goes *outside* the run instead — `**bold** ` — which is the
3706    /// same document to a reader and a live one to a parser. The caret follows it
3707    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3708    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3709    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3710    ///
3711    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3712    /// through here, so the rule holds however the whitespace arrives at the
3713    /// edge. The repair is decided *after* the plain edit, by asking whether the
3714    /// mark actually died: a code span's backticks aren't whitespace-sensitive
3715    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3716    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3717        let fix = self.mark_edge_fix(start, end, text);
3718        if !self.splice_exact(start, end, text, kind) {
3719            return false;
3720        }
3721        if let Some(fix) = fix {
3722            self.repair_mark_edges(fix);
3723        }
3724        if text.is_empty() && end > start {
3725            self.settle_inside_close_delims();
3726        }
3727        true
3728    }
3729
3730    /// After a delete, take a caret left standing past a run's closing delimiters
3731    /// back inside the run.
3732    ///
3733    /// A delete leaves the caret where the deleted bytes began, and when those
3734    /// bytes were the last thing after a marked phrase — the space the mark-edge
3735    /// rule pushed out of `**bold** `, say — that spot is the far side of the
3736    /// closing `**`. The rich view has nothing to draw there: the delimiters are
3737    /// hidden, so the caret shows at the end of the word either way, and the two
3738    /// offsets are one place on screen with two different meanings. Typing at the
3739    /// outer one lands past the run, so the writer who backspaced a space out of
3740    /// their bold phrase watches the next character come out plain, and the
3741    /// toolbar button go dark, with the caret never appearing to move.
3742    ///
3743    /// The end of the run's text is the caret's home there — a delete that took
3744    /// away everything after a phrase leaves the caret at the end of that phrase,
3745    /// which is inside it — so it settles onto that
3746    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3747    /// walk, through every mark closing at the point): the word stays bold, the
3748    /// button stays lit, and the next character carries on the phrase.
3749    ///
3750    /// Rich view only, and only where a mark really closes at the caret — mid-run
3751    /// or in plain prose no span ends there and the caret stays put. The opening
3752    /// edge is left alone on purpose: a caret in front of a run inherits from the
3753    /// text on its left, which is the plain text outside.
3754    fn settle_inside_close_delims(&mut self) {
3755        if self.view != View::Wysiwyg {
3756            return;
3757        }
3758        let at = self.step_inside_close_delims(self.caret);
3759        if at != self.caret {
3760            self.caret = at;
3761            self.clear_pending();
3762            self.record_caret();
3763        }
3764    }
3765
3766    /// The splice exactly as asked, with no mark-edge repair — for the callers
3767    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3768    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3769    /// the bytes they inserted.
3770    ///
3771    /// One `edit_range` through twig, then re-anchor the caret from the returned
3772    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3773    /// Markdown/Djot) leaves the document untouched and reports.
3774    ///
3775    /// Returns whether the edit landed — for a caller that has offsets of its
3776    /// own to place afterwards, which a rolled-back splice would leave pointing
3777    /// into text that never came to exist.
3778    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3779        // The read-only gate, for every edit at once — see the field.
3780        if self.read_only {
3781            return false;
3782        }
3783        // twig records an undo step for every edit; when this one continues a
3784        // run of the same kind (typing, deleting), tell twig to fold it into the
3785        // step before it so the whole run undoes at once.
3786        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3787        // Hand twig the pre-edit caret before the splice, so the undo step it
3788        // retires carries where the caret was standing.
3789        self.record_caret();
3790        match self.editor.edit_range(start, end, text) {
3791            Ok(change) => {
3792                if coalesce {
3793                    let _ = self.editor.coalesce_last_undo();
3794                }
3795                self.last_edit_kind = Some(kind);
3796                self.refresh();
3797                self.caret = change.new.end;
3798                self.anchor = None;
3799                self.goal_col = None;
3800                self.clear_pending();
3801                self.dirty = self.source != self.clean_source;
3802                self.status = None;
3803                // And the post-edit caret, so a later redo restores it.
3804                self.record_caret();
3805                true
3806            }
3807            // The edit was rolled back, so twig's history did not move and
3808            // neither may ours: pushing here would leave a step with no edit
3809            // under it and shift every later undo onto the wrong caret.
3810            Err(e) => {
3811                self.status = Some(format!("edit: {e}"));
3812                false
3813            }
3814        }
3815    }
3816
3817    /// The re-spelling that would keep the mark-edge rule for the edit
3818    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3819    /// against a delimiter and the plain splice is already right. Computed
3820    /// *before* the edit, while the run's spans and delimiters can still be read
3821    /// off the document; applied afterwards, and only if the mark really died —
3822    /// see [`repair_mark_edges`](Self::repair_mark_edges).
3823    ///
3824    /// Rich view only. Source view is for typing raw markup, where a space put
3825    /// against a `**` is exactly the character it looks like.
3826    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3827        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3828            return None;
3829        }
3830        // Every inline mark standing over the edit, outermost first, with the
3831        // content span that says where its delimiters are.
3832        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3833            .editor
3834            .ancestors_at(start)
3835            .unwrap_or_default()
3836            .into_iter()
3837            .filter_map(|m| {
3838                let kind = inline_kind(&m.kind)?;
3839                let content = m.content_span.clone()?;
3840                Some((kind, m.span.clone(), content))
3841            })
3842            .collect();
3843        // The innermost run whose *content* holds the whole edit: the one whose
3844        // text is being changed, rather than one the edit merely sits under.
3845        let (kind, span, content) = chain
3846            .iter()
3847            .rev()
3848            .find(|(_, _, c)| c.start <= start && end <= c.end)?
3849            .clone();
3850        // What that content becomes. Whitespace at either end of it is what
3851        // would put out the mark.
3852        let body = format!(
3853            "{}{text}{}",
3854            &self.source[content.start..start],
3855            &self.source[end..content.end]
3856        );
3857        let (lead, trail) = if body.trim().is_empty() {
3858            // Nothing but whitespace left: there is no content to mark at all,
3859            // and the delimiters go with it rather than closing on a space.
3860            (body.len(), 0)
3861        } else {
3862            (
3863                body.len() - body.trim_start().len(),
3864                body.len() - body.trim_end().len(),
3865            )
3866        };
3867        // Nothing against a delimiter, and something still between them: the
3868        // plain edit stands. An emptied run is broken just as surely (`**b**`
3869        // with the `b` deleted is the literal `****`) and is re-spelt as the
3870        // nothing it now says.
3871        if lead == 0 && trail == 0 && !body.is_empty() {
3872            return None;
3873        }
3874        // Marks that open or close exactly where this one does — `***both***` is
3875        // two runs sharing an edge — spell their delimiters as one run of bytes,
3876        // so the whitespace has to clear all of them together.
3877        let (mut open_at, mut close_at) = (span.start, span.end);
3878        for _ in 0..chain.len() {
3879            match chain.iter().find(|(_, _, c)| c.start == open_at) {
3880                Some((_, s, _)) => open_at = s.start,
3881                None => break,
3882            }
3883        }
3884        for _ in 0..chain.len() {
3885            match chain.iter().find(|(_, _, c)| c.end == close_at) {
3886                Some((_, s, _)) => close_at = s.end,
3887                None => break,
3888            }
3889        }
3890        let open = &self.source[open_at..content.start];
3891        let close = &self.source[content.end..close_at];
3892        let core = &body[lead..body.len() - trail];
3893        let respelt = if core.is_empty() {
3894            body.clone()
3895        } else {
3896            format!(
3897                "{}{open}{core}{close}{}",
3898                &body[..lead],
3899                &body[body.len() - trail..]
3900            )
3901        };
3902        // The caret sits just past the inserted text within the new content —
3903        // which, when that lands in the whitespace, is now outside the delimiters.
3904        let pos = (start - content.start) + text.len();
3905        let caret = if core.is_empty() || pos <= lead {
3906            open_at + pos
3907        } else if pos >= lead + core.len() {
3908            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
3909        } else {
3910            open_at + lead + open.len() + (pos - lead)
3911        };
3912        Some(MarkEdgeFix {
3913            kind,
3914            probe: content.start,
3915            start: open_at,
3916            end: close_at + text.len() - (end - start),
3917            text: respelt,
3918            caret,
3919            // The marks in force here, resolved against any armed sticky delta —
3920            // what the writer is typing in, and so what has to still be true on
3921            // the far side of the delimiter the caret just stepped over.
3922            want: chain
3923                .iter()
3924                .filter(|(_, s, _)| start < s.end)
3925                .map(|(k, _, _)| *k)
3926                .collect::<InlineMarks>()
3927                .xor(self.pending_here()),
3928        })
3929    }
3930
3931    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
3932    /// did break the mark. Whether whitespace at a delimiter is fatal is the
3933    /// format's business, not leaf's: `**bold **` is no longer strong, while
3934    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
3935    /// don't care either. Asking the parser afterwards settles it for every kind
3936    /// and format at once, and costs a re-spelling only where one is due.
3937    ///
3938    /// The repair rides along with the edit that caused it — one undo step puts
3939    /// back what the writer typed, not a delimiter shuffle they never saw.
3940    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
3941        if fix.end > self.source.len() {
3942            return;
3943        }
3944        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
3945            return; // still a mark: these delimiters don't mind the whitespace
3946        }
3947        let resumed = self.last_edit_kind;
3948        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
3949            return;
3950        }
3951        let _ = self.editor.coalesce_last_undo();
3952        // The keystroke owns the undo step, so the run of typing it belongs to
3953        // keeps coalescing over the repair rather than breaking in two here.
3954        self.last_edit_kind = resumed;
3955        self.caret = fix.caret.min(self.source.len());
3956        self.anchor = None;
3957        self.goal_col = None;
3958        self.rearm(fix.want);
3959        self.clamp_caret();
3960        self.record_caret();
3961    }
3962
3963    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
3964    /// writer is typing in, carried across an edit that moved the caret out of
3965    /// the run holding them. Arms nothing when the caret already stands in
3966    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
3967    /// a clean delta here (see [`toggle`](Self::toggle)).
3968    fn rearm(&mut self, want: InlineMarks) {
3969        let here: InlineMarks = self
3970            .marks_at(self.caret)
3971            .into_iter()
3972            .map(|(k, _)| k)
3973            .collect();
3974        self.pending_marks = want.xor(here);
3975        self.pending_at = Some(self.caret);
3976    }
3977
3978    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
3979    /// which backslash-escapes any character that would otherwise open markup in
3980    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
3981    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
3982    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
3983    /// a collapsed point — a selection is deleted by the caller first, since
3984    /// `insert_literal` inserts rather than replaces.
3985    fn insert_literal_at(
3986        &mut self,
3987        at: usize,
3988        text: &str,
3989        kind: EditKind,
3990        force_coalesce: bool,
3991    ) -> bool {
3992        // The read-only gate: this door goes to twig directly, not through
3993        // `splice_exact`, so it guards itself — see the field.
3994        if self.read_only {
3995            return false;
3996        }
3997        // `force_coalesce` folds this into the immediately preceding edit (the
3998        // selection-delete of an overwrite) so the pair is one undo step; else it
3999        // coalesces only when it continues a run of the same-kind typing.
4000        let coalesce =
4001            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4002        // The mark-edge rule holds for typed text however it is spelled — see
4003        // `splice`. Only an insert twig passed through unchanged can use it,
4004        // since a fix is measured in the bytes that actually land, and an escape
4005        // adds bytes this couldn't have counted.
4006        let fix = self.mark_edge_fix(at, at, text);
4007        self.record_caret();
4008        match self.editor.insert_literal(at, text) {
4009            Ok(change) => {
4010                if coalesce {
4011                    let _ = self.editor.coalesce_last_undo();
4012                }
4013                self.last_edit_kind = Some(kind);
4014                self.refresh();
4015                self.caret = change.new.end;
4016                self.anchor = None;
4017                self.goal_col = None;
4018                self.clear_pending();
4019                self.dirty = self.source != self.clean_source;
4020                self.status = None;
4021                self.record_caret();
4022                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4023                    self.repair_mark_edges(fix);
4024                }
4025                true
4026            }
4027            Err(e) => {
4028                self.status = Some(format!("edit: {e}"));
4029                false
4030            }
4031        }
4032    }
4033
4034    /// After a structural list edit (a new item, a nest/unnest), renumber the
4035    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4036    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4037    /// renumber as its own edit; fold it into the edit that triggered it so the
4038    /// two undo as one, and only when it actually changed the source (a no-op or
4039    /// a caret outside any ordered list must not coalesce the real edit into the
4040    /// step before it).
4041    fn renumber_here(&mut self) {
4042        self.renumber_at(self.caret);
4043    }
4044
4045    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4046    /// caret — for an edit that leaves the caret one past the item it just wrote,
4047    /// where twig resolves no list to renumber.
4048    fn renumber_at(&mut self, off: usize) {
4049        // The read-only gate — this door reaches twig without the splice.
4050        if self.read_only {
4051            return;
4052        }
4053        let before = self.source.clone();
4054        if self.editor.renumber_ordered_lists(off).is_err() {
4055            return; // not inside an ordered list — nothing to renumber
4056        }
4057        self.refresh();
4058        if self.source != before {
4059            let _ = self.editor.coalesce_last_undo();
4060            self.dirty = self.source != self.clean_source;
4061            self.clamp_caret();
4062            self.record_caret();
4063        }
4064    }
4065
4066    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4067    /// sub-item written directly beneath a text line reparses that text as a
4068    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
4069    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4070    /// bullets can't underline anything, so swap the dash for a `*`: the item
4071    /// stays an empty nested bullet, the parent stays prose, and the source
4072    /// round-trips instead of hiding a heading the user never asked for. Folded
4073    /// into the triggering edit's undo step, the way renumbering is.
4074    ///
4075    /// Gated on the collapse having actually happened (the swapped dash was
4076    /// swallowed into a `heading`), so a real setext heading the author wrote —
4077    /// or a `- x` with content, which can't underline anything — is never
4078    /// touched. This has to live in the *edit*, not the renderer: leaving the
4079    /// hazardous bytes on disk and only painting over them would ship a file
4080    /// every other CommonMark tool reads as a heading.
4081    ///
4082    /// This one keeps its own byte scan, and has to: the hazard is precisely
4083    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4084    /// which asks twig which lines open an item — reports nothing here. There is
4085    /// no node to ask about. It is also the last Markdown spelling leaf writes on
4086    /// purpose rather than for want of an answer; once twig spells continuations
4087    /// itself, avoiding the trap becomes twig's, and this goes.
4088    ///
4089    /// [`list_marker_on_line`]: Self::list_marker_on_line
4090    fn avoid_setext_collapse(&mut self) {
4091        let caret = self.caret.min(self.source.len());
4092        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4093        let bytes = self.source.as_bytes();
4094        let mut dash = line_start;
4095        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4096            dash += 1;
4097        }
4098        // A dash bullet is the only marker that doubles as a setext underline.
4099        if bytes.get(dash) != Some(&b'-') {
4100            return;
4101        }
4102        // Only an *empty* item is a bare underline; `- x` carries content and
4103        // can't fold the line above into a heading.
4104        let line_end = self.source[dash..]
4105            .find('\n')
4106            .map_or(self.source.len(), |i| dash + i);
4107        if !self.source[dash + 1..line_end].trim().is_empty() {
4108            return;
4109        }
4110        // The tell: that dash was swallowed into a `heading`. A properly nested
4111        // empty item sits under a `list_item`, with no heading in reach. Probe
4112        // the dash byte itself (well inside the heading), not the caret, whose
4113        // end-of-line offset can fall on the half-open span boundary.
4114        let collapsed = self
4115            .editor
4116            .ancestors_at(dash)
4117            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4118            .unwrap_or(false);
4119        if !collapsed {
4120            return;
4121        }
4122        let caret = self.caret;
4123        if self.splice(dash, dash + 1, "*", EditKind::Other) {
4124            // Same width, so the caret keeps its column; fold into the edit that
4125            // triggered this so Tab stays one undo step.
4126            let _ = self.editor.coalesce_last_undo();
4127            self.caret = caret.min(self.source.len());
4128            self.clamp_caret();
4129            self.record_caret();
4130        }
4131    }
4132
4133    fn snapshot(&self) -> CaretState {
4134        CaretState {
4135            caret: self.caret,
4136            anchor: self.anchor,
4137        }
4138    }
4139
4140    /// Hand twig the current caret and selection as the blob for the live
4141    /// document state. Called before an edit — so the step twig retires records
4142    /// where the caret was, and undo can restore it — and again once the op has
4143    /// placed the caret, so redo restores where the edit left it.
4144    ///
4145    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4146    /// caret through its own history, so coalescing falls out for free (folding
4147    /// two twig steps into one drops the intermediate blob, keeping the run's
4148    /// first) and the parallel stacks that had to march in lockstep — and could
4149    /// silently drift out of it — are gone.
4150    fn record_caret(&mut self) {
4151        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4152    }
4153
4154    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4155    /// the toggled region selected so a second press cleanly reverses it.
4156    pub fn toggle(&mut self, kind: InlineKind) {
4157        // The read-only gate — this door reaches twig without the splice.
4158        if self.read_only {
4159            return;
4160        }
4161        // Ahead of the no-selection branch below: arming a mark for text not yet
4162        // typed is a promise `insert` cannot keep in a format with no delimiters
4163        // to spell it with. Per *kind*, not per format — Markdown spells five
4164        // of the eight marks (highlight among them, under the `highlight`
4165        // extension leaf parses with), djot all eight, HTML seven.
4166        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4167            return;
4168        }
4169        let Some((s, e)) = self.selection() else {
4170            // No selection: arm the mark for the next text typed here, the way a
4171            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4172            // in the flow of typing without ever selecting anything — the delta
4173            // is realised onto the freshly typed text by `insert`. A fresh caret
4174            // position starts the delta over from the marks actually in force.
4175            if self.pending_at != Some(self.caret) {
4176                self.pending_marks = InlineMarks::empty();
4177                self.pending_at = Some(self.caret);
4178            }
4179            self.pending_marks.flip(kind);
4180            self.status = None;
4181            return;
4182        };
4183        // Whitespace at the edge of a selection is not part of what was chosen —
4184        // a double-click takes the space after the word with it — and a mark
4185        // cannot close against one anyway: `**word **` is four literal asterisks
4186        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4187        let picked = &self.source[s..e];
4188        let (s, e) = (
4189            s + (picked.len() - picked.trim_start().len()),
4190            e - (picked.len() - picked.trim_end().len()),
4191        );
4192        if s >= e {
4193            self.status = Some(format!("{kind:?}: nothing selected to mark"));
4194            return;
4195        }
4196        // Styling a selection is a one-shot act, not a sticky mode.
4197        self.clear_pending();
4198        self.record_caret();
4199        match self.editor.toggle_inline(s, e, kind) {
4200            Ok(change) => {
4201                self.last_edit_kind = None; // structural edit is its own undo step
4202                self.refresh();
4203                self.anchor = Some(change.new.start);
4204                self.caret = change.new.end;
4205                self.dirty = self.source != self.clean_source;
4206                self.status = None;
4207                self.record_caret();
4208            }
4209            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4210        }
4211    }
4212
4213    /// Whether the caret stands in a highlight — what a frontend asks to enable
4214    /// or disable its highlight-colour controls, the way
4215    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4216    ///
4217    /// A fact about the *caret*, and the other half of
4218    /// [`Capabilities::mark_color`], which is the fact about the format. A
4219    /// frontend needs both: djot spells a highlight and no colour for it, so a
4220    /// caret standing in `{=word=}` answers `true` here and still has no palette
4221    /// to offer.
4222    ///
4223    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4224    /// the palette appears exactly where the Highlight button is lit — with one
4225    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4226    /// into lights the button and answers `false` here, because there is no node
4227    /// to colour until the text exists.
4228    pub fn caret_in_mark(&mut self) -> bool {
4229        self.mark_offset().is_some()
4230    }
4231
4232    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4233    /// standing in the highlight the gesture means — or `None` when neither end
4234    /// of what is selected is in one.
4235    ///
4236    /// The caret first, and the selection's *start* after it, because of what
4237    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4238    /// whole, with the caret at its far edge, one past the closing `==` and so
4239    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4240    /// and colour it — the two presses a coloured highlight is made of — would
4241    /// otherwise refuse on the second, having just written the highlight the
4242    /// author is pointing at.
4243    fn mark_offset(&mut self) -> Option<usize> {
4244        let in_mark = |d: &mut Self, off: usize| {
4245            d.marks_at(off)
4246                .into_iter()
4247                .any(|(k, _)| k == InlineKind::Mark)
4248                .then_some(off)
4249        };
4250        let caret = self.caret.min(self.source.len());
4251        in_mark(self, caret).or_else(|| {
4252            let start = self.selection()?.0;
4253            in_mark(self, start)
4254        })
4255    }
4256
4257    /// The colour of the highlight at the caret — `None` both when the caret is
4258    /// in no highlight and when the highlight it is in names no colour, which
4259    /// are the same answer to "which swatch is lit".
4260    ///
4261    /// The innermost mark, by span, for the same reason
4262    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4263    /// what the caret is *in* is the deepest node containing it. A `data-color`
4264    /// naming a colour this build has no variant for reads as `None` — the
4265    /// renderer already draws that as a plain highlight rather than guessing,
4266    /// and the toolbar agrees with the renderer.
4267    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4268        let at = self.mark_offset()?;
4269        self.mark_color_at(at)
4270    }
4271
4272    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4273    /// the innermost `mark` covering it, and the colour it names.
4274    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4275        self.nodes()
4276            .into_iter()
4277            .filter(|n| n.kind == Kind::Mark)
4278            .filter(|n| n.span.start <= off && off < n.span.end)
4279            .min_by_key(|n| n.span.end - n.span.start)
4280            .and_then(|n| MarkColor::from_attrs(&n.attrs))
4281    }
4282
4283    /// Colour the highlight at the caret, or clear its colour with `None` — the
4284    /// palette behind a toolbar's Highlight button.
4285    ///
4286    /// Markdown only, and the one gesture whose availability is a fact about the
4287    /// *parse extensions* rather than about the format alone: the colour is
4288    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4289    /// records as the mark's `data-color`, and only an editor parsing with
4290    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4291    /// document) reads it back that way. Djot spells the highlight and no colour
4292    /// for it, so this refuses there — see [`Capabilities::mark_color`].
4293    ///
4294    /// **A colour is a property of a highlight that already exists.** There is
4295    /// no "highlight this in red" here, because that is two splices and would be
4296    /// two undo steps under one press; a frontend that wants it calls
4297    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4298    /// order the two buttons already sit in. With no highlight at the caret this
4299    /// says so in the status line and writes nothing.
4300    ///
4301    /// The caret keeps its place in the *text*: the splice is entirely in the
4302    /// prefix between the opening `==` and the first word, so an offset past it
4303    /// rides the emoji's width, and one standing on the prefix itself lands
4304    /// where the prefix now ends.
4305    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4306        // The read-only gate — this door reaches twig without the splice.
4307        if self.read_only {
4308            return;
4309        }
4310        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4311            return;
4312        }
4313        let Some(at) = self.mark_offset() else {
4314            self.status = Some("highlight colour: no highlight at the caret".into());
4315            return;
4316        };
4317        // Clearing a colour a highlight hasn't got is twig's one *successful*
4318        // no-op, and the `Change` it hands back then describes whatever edit came
4319        // before it — a stale span that would drag the caret somewhere it never
4320        // was. Answer it here, where the question is cheap, rather than trusting
4321        // a change that isn't one.
4322        if color.is_none() && self.mark_color_at(at).is_none() {
4323            self.status = None;
4324            return;
4325        }
4326        self.record_caret();
4327        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4328            Ok(change) => {
4329                // Re-anchored from the offsets as they were, *before* `refresh`
4330                // sees the new bytes: the caret it clamps is one standing inside
4331                // a prefix that didn't exist a moment ago, and walking it back to
4332                // a char boundary of the emoji loses the place this is restoring.
4333                let caret = reanchor(self.caret, &change);
4334                let anchor = self.anchor.map(|a| reanchor(a, &change));
4335                self.last_edit_kind = None; // structural edit is its own undo step
4336                self.refresh();
4337                self.caret = caret;
4338                self.anchor = anchor;
4339                self.dirty = self.source != self.clean_source;
4340                self.status = None;
4341                self.clamp_caret();
4342                self.record_caret();
4343            }
4344            Err(e) => self.status = Some(format!("highlight colour: {e}")),
4345        }
4346    }
4347
4348    /// One press of a colour swatch: colour the highlight at the caret, or —
4349    /// over a selection that isn't highlighted yet — highlight it and colour it,
4350    /// as **one** undo step.
4351    ///
4352    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4353    /// one splice; this is the compound every toolbar actually presses, and it
4354    /// lives here rather than in each frontend because the rule it encodes —
4355    /// what a swatch means when there is no highlight under it yet — is one
4356    /// answer, not one per frontend. The two splices are folded into a single
4357    /// history step, so the press that made a red highlight is taken back by a
4358    /// single undo rather than leaving an uncoloured one behind.
4359    ///
4360    /// `None` clears the colour, and over an unhighlighted selection means
4361    /// simply "highlight this" — the same thing the Highlight button does.
4362    /// A bare caret in no highlight is left alone with a status line, because
4363    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4364    /// colour cannot be armed with it.
4365    pub fn highlight(&mut self, color: Option<MarkColor>) {
4366        if self.caret_in_mark() || self.selection().is_none() {
4367            self.set_mark_color(color);
4368            return;
4369        }
4370        self.toggle(InlineKind::Mark);
4371        // The format may not spell a highlight at all (`toggle` said so), and
4372        // there is nothing to colour if it doesn't.
4373        if self.status.is_some() {
4374            return;
4375        }
4376        let before = self.revision;
4377        self.set_mark_color(color);
4378        // Only fold when the colour really spliced. `highlight(None)` over a
4379        // fresh highlight is a no-op by design, and coalescing there would eat
4380        // the *previous* edit into the toggle instead.
4381        if self.revision != before {
4382            let _ = self.editor.coalesce_last_undo();
4383        }
4384    }
4385
4386    // ── the presentation vocabulary ─────────────────────────────────────────
4387    //
4388    // Six gestures and five queries over twig's two attribute ops. Each gesture
4389    // edits **one key and keeps the rest**: it reads the node's attributes,
4390    // removes its own key (and, for alignment, its own tokens out of `class`),
4391    // adds the new value or nothing, and passes the list back whole — twig's
4392    // contract is replace-not-merge, so the read is the caller's job. A
4393    // paragraph that came in as `class="lead center" id="intro"
4394    // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4395    // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4396    // touched, which is what lets a document from elsewhere pass through the
4397    // editor unharmed.
4398    //
4399    // Clearing is the same gesture with `None`: the key goes, and an empty list
4400    // at the end unwraps the span or the Markdown div, which twig does.
4401
4402    /// Set — or with `None` clear — the alignment of the block the caret is in.
4403    ///
4404    /// A block property, so the gesture is `set_block_attrs` on the caret's
4405    /// block **whatever is selected**: a line is a block's, and "centre this"
4406    /// with three words selected means the paragraph, not the words. The
4407    /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4408    /// same `class` are kept.
4409    ///
4410    /// In Markdown the attributes live on a `<div>` around the block — twig has
4411    /// no paragraph attribute syntax to write — and this reads them back off
4412    /// that div when the block is its sole child, so a second press rewrites
4413    /// the div rather than nesting a second one.
4414    pub fn set_alignment(&mut self, align: Option<Align>) {
4415        let attrs = self.block_attrs_at_caret();
4416        if align.is_none()
4417            && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4418        {
4419            return;
4420        }
4421        let attrs = with_class_token(
4422            &attrs,
4423            |t| Align::from_token(t).is_some(),
4424            align.map(Align::name),
4425        );
4426        self.write_block_attrs("alignment", attrs);
4427    }
4428
4429    /// Set — or with `None` clear — the line spacing of the block the caret is
4430    /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4431    /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4432    /// three names or an exact ratio, written in its canonical spelling.
4433    pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4434        let attrs = self.block_attrs_at_caret();
4435        if spacing.is_none()
4436            && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4437                LineHeight::from_attrs(a).is_some()
4438            })
4439        {
4440            return;
4441        }
4442        let spelling = spacing.map(LineHeight::name);
4443        let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4444        self.write_block_attrs("line spacing", attrs);
4445    }
4446
4447    /// Set — or with `None` clear — the size of the selected run, or of the
4448    /// caret's whole block when nothing is selected.
4449    ///
4450    /// Size, face and colour are the *run's*, and the block's when no run is
4451    /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4452    /// the range in an attributed span or re-styles the span it already lies in
4453    /// (never nesting a second, and unwrapping it when the last key goes). With
4454    /// no selection it is `set_block_attrs` on the caret's block, so that "make
4455    /// this paragraph larger" is a click with the caret in it rather than a
4456    /// select-all first.
4457    ///
4458    /// The walker reads the key at both levels with the nearer winning, so a
4459    /// span's `data-size` inside a block carrying its own applies to the span.
4460    ///
4461    /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4462    /// what a menu offers first because a step reads as a step up under every
4463    /// theme, or the point size an author asked for, which is exact and is all
4464    /// it is. Either is written in its canonical spelling, so a size set twice
4465    /// from the same field writes the same bytes both times.
4466    pub fn set_font_size(&mut self, size: Option<FontSize>) {
4467        let spelling = size.map(FontSize::name);
4468        self.set_run_attr("size", "data-size", spelling.as_deref());
4469    }
4470
4471    /// Set — or with `None` clear — the face of the selected run, or of the
4472    /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4473    /// [`FontFace`] under `data-font` — one of the four generics, or the family
4474    /// the author named, which the frontends resolve through the platform's
4475    /// font registry and fall back to the body face without.
4476    pub fn set_font_family(&mut self, font: Option<FontFace>) {
4477        let spelling = font.as_ref().map(FontFace::name);
4478        self.set_run_attr("font", "data-font", spelling.as_deref());
4479    }
4480
4481    /// Set — or with `None` clear — the *text* colour of the selected run, or of
4482    /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
4483    /// with [`TextColor`] under `data-color` — one of the seven names, whose
4484    /// two inks the theme owns, or the triple the author picked, which is
4485    /// painted as written in both appearances.
4486    ///
4487    /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
4488    /// writes, and a different thing: that one colours a highlight's
4489    /// *background* and rides the `mark` node twig owns the spelling of, this
4490    /// one colours the letters and rides an attributed span. The two never
4491    /// collide, because a `mark` is a `mark` and a span is a span — and they
4492    /// share a vocabulary on purpose, so that a frontend with a red for a
4493    /// highlight has a red for text and both are *that* red.
4494    pub fn set_text_color(&mut self, color: Option<TextColor>) {
4495        let spelling = color.map(TextColor::name);
4496        self.set_run_attr("text colour", "data-color", spelling.as_deref());
4497    }
4498
4499    /// Insert a page break at the caret — `::page-break`, a leaf directive with
4500    /// no label and no attributes, which twig spells in every format that names
4501    /// a leaf container (Markdown under the `directives` extension
4502    /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
4503    /// page-break` fence).
4504    ///
4505    /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
4506    /// places a rule, and for the same reason: a directive is a block, so twig
4507    /// alone has nowhere to put one mid-paragraph and lands it after the
4508    /// caret's whole block. A bare paragraph is therefore parted at the caret
4509    /// first and the break aimed at the *first* half. See that method for the
4510    /// whole of the rule, including why a code block, a list item, a table and
4511    /// a setext heading are left unsplit.
4512    ///
4513    /// The frontends that paginate read the row's
4514    /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
4515    /// the ones that do not draw the `⧉ page-break` placeholder every leaf
4516    /// directive gets.
4517    pub fn insert_page_break(&mut self) {
4518        if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
4519            return;
4520        }
4521        self.caret = self.skip_trailing_close_delims(self.caret);
4522        // A selection is replaced by the break, as a rule replaces one.
4523        if let Some((s, e)) = self.selection() {
4524            self.splice(s, e, "", EditKind::Other);
4525        }
4526        self.anchor = None;
4527        self.record_caret();
4528        let at = self.caret;
4529        if self.caret_parts_bare_paragraph() {
4530            // A failure here is not fatal: the break still lands after the
4531            // block, which is what this call was trying to improve on.
4532            let _ = self.editor.split_block(at);
4533        }
4534        match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
4535            Ok(change) => {
4536                self.last_edit_kind = None;
4537                self.refresh();
4538                self.anchor = None;
4539                self.caret = change.new.end;
4540                self.dirty = self.source != self.clean_source;
4541                self.status = None;
4542                self.clamp_caret();
4543                self.record_caret();
4544            }
4545            Err(e) => self.status = Some(format!("page break: {e}")),
4546        }
4547    }
4548
4549    /// The alignment in force at the caret, or `None` for the theme's default —
4550    /// which swatch of an alignment control is lit.
4551    ///
4552    /// Read off the nearest node that names one: the block the caret is in, and
4553    /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
4554    /// shape, one property along.
4555    pub fn alignment_at_caret(&mut self) -> Option<Align> {
4556        self.presentation_chain()
4557            .iter()
4558            .find_map(|attrs| Align::from_attrs(attrs))
4559    }
4560
4561    /// The line spacing in force at the caret, or `None` for the theme's own.
4562    /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
4563    pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
4564        self.presentation_chain()
4565            .iter()
4566            .find_map(|attrs| LineHeight::from_attrs(attrs))
4567    }
4568
4569    /// The size in force at the caret, or `None` for the theme's own — the
4570    /// entry a size menu shows ticked.
4571    ///
4572    /// Run-level, so the chain starts one node deeper: the attributed span the
4573    /// caret stands in, then its block, then the `div`s around it. The nearest
4574    /// wins, which is the rule the walker draws by.
4575    ///
4576    /// A name or a value, whichever the nearest node wrote. A `data-size` the
4577    /// grammar does not cover — a `huge` from elsewhere — is not a size this
4578    /// can answer, so the answer is `None` and the menu ticks *Default*, the
4579    /// same thing it did before the vocabulary opened.
4580    pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
4581        self.presentation_chain()
4582            .iter()
4583            .find_map(|attrs| FontSize::from_attrs(attrs))
4584    }
4585
4586    /// The face in force at the caret, or `None` for the theme's body face.
4587    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
4588    pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
4589        self.presentation_chain()
4590            .iter()
4591            .find_map(|attrs| FontFace::from_attrs(attrs))
4592    }
4593
4594    /// The *text* colour in force at the caret, or `None` for the theme's.
4595    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
4596    /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
4597    /// highlight's background off a `mark`, and a `mark` is never in this chain.
4598    pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
4599        self.presentation_chain()
4600            .iter()
4601            .find_map(|attrs| TextColor::from_attrs(attrs))
4602    }
4603
4604    /// The selection-or-caret half of the three run-level gestures: a span over
4605    /// a real selection, the caret's block over none.
4606    fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
4607        match self.selection() {
4608            Some((start, end)) => {
4609                let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
4610                self.write_run_attrs(what, start, end, attrs);
4611            }
4612            None => {
4613                let own = self.block_attrs_at_caret();
4614                if value.is_none()
4615                    && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
4616                {
4617                    return;
4618                }
4619                let attrs = with_attr(&own, key, value);
4620                self.write_block_attrs(what, attrs);
4621            }
4622        }
4623    }
4624
4625    /// A clear this gesture cannot carry out, said out loud instead of written:
4626    /// the node it rewrites — the caret's block, or the `<div>` around it that
4627    /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
4628    /// — does not name the property at all, and a `div` further out does.
4629    ///
4630    /// Handing twig the block's attributes with the key already absent changes
4631    /// no byte, and the query goes on answering `Some` off the div: the menu
4632    /// entry the author pressed stays unticked, and nothing says why. Twig's
4633    /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
4634    /// write on a node it is not rewriting — the honest answer is the status
4635    /// line, in the voice the other refusals use.
4636    ///
4637    /// `names` is the property's own reading of an attribute list, because
4638    /// alignment lives in a `class` token rather than a key of its own. Spans
4639    /// are skipped: one inside the block is not what a *block* gesture writes
4640    /// either, but neither is it "the div around the block", and the run-level
4641    /// gestures reach it through a selection.
4642    fn refuse_clear_from_div(
4643        &mut self,
4644        what: &str,
4645        own: &Attrs,
4646        names: impl Fn(&Attrs) -> bool,
4647    ) -> bool {
4648        if names(own) {
4649            return false;
4650        }
4651        let caret = self.caret.min(self.source.len());
4652        if !self
4653            .attr_chain_at(caret)
4654            .iter()
4655            .any(|(span, attrs)| !span && names(attrs))
4656        {
4657            return false;
4658        }
4659        self.status = Some(format!("{what}: set on the div around the block"));
4660        true
4661    }
4662
4663    /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
4664    /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
4665    /// has.
4666    ///
4667    /// **The caret keeps its place in the text, not its byte offset.** How a
4668    /// format spells a block's attributes is markup written *around* the block
4669    /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
4670    /// it in Markdown, a longer opening tag in HTML — and every one of those
4671    /// grows or shrinks above the author's own bytes. Where twig's change
4672    /// rewrites the block whole (Markdown's div is spliced as one region, block
4673    /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
4674    /// and parks the caret at the end of the splice, past the closing `</div>`:
4675    /// the caret is then in no block at all, so a second press of the same menu
4676    /// answers "no block at the caret" and the toolbar's queries read nothing.
4677    /// [`reanchor_in_block`] is what carries it across instead — the block's
4678    /// content span before and after, which is the one thing the respelling
4679    /// leaves alone.
4680    ///
4681    /// Read *before* the splice and applied *after* `refresh`, because both
4682    /// halves of that mapping are facts about a tree twig is between: the
4683    /// block's old bytes are gone once the edit lands, and its new ones are not
4684    /// in `self.source` until the refresh puts them there.
4685    fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
4686        if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
4687            return;
4688        }
4689        // A blank line has no block to carry an attribute, and twig answers
4690        // `NotFound` there — say so in leaf's own words instead.
4691        let Some(at) = self.block_offset_for_caret() else {
4692            self.status = Some(format!("{what}: no block at the caret"));
4693            return;
4694        };
4695        self.record_caret();
4696        let pairs = attr_pairs(&attrs);
4697        let was = self.block_content_at(at);
4698        let text = was.clone().map(|s| self.source[s].to_string());
4699        match self.editor.set_block_attrs(at, &pairs) {
4700            Ok(change) => {
4701                let (caret, anchor) = (self.caret, self.anchor);
4702                self.last_edit_kind = None; // structural edit is its own undo step
4703                self.refresh();
4704                let now = self.block_content_in(&change.new, text.as_deref());
4705                // A block the two halves cannot both name — a code block, a
4706                // caret in a list's marker — takes the plain arithmetic, which
4707                // is what it had before.
4708                let block = was.as_ref().zip(now.as_ref());
4709                self.caret = reanchor_in_block(caret, &change, block);
4710                self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
4711                self.dirty = self.source != self.clean_source;
4712                self.status = None;
4713                // The clamp reads the caret floor off the map, and this edit
4714                // can move the floor: taking the `{…}` line off a djot
4715                // document's first block moves the first rendered offset to 0,
4716                // and a floor read from the old map stood the caret past the
4717                // block's text. So the map is this revision's before the clamp
4718                // — see `open_paragraph_at_block_edge`.
4719                self.rebuild_map();
4720                self.clamp_caret();
4721                self.record_caret();
4722            }
4723            Err(e) => self.status = Some(format!("{what}: {e}")),
4724        }
4725    }
4726
4727    /// The content span of the innermost paragraph or heading covering `off` —
4728    /// the author's own bytes, without the `# ` or the `<p>` that spells the
4729    /// block around them.
4730    ///
4731    /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
4732    /// reads, so that what a gesture re-anchors by is the block it wrote to.
4733    fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
4734        self.nodes()
4735            .into_iter()
4736            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4737            .filter(|n| n.span.start <= off && off <= n.span.end)
4738            .min_by_key(|n| n.span.end - n.span.start)
4739            .map(|n| n.content_span.unwrap_or(n.span))
4740    }
4741
4742    /// [`block_content_at`](Self::block_content_at)'s other half: the content
4743    /// span of the block `region` holds now, found by the bytes it held before.
4744    ///
4745    /// Matched on the text rather than taken as the first block in the region,
4746    /// because a rewritten region is markup and all — `<div class="center">`
4747    /// carries words of its own — and because the block this gesture moved is
4748    /// the one whose content the respelling did not touch. `None` where the
4749    /// region holds no block at all, which is djot's every case: the `{…}` line
4750    /// is spliced above the block and the block itself never moves through the
4751    /// change at all, only past it.
4752    fn block_content_in(
4753        &mut self,
4754        region: &Range<usize>,
4755        text: Option<&str>,
4756    ) -> Option<Range<usize>> {
4757        let text = text?;
4758        let spans: Vec<Range<usize>> = self
4759            .nodes()
4760            .into_iter()
4761            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4762            .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
4763            .map(|n| n.content_span.unwrap_or(n.span))
4764            .collect();
4765        spans
4766            .into_iter()
4767            .find(|s| self.source.get(s.clone()) == Some(text))
4768    }
4769
4770    /// Hand `attrs` to twig as the attribute set of the span over `[start,
4771    /// end)` — wrapping one, or re-styling the one the range already lies in,
4772    /// or unwrapping it when `attrs` is empty.
4773    ///
4774    /// What the splice leaves selected is the span's **content** — the author's
4775    /// words — and not the whole of `change.new`, which is markup and all:
4776    /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
4777    /// selection reaching past the node's own span lies in no span at all, so a
4778    /// second press of the menu would nest a fresh one instead of re-styling
4779    /// the one just written.
4780    fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
4781        if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
4782            return;
4783        }
4784        self.record_caret();
4785        let pairs = attr_pairs(&attrs);
4786        match self.editor.wrap_range_attrs(start, end, &pairs) {
4787            Ok(change) => {
4788                self.last_edit_kind = None;
4789                self.refresh();
4790                let content = self.span_content_in(&change.new);
4791                self.anchor = Some(content.start);
4792                self.caret = content.end;
4793                self.dirty = self.source != self.clean_source;
4794                self.status = None;
4795                self.clamp_caret();
4796                self.record_caret();
4797            }
4798            Err(e) => self.status = Some(format!("{what}: {e}")),
4799        }
4800    }
4801
4802    /// The content range of the attributed span `spliced` now holds — the
4803    /// outermost one inside it, since that is the one just written — or
4804    /// `spliced` itself where the splice left no span, which is what an unwrap
4805    /// leaves behind.
4806    fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
4807        self.nodes()
4808            .into_iter()
4809            .filter(wysiwyg::is_run_span)
4810            .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
4811            .max_by_key(|n| n.span.end - n.span.start)
4812            .and_then(|n| n.content_span)
4813            .unwrap_or_else(|| spliced.clone())
4814    }
4815
4816    /// The attribute set `set_block_attrs` is about to **replace** at the caret
4817    /// — which is the block's own, except in Markdown, where twig writes a
4818    /// block's attributes onto a `<div>` around it and rewrites that div when
4819    /// the block is its sole child. Reading the paragraph there would hand back
4820    /// an empty list and quietly drop everything the div said.
4821    ///
4822    /// Empty when the caret is in no block at all, which is the same list a
4823    /// block carrying no attributes gives — and the right one either way, since
4824    /// the gesture then refuses on its own.
4825    fn block_attrs_at_caret(&mut self) -> Attrs {
4826        let Some(off) = self.block_offset_for_caret() else {
4827            return Vec::new();
4828        };
4829        let nodes = self.nodes();
4830        let Some(block) = nodes
4831            .iter()
4832            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4833            .filter(|n| n.span.start <= off && off <= n.span.end)
4834            .min_by_key(|n| n.span.end - n.span.start)
4835        else {
4836            return Vec::new();
4837        };
4838        if self.format == Format::Markdown
4839            && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
4840            && wysiwyg::element_tag(parent) == Some("div")
4841            && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
4842        {
4843            return parent.attrs.clone();
4844        }
4845        block.attrs.clone()
4846    }
4847
4848    /// The attribute set `wrap_range_attrs` is about to **replace** over
4849    /// `[start, end)` — the innermost attributed span the range lies inside,
4850    /// which twig re-styles rather than nesting a second one in. Empty when the
4851    /// range lies in no span, where the gesture mints a fresh one.
4852    fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
4853        self.nodes()
4854            .into_iter()
4855            .filter(wysiwyg::is_run_span)
4856            .filter(|n| n.span.start <= start && end <= n.span.end)
4857            .min_by_key(|n| n.span.end - n.span.start)
4858            .map(|n| n.attrs)
4859            .unwrap_or_default()
4860    }
4861
4862    /// The attribute lists that bear on a presentation query, **nearest first**:
4863    /// the attributed spans the caret stands in (innermost first), then its
4864    /// block, then the `div`s around it. A `find_map` down this is the whole of
4865    /// each query, and the order is the rule the walker draws by.
4866    ///
4867    /// Read at the caret, and at the selection's *start* when the caret stands
4868    /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
4869    /// caret one past the span it just wrote — `toggle`'s convention — so
4870    /// asking the menu which entry that press just ticked must not answer
4871    /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
4872    fn presentation_chain(&mut self) -> Vec<Attrs> {
4873        let caret = self.caret.min(self.source.len());
4874        let mut chain = self.attr_chain_at(caret);
4875        if !chain.iter().any(|(span, _)| *span)
4876            && let Some((start, _)) = self.selection()
4877        {
4878            let alt = self.attr_chain_at(start);
4879            if alt.iter().any(|(span, _)| *span) {
4880                chain = alt;
4881            }
4882        }
4883        chain.into_iter().map(|(_, attrs)| attrs).collect()
4884    }
4885
4886    /// [`presentation_chain`](Self::presentation_chain) at one offset — every
4887    /// node bearing the vocabulary that covers it, innermost first, each paired
4888    /// with whether it is an attributed span (which is what tells the caller
4889    /// its run-level answer came from a run).
4890    ///
4891    /// Sorted by span length, which *is* the nesting order: a span lies inside
4892    /// its block and a block inside its div, so shortest-first is
4893    /// nearest-first without a second tree walk.
4894    fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
4895        let off = off.min(self.source.len());
4896        let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
4897        for n in self.nodes() {
4898            let span = wysiwyg::is_run_span(&n);
4899            let block = matches!(n.kind, Kind::Para | Kind::Heading);
4900            let div = wysiwyg::element_tag(&n) == Some("div");
4901            if !(span || block || div) {
4902                continue;
4903            }
4904            // A span is half-open, the way a mark is: the offset one past it is
4905            // the text after it. A block and a div claim their end too, so a
4906            // caret resting at the end of a line still reads its paragraph.
4907            let inside = if span {
4908                n.span.start <= off && off < n.span.end
4909            } else {
4910                n.span.start <= off && off <= n.span.end
4911            };
4912            if !inside {
4913                continue;
4914            }
4915            hits.push((n.span.end - n.span.start, span, n.attrs));
4916        }
4917        hits.sort_by_key(|(len, _, _)| *len);
4918        hits.into_iter()
4919            .map(|(_, span, attrs)| (span, attrs))
4920            .collect()
4921    }
4922
4923    /// Convert the block at the caret to a heading level or paragraph.
4924    pub fn set_block(&mut self, kind: BlockKind) {
4925        // The read-only gate — this door reaches twig without the splice.
4926        if self.read_only {
4927            return;
4928        }
4929        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
4930            return;
4931        }
4932        self.record_caret();
4933        // A blank line has no node to convert, and twig opens a block there
4934        // rather than declining — so the caret's own offset is the right thing
4935        // to hand it when `block_offset_for_caret` finds nothing.
4936        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
4937        match self.editor.set_block(offset, kind) {
4938            Ok(change) => {
4939                self.last_edit_kind = None;
4940                self.refresh();
4941                // Opening a block on a blank line writes a marker the caret
4942                // belongs *after*; converting an existing one moves nothing.
4943                self.caret = self.caret.max(change.new.end);
4944                self.clamp_caret();
4945                self.anchor = None;
4946                self.dirty = self.source != self.clean_source;
4947                self.status = None;
4948                self.record_caret();
4949            }
4950            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4951        }
4952    }
4953
4954    /// Whether `off` is inside a text block (paragraph, heading, code block…).
4955    fn has_block_at(&mut self, off: usize) -> bool {
4956        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
4957            chain
4958                .iter()
4959                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
4960        })
4961    }
4962
4963    /// The offset to hand twig's `set_block`: the caret when it is already inside
4964    /// a block, otherwise nudged onto the previous character (a caret at a line
4965    /// end sits at the doc level, outside the block). `None` when the caret is on
4966    /// a blank line — a new paragraph with no block node to convert.
4967    fn block_offset_for_caret(&mut self) -> Option<usize> {
4968        let caret = self.caret.min(self.source.len());
4969        if self.has_block_at(caret) {
4970            return Some(caret);
4971        }
4972        // Nudge to the previous character — but never across a newline: that would
4973        // target the previous block, and a blank line genuinely has no block.
4974        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
4975            && ch != '\n'
4976            && self.has_block_at(i)
4977        {
4978            return Some(i);
4979        }
4980        None
4981    }
4982
4983    /// The heading level of the text block at the caret, or `None` when that
4984    /// block is not a heading.
4985    pub fn current_heading_level(&mut self) -> Option<u32> {
4986        let caret = self.caret;
4987        self.nodes()
4988            .into_iter()
4989            .filter(|n| n.kind == Kind::Heading)
4990            .find(|n| n.span.start <= caret && caret <= n.span.end)
4991            .and_then(|n| n.level)
4992    }
4993
4994    /// The inline marks in force at the caret (or over the selection) — what a
4995    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
4996    /// inline counterpart. Cheap enough to call every frame: one twig
4997    /// `ancestors_at` query per caret (two with a selection), each walking root
4998    /// → deepest node at one offset. It never snapshots the tree the way
4999    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5000    /// the only allocation is twig's own small ancestor `Vec`.
5001    ///
5002    /// **A selection reports a mark only when the mark covers *all* of it.**
5003    /// That's what every real toolbar means by an active button — Bold lit over
5004    /// a half-bold selection would claim a press turns bold *off*, when
5005    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5006    /// Whole-coverage is asked as "is the same mark node standing over both the
5007    /// first and the last character?": inline nodes are contiguous, so one node
5008    /// covering both ends covers every byte between them. Two touching runs
5009    /// (`**a****b**`) are two nodes, and correctly light nothing.
5010    ///
5011    /// At a bare caret a mark is active when the caret stands inside the mark's
5012    /// span — `span.start <= caret < span.end`, delimiters included, which is
5013    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5014    /// opening `*` (2) through the last byte of the closing `**` (9) are all
5015    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5016    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5017    /// typing would actually land inside the marked run. The offset one past the
5018    /// mark (10) is the text after it and reports nothing, at the end of the
5019    /// buffer exactly as in the middle.
5020    pub fn active_inline_marks(&mut self) -> InlineMarks {
5021        let Some((start, end)) = self.selection() else {
5022            // The marks actually in force at the caret, flipped by any armed
5023            // sticky delta — so `⌘b` at a bare caret lights the Bold button
5024            // immediately, before a single character is typed.
5025            let base: InlineMarks = self
5026                .marks_at(self.caret)
5027                .into_iter()
5028                .map(|(k, _)| k)
5029                .collect();
5030            return base.xor(self.pending_here());
5031        };
5032        // The selection's *last character*, not its exclusive end: `end` is the
5033        // offset one past the selection, which for a selection ending exactly at
5034        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5035        // entirely bold, but offset 10 is the space after).
5036        let last = prev_boundary(&self.source, end);
5037        let head = self.marks_at(start);
5038        let tail = self.marks_at(last);
5039        head.into_iter()
5040            .filter(|m| tail.contains(m))
5041            .map(|(k, _)| k)
5042            .collect()
5043    }
5044
5045    /// The inline marks whose span covers `off`, each with the id of the node
5046    /// carrying it — the id is what lets a selection tell one mark node from
5047    /// another of the same kind.
5048    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5049        let off = off.min(self.source.len());
5050        self.editor
5051            .ancestors_at(off)
5052            .unwrap_or_default()
5053            .into_iter()
5054            // `span.end` is the offset one *past* the mark, so it isn't in it.
5055            // twig already resolves a boundary to whatever starts there — in
5056            // `**bold** x` offset 8 is the following text, not the strong — but
5057            // when nothing follows, the tie has nobody to break for and the
5058            // chain still ends at the mark. That would make the answer at the
5059            // last offset of the document depend on whether the file happens to
5060            // end in a newline; the rule is `span.start <= off < span.end`, and
5061            // it's the same rule at the end of a buffer as in the middle.
5062            .filter(|m| off < m.span.end)
5063            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5064            .collect()
5065    }
5066
5067    /// Toggle a heading at the caret: if the block is already this heading level,
5068    /// revert it to a paragraph; otherwise convert it to this heading level.
5069    /// This gives the heading commands the same toggle feel as bold/italic/code —
5070    /// re-applying a heading a line already has turns it back into body text.
5071    pub fn toggle_heading(&mut self, level: u32) {
5072        if self.current_heading_level() == Some(level) {
5073            self.set_block(BlockKind::Paragraph);
5074        } else {
5075            self.set_block(BlockKind::Heading(level));
5076        }
5077    }
5078
5079    /// Toggle a block quote around the selection, or around the block at the
5080    /// caret — the toolbar's Quote button.
5081    pub fn toggle_blockquote(&mut self) {
5082        self.toggle_container(BlockContainerKind::BlockQuote);
5083    }
5084
5085    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5086    /// over the block at the caret — one op with the kind as a flag, the way
5087    /// `toggle_heading` takes its level, so a frontend needs no twig type to
5088    /// name the two buttons.
5089    ///
5090    /// Pressing the *other* list's button while in a list converts in place
5091    /// rather than nesting, so the pair reads as one three-state control
5092    /// (bulleted / numbered / neither) rather than two independent wrappers.
5093    pub fn toggle_list(&mut self, ordered: bool) {
5094        self.toggle_container(if ordered {
5095            BlockContainerKind::OrderedList
5096        } else {
5097            BlockContainerKind::BulletList
5098        });
5099    }
5100
5101    // ── Task list items ──────────────────────────────────────────────────────
5102    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5103    // inline content of the item's first paragraph rather than part of its
5104    // marker, so adding or removing one must leave the item's continuation
5105    // indentation alone, and an item inside a quote is found past the quote
5106    // markers. leaf names the gesture and the offset; the spelling is twig's.
5107
5108    /// Whether the list item at the caret carries a checkbox, and which way it
5109    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5110    /// item or no item at all. What a toolbar reads to light its checkbox button.
5111    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5112        self.task_checked_at(self.caret)
5113    }
5114
5115    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5116    /// offset — what a frontend asks before deciding a click landed on a box.
5117    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5118        self.innermost_list_item(offset.min(self.source.len()))?
5119            .checked
5120    }
5121
5122    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5123    /// A no-op with a reported reason when the caret is in no task item — minting
5124    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5125    pub fn toggle_task_checked(&mut self) {
5126        self.toggle_task_at(self.caret);
5127    }
5128
5129    /// Tick or untick the task item covering `offset` — what a *click* on a
5130    /// rendered checkbox is. Separate from the caret form because a click carries
5131    /// its own offset and must not first move the caret there: ticking a box
5132    /// three paragraphs away should not take the cursor with it.
5133    pub fn toggle_task_at(&mut self, offset: usize) {
5134        // The read-only gate — this door reaches twig without the splice.
5135        if self.read_only {
5136            return;
5137        }
5138        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5139            return;
5140        }
5141        let offset = offset.min(self.source.len());
5142        self.record_caret();
5143        match self.editor.toggle_task_checked(offset) {
5144            Ok(_) => self.after_task_edit(),
5145            Err(e) => self.status = Some(format!("task: {e}")),
5146        }
5147    }
5148
5149    /// Give the list item at the caret a checkbox, or take its checkbox away —
5150    /// the gesture that converts between a plain bullet and a task. A new box
5151    /// arrives unticked.
5152    pub fn toggle_task_item(&mut self) {
5153        // The read-only gate — this door reaches twig without the splice.
5154        if self.read_only {
5155            return;
5156        }
5157        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5158            return;
5159        }
5160        let caret = self.caret.min(self.source.len());
5161        self.record_caret();
5162        match self.editor.toggle_task_item(caret) {
5163            Ok(_) => self.after_task_edit(),
5164            Err(e) => self.status = Some(format!("task: {e}")),
5165        }
5166    }
5167
5168    /// Settle after a task gesture. The caret rides its old byte offset and is
5169    /// clamped back in: a box is three or four bytes on the item's first line, so
5170    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5171    /// real stop either way.
5172    fn after_task_edit(&mut self) {
5173        self.last_edit_kind = None;
5174        self.refresh();
5175        self.anchor = None;
5176        self.dirty = self.source != self.clean_source;
5177        self.status = None;
5178        self.clamp_caret();
5179        self.record_caret();
5180    }
5181
5182    // ── Tables ───────────────────────────────────────────────────────────────
5183    // A table is a grid, and twig edits it as one — add/remove/move a row or
5184    // column, set a column's alignment — re-spelling the whole table in a single
5185    // splice. Every gesture is anchored at the caret's cell. leaf just names the
5186    // gesture and re-reads the result; the whole table's numbering, borders, and
5187    // delimiter are twig's to keep straight.
5188
5189    /// Whether the caret is inside a table — what a frontend asks to enable or
5190    /// disable its table controls.
5191    ///
5192    /// An HTML `<table>` still answers `true`: the caret really is in a table,
5193    /// and the reason the grid controls stay dark there is
5194    /// [`Capabilities::table`], which is a fact about the document's format
5195    /// rather than about the caret. A frontend needs both.
5196    pub fn caret_in_table(&mut self) -> bool {
5197        let caret = self.caret.min(self.source.len());
5198        self.editor
5199            .ancestors_at(caret)
5200            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5201            .unwrap_or(false)
5202    }
5203
5204    /// One grid op, guarded and settled — the shared body of the seven below.
5205    ///
5206    /// The guard is why this exists rather than seven copies of the same three
5207    /// lines, and it is the one guard leaf cannot delegate to twig. The table
5208    /// editor is the gesture family that consults no `Syntax` table (it spells a
5209    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5210    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5211    /// grid as a *pipe table* and reports success, swapping the element out for
5212    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5213    /// downstream could tell that from a successful edit — the splice is real,
5214    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5215    /// stopping at the door rather than detecting after the fact. See
5216    /// [`spells_pipe_tables`].
5217    fn table_op(
5218        &mut self,
5219        what: &str,
5220        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5221    ) {
5222        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5223            return;
5224        }
5225        self.record_caret();
5226        let at = self.caret;
5227        let r = op(&mut self.editor, at);
5228        self.apply_table(r, what);
5229    }
5230
5231    /// Insert an empty row below (`below`) or above the caret's row.
5232    pub fn table_insert_row(&mut self, below: bool) {
5233        self.table_op("table row", |e, at| e.table_insert_row(at, below));
5234    }
5235
5236    /// Delete the caret's row (not the header, not the last body row).
5237    pub fn table_delete_row(&mut self) {
5238        self.table_op("table row", |e, at| e.table_delete_row(at));
5239    }
5240
5241    /// Insert an empty column right (`right`) or left of the caret's column.
5242    pub fn table_insert_column(&mut self, right: bool) {
5243        self.table_op("table column", |e, at| e.table_insert_column(at, right));
5244    }
5245
5246    /// Delete the caret's column (unless it is the only one).
5247    pub fn table_delete_column(&mut self) {
5248        self.table_op("table column", |e, at| e.table_delete_column(at));
5249    }
5250
5251    /// Set the caret's column to `alignment`.
5252    pub fn table_set_alignment(&mut self, alignment: Alignment) {
5253        self.table_op("table alignment", |e, at| {
5254            e.table_set_alignment(at, alignment)
5255        });
5256    }
5257
5258    /// Move the caret's row one place down (`down`) or up, within the body rows.
5259    pub fn table_move_row(&mut self, down: bool) {
5260        self.table_op("table row", |e, at| e.table_move_row(at, down));
5261    }
5262
5263    /// Move the caret's column one place right (`right`) or left.
5264    pub fn table_move_column(&mut self, right: bool) {
5265        self.table_op("table column", |e, at| e.table_move_column(at, right));
5266    }
5267
5268    /// Settle the caret and document flags after a table op (or report its
5269    /// error). twig re-spells the whole table, so the caret rides its old byte
5270    /// offset and is clamped back into the rebuilt bytes — near enough to where
5271    /// it was, since the op preserves the cells' content and order around it.
5272    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5273        match result {
5274            Ok(()) => {
5275                self.last_edit_kind = None;
5276                self.refresh();
5277                self.anchor = None;
5278                self.clamp_caret();
5279                self.dirty = self.source != self.clean_source;
5280                self.status = None;
5281                self.record_caret();
5282            }
5283            Err(e) => self.status = Some(format!("{what}: {e}")),
5284        }
5285    }
5286
5287    /// One `toggle_block_container` over the block-level target.
5288    ///
5289    /// leaf says *where*; twig decides everything else — which blocks the range
5290    /// covers, whether that means wrapping, unwrapping, nesting or converting,
5291    /// and how this document's format spells the prefix. The rule that a
5292    /// container only comes off when the range covers every block it holds is
5293    /// what the re-anchoring below is built around.
5294    fn toggle_container(&mut self, kind: BlockContainerKind) {
5295        // The read-only gate — this door reaches twig without the splice.
5296        if self.read_only {
5297            return;
5298        }
5299        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5300            return;
5301        }
5302        let selected = self.selection();
5303        // A blank line holds no block, and twig opens an *empty* container on one
5304        // — since 3.2.0; it used to decline the range with `NotFound`, which is
5305        // why this used to lend it a scratch paragraph to wrap. Worth knowing
5306        // here because the line-for-line caret mapping below cannot describe it:
5307        // opening one under a paragraph writes the blank line the format needs
5308        // above the marker too, so the rewritten region has a line the old one
5309        // didn't, and "the same line, the same distance from its end" lands on
5310        // that new blank instead of in the container.
5311        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5312        // Without a selection the target is the caret's own block, resolved the
5313        // way `set_block` resolves it — a caret at a line end sits at the doc
5314        // level and has to be nudged back onto the block it looks like it's in.
5315        // An empty range is enough: twig widens to the whole lines it touches.
5316        let (start, end) = match selected {
5317            Some(range) => range,
5318            None => {
5319                let off = self.block_offset_for_caret().unwrap_or(self.caret);
5320                (off, off)
5321            }
5322        };
5323        self.record_caret();
5324        match self.editor.toggle_block_container(start, end, kind) {
5325            Ok(change) => {
5326                // Read the caret's place out of the *pre-edit* source, before
5327                // `refresh` swaps that source out from under it.
5328                let place = (selected.is_none() && !opened_empty)
5329                    .then(|| self.caret_line_tail(&change.old));
5330                self.last_edit_kind = None; // structural edit is its own undo step
5331                self.refresh();
5332                match place {
5333                    // Both land the caret at the far end of what twig wrote, and
5334                    // differ only in what they leave selected.
5335                    //
5336                    // From a selection: select what the container now holds, the
5337                    // way `toggle` keeps its marked region selected — and for a
5338                    // stronger reason than symmetry: a container comes *off* only
5339                    // a range covering every block it holds, so a selection left
5340                    // on its old bytes (now short by a prefix per line) would nest
5341                    // on the second press instead of reversing the first.
5342                    //
5343                    // From a blank line: nothing to select, and the end of the
5344                    // region is exactly past the bare `> ` / `- ` twig wrote —
5345                    // the caret standing inside the container that was asked for.
5346                    None => {
5347                        self.anchor = (!opened_empty).then_some(change.new.start);
5348                        self.caret = change.new.end;
5349                    }
5350                    Some(place) => {
5351                        self.anchor = None;
5352                        self.caret = self.line_tail_offset(&change.new, place);
5353                    }
5354                }
5355                self.dirty = self.source != self.clean_source;
5356                self.status = None;
5357                self.clamp_caret();
5358                self.record_caret();
5359            }
5360            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5361        }
5362    }
5363
5364    /// The caret's place inside the region a container toggle is rewriting, in
5365    /// the only terms the rewrite preserves: which of the region's lines it sits
5366    /// on, and how many bytes of that line lie ahead of it.
5367    ///
5368    /// A container's markup goes in at column 0 and never touches what follows
5369    /// on the line, so that pair survives the edit exactly where a byte offset
5370    /// does not — a caret left on its old offset slides back by one prefix per
5371    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
5372    /// `> ` it just asked for.
5373    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
5374        let caret = self.caret.clamp(old.start, old.end);
5375        let line = self.source[old.start..caret].matches('\n').count();
5376        let end = self.source[caret..old.end]
5377            .find('\n')
5378            .map_or(old.end, |i| caret + i);
5379        (line, end - caret)
5380    }
5381
5382    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
5383    /// region: the offset `tail` bytes back from the end of the region's `line`.
5384    ///
5385    /// Both walks are clamped rather than trusted, because the one op that does
5386    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
5387    /// the items back apart with blank lines between them — and a caret landing
5388    /// on the nearest line of the right item beats one landing out of the region
5389    /// entirely.
5390    fn line_tail_offset(
5391        &self,
5392        new: &std::ops::Range<usize>,
5393        (line, tail): (usize, usize),
5394    ) -> usize {
5395        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
5396        let mut start = 0;
5397        for _ in 0..line {
5398            match region[start..].find('\n') {
5399                Some(i) => start += i + 1,
5400                None => break,
5401            }
5402        }
5403        let end = region[start..]
5404            .find('\n')
5405            .map_or(region.len(), |i| start + i);
5406        new.start + end.saturating_sub(tail).max(start)
5407    }
5408
5409    /// Link the selection to `destination` — the toolbar's Link button. With no
5410    /// selection it acts at the caret, which re-points a link the caret is
5411    /// already standing in (twig replaces an existing link's destination and
5412    /// keeps its text) and otherwise spells a link that has no text of its own:
5413    /// an autolink (`<https://x.dev>`) where the destination is one, and
5414    /// `[destination](destination)` where it isn't.
5415    ///
5416    /// `destination` reaches twig raw. Escaping it is format knowledge and the
5417    /// two formats genuinely disagree — Markdown ends a destination at the first
5418    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
5419    /// itself — so the side holding the document is the side that gets to spell
5420    /// it. A destination twig can't carry at all (one with a newline) comes back
5421    /// as an error rather than a quietly rewritten URL.
5422    pub fn insert_link(&mut self, destination: &str) {
5423        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
5424            return;
5425        }
5426        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5427        self.record_caret();
5428        match self.editor.insert_link(start, end, destination) {
5429            Ok(change) => {
5430                self.last_edit_kind = None;
5431                self.refresh();
5432                match self.link_text_span(change.new.start) {
5433                    // A link with text of its own: select it, so typing replaces
5434                    // a `[dest](dest)`'s stand-in label and a second press
5435                    // re-points what the first one linked.
5436                    Some(text) => {
5437                        self.anchor = (text.start != text.end).then_some(text.start);
5438                        self.caret = text.end;
5439                    }
5440                    // An autolink is finished the moment it's written — its text
5441                    // *is* the URL. Leaving it selected would aim the next press
5442                    // at the one shape twig still wraps instead of re-points.
5443                    None => {
5444                        self.anchor = None;
5445                        self.caret = change.new.end;
5446                    }
5447                }
5448                self.dirty = self.source != self.clean_source;
5449                self.status = None;
5450                self.clamp_caret();
5451                self.record_caret();
5452            }
5453            Err(e) => self.status = Some(format!("link: {e}")),
5454        }
5455    }
5456
5457    /// Insert a block-level image at the caret: `![alt](destination)`. Any
5458    /// selection becomes the alt text (so "select a caption, insert image" labels
5459    /// it); with no selection, `alt` is used — empty for none. The caret lands
5460    /// just past the inserted image.
5461    ///
5462    /// Both halves go through twig (`insert_literal` for the alt text,
5463    /// `insert_image` for the image), so neither is spelled here. That used to be a
5464    /// `format!`, and it was wrong the first time an app inserted a real filename:
5465    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
5466    /// not an image at all — and the fix is per-format, since moving into the
5467    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
5468    pub fn insert_image(&mut self, destination: &str, alt: &str) {
5469        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
5470            return;
5471        }
5472        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5473        self.record_caret();
5474        // With no selection and an explicit `alt`, the alt text has to exist in the
5475        // document before it can be the image's — and it is raw caller input, so
5476        // it goes in through `insert_literal`, which escapes it for the format
5477        // rather than letting a `]` in someone's caption close the image early.
5478        let (start, end) = if start == end && !alt.is_empty() {
5479            match self.editor.insert_literal(start, alt) {
5480                Ok(change) => (change.new.start, change.new.end),
5481                Err(e) => {
5482                    self.status = Some(format!("image: {e}"));
5483                    return;
5484                }
5485            }
5486        } else {
5487            (start, end)
5488        };
5489        match self.editor.insert_image(start, end, destination) {
5490            Ok(change) => {
5491                self.last_edit_kind = None;
5492                self.refresh();
5493                // Just past the image, nothing selected — where a caret belongs
5494                // after inserting one.
5495                self.anchor = None;
5496                self.caret = change.new.end;
5497                self.dirty = self.source != self.clean_source;
5498                self.status = None;
5499                self.clamp_caret();
5500                self.record_caret();
5501            }
5502            Err(e) => self.status = Some(format!("image: {e}")),
5503        }
5504    }
5505
5506    /// Insert a block-level image, video, or audio at the caret. The image case
5507    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
5508    /// HTML elements, which is the only spelling Markdown and Djot have for them:
5509    ///
5510    /// ```text
5511    /// <video src="clip.mp4" controls>alt</video>
5512    /// <audio src="take.mp3" controls>alt</audio>
5513    /// ```
5514    ///
5515    /// HTML rather than a `::video{…}` directive deliberately. A directive means
5516    /// something only to an app that knows the vocabulary, so the document would
5517    /// read as literal punctuation everywhere else; `<video>` is what every other
5518    /// renderer already understands, and what leaf's own reader picks back up
5519    /// through `html_elements` promotion (see [`parse_extensions`]).
5520    ///
5521    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
5522    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
5523    /// `html_elements`. Before that only the multi-line form parsed as a block at
5524    /// all, and this wrote three lines to work around it.
5525    ///
5526    /// `controls` is always written: a player with no transport is a still frame
5527    /// the reader can't do anything with. Any selection becomes the element's
5528    /// fallback text, exactly as it becomes an image's alt.
5529    ///
5530    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
5531    /// applies, and bites harder here: a `"` in `destination` closes the
5532    /// attribute. A frontend taking these from a file picker is fine; one taking
5533    /// them from free text should keep them tame.
5534    ///
5535    /// [`MediaInfo`]: crate::MediaInfo
5536    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
5537        if kind == MediaKind::Image {
5538            return self.insert_image(destination, alt);
5539        }
5540        // Gated on the *image* gesture, not on one of its own — there isn't one,
5541        // since the bytes below are spelled here rather than by twig, and an HTML
5542        // document would in fact parse them. The button is one control with three
5543        // kinds behind it, and two of them working in a format where the third
5544        // cannot is a worse surface than three that agree — especially as
5545        // `insert_image` is the kind anyone reaches for first.
5546        if self.refuse_unsupported("media", Gesture::InsertImage) {
5547            return;
5548        }
5549        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5550        let alt_text = self
5551            .selected_text()
5552            .map(str::to_string)
5553            .unwrap_or_else(|| alt.to_string());
5554        let tag = match kind {
5555            MediaKind::Audio => "audio",
5556            _ => "video",
5557        };
5558        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
5559        self.edit(start, end, &markup);
5560    }
5561
5562    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
5563    /// button. Spelling and placement are both twig's; leaf used to write `---`
5564    /// itself, which was the Markdown spelling in a djot document too.
5565    ///
5566    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
5567    /// mid-paragraph and lands it after the caret's whole block. To get a rule
5568    /// *at* the caret — the paragraph parted in two around it, which is what a
5569    /// rule button is understood to do — the paragraph is first divided with
5570    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
5571    /// the offset `split_block` returns puts the rule after the *second* half
5572    /// instead, which is a rule in the right document and the wrong place.
5573    ///
5574    /// Only a plain paragraph is split, and only where there is something to
5575    /// part: at the paragraph's end the split has no second half to mint and
5576    /// would write the separator anyway — a blank line and the empty slot Enter
5577    /// leaves for the next paragraph, which the rule then lands above and
5578    /// nothing fills — so there the rule goes straight after the paragraph,
5579    /// which is where the split-and-aim was sending it regardless. At the
5580    /// paragraph's *start* the split is kept, though it parts nothing either:
5581    /// `|para` becomes `\npara` with the caret on the new blank line, and a
5582    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
5583    /// "before the paragraph" is said through a gesture that only knows
5584    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
5585    /// else the rule simply lands after the block, which is both twig's own
5586    /// answer and the better one: splitting a fenced code block would leave two
5587    /// fences with a rule between them, and splitting a list item would mint an
5588    /// item nobody asked for on the way to a rule that lands after the list
5589    /// regardless. A table and a setext heading refuse the split outright, so
5590    /// they take the same path by themselves.
5591    pub fn insert_thematic_break(&mut self) {
5592        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
5593        {
5594            return;
5595        }
5596        self.caret = self.skip_trailing_close_delims(self.caret);
5597        // A selection is replaced by the rule, so collapse it first and let the
5598        // split-and-rule below run from the caret it leaves behind.
5599        if let Some((s, e)) = self.selection() {
5600            self.splice(s, e, "", EditKind::Other);
5601        }
5602        self.anchor = None;
5603        self.record_caret();
5604        let at = self.caret;
5605        if self.caret_parts_bare_paragraph() {
5606            // A failure here is not fatal: the rule still lands after the block,
5607            // which is exactly what this call was trying to improve on.
5608            let _ = self.editor.split_block(at);
5609        }
5610        match self.editor.insert_thematic_break(at) {
5611            Ok(change) => {
5612                self.last_edit_kind = None;
5613                self.refresh();
5614                self.anchor = None;
5615                self.caret = change.new.end;
5616                self.dirty = self.source != self.clean_source;
5617                self.status = None;
5618                self.clamp_caret();
5619                self.record_caret();
5620            }
5621            Err(e) => self.status = Some(format!("thematic break: {e}")),
5622        }
5623    }
5624
5625    /// Insert a fresh table at the caret — the toolbar's Table button. One
5626    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
5627    /// the document's own dialect and placed the way its thematic break is:
5628    /// after the caret's block, blank-separated. A bare paragraph is parted
5629    /// around the caret first, exactly as
5630    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
5631    /// table lands *at* the caret rather than after everything the caret's
5632    /// paragraph says.
5633    ///
5634    /// The caret ends in the first header cell, selected the way Tab selects
5635    /// a cell — the natural next act is to type the heading, and Tab then
5636    /// walks the grid. That cell is read back from the rebuilt table map
5637    /// rather than computed from the splice, because twig's blank line and
5638    /// quote prefix put the first bar at an offset only the reparse knows.
5639    ///
5640    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
5641    /// rows or columns is twig's refusal (a header with nothing under it is
5642    /// what its row delete refuses to leave), reported through `status`.
5643    pub fn insert_table(&mut self, rows: usize, cols: usize) {
5644        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
5645            return;
5646        }
5647        self.caret = self.skip_trailing_close_delims(self.caret);
5648        if let Some((s, e)) = self.selection() {
5649            self.splice(s, e, "", EditKind::Other);
5650        }
5651        self.anchor = None;
5652        self.record_caret();
5653        let at = self.caret;
5654        if self.caret_parts_bare_paragraph() {
5655            let _ = self.editor.split_block(at);
5656        }
5657        match self.editor.insert_table(at, rows, cols) {
5658            Ok(change) => {
5659                self.last_edit_kind = None;
5660                self.refresh();
5661                self.anchor = None;
5662                self.caret = change.new.end;
5663                self.dirty = self.source != self.clean_source;
5664                self.status = None;
5665                self.clamp_caret();
5666                // Into the first header cell of the table just written: the
5667                // first table whose grid begins inside the splice.
5668                self.rebuild_map();
5669                let first_cell = self
5670                    .vmap
5671                    .tables
5672                    .iter()
5673                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
5674                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
5675                    .map(|cell| (cell.start, cell.end));
5676                if let Some((start, end)) = first_cell {
5677                    self.select_cell(start, end);
5678                }
5679                self.record_caret();
5680            }
5681            Err(e) => self.status = Some(format!("table: {e}")),
5682        }
5683    }
5684
5685    /// Whether the caret sits in a paragraph and nothing else — no list item, no
5686    /// quote, no fence, no table — with paragraph text still ahead of it. The
5687    /// one shape where parting the block around the caret is unambiguously what
5688    /// a rule button means; see
5689    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
5690    /// container is left to take the rule after itself.
5691    ///
5692    /// The "text ahead" half is what keeps `split_block` from running at the
5693    /// one edge where its output composes badly. At a paragraph's end twig
5694    /// cannot mint the empty second half (no format spells an empty
5695    /// paragraph), so it writes only the separator — a blank line and the
5696    /// slot Enter leaves for the paragraph to come — and a block then aimed at
5697    /// the first half lands above a slot that nothing fills: `para\n` with the
5698    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
5699    /// as nothing ahead, since the split would shed it as the second half's
5700    /// leading indent and leave the same slot. Which end of the newline a
5701    /// paragraph's span stops at differs between the formats (Markdown before
5702    /// it, djot after), which is why this reads the remaining bytes rather
5703    /// than comparing offsets. The paragraph's
5704    /// start is deliberately not the same case — see
5705    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
5706    /// split is kept.
5707    fn caret_parts_bare_paragraph(&mut self) -> bool {
5708        let caret = self.caret.min(self.source.len());
5709        let Ok(chain) = self.editor.ancestors_at(caret) else {
5710            return false;
5711        };
5712        let mut para_end = None;
5713        for m in chain {
5714            match m.kind {
5715                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
5716                Kind::ListItem
5717                | Kind::TaskListItem
5718                | Kind::BlockQuote
5719                | Kind::CodeBlock
5720                | Kind::Table => return false,
5721                _ => {}
5722            }
5723        }
5724        match para_end {
5725            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
5726            _ => false,
5727        }
5728    }
5729
5730    /// The destination of the link under the caret — what a Link prompt shows so
5731    /// ⌘K on an existing link edits its URL instead of asking for it again.
5732    /// `None` when the caret stands in no link.
5733    ///
5734    /// An autolink carries no separate destination: its text *is* the URL, so
5735    /// that's what comes back for one.
5736    pub fn link_destination_at_caret(&mut self) -> Option<String> {
5737        self.link_destination_at(self.caret)
5738    }
5739
5740    /// The destination of the link at `off`.
5741    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
5742    /// the caret isn't.
5743    ///
5744    /// The offset form exists for the same reason
5745    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
5746    /// the document somewhere else — a footnote's text in a popover, say — has
5747    /// rows and runs but no caret in them, and still needs to know which of those
5748    /// runs a reader can follow.
5749    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
5750        self.nodes()
5751            .into_iter()
5752            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
5753            .filter(|n| n.span.start <= off && off < n.span.end)
5754            .max_by_key(|n| n.span.start)
5755            .and_then(|n| n.destination.or(n.text))
5756    }
5757
5758    /// Where the locator `id` lands in this document — the `#v2` half of a
5759    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
5760    /// answers to it.
5761    ///
5762    /// The other end of a link, and the reason this exists: without it a
5763    /// destination has only file granularity, so following a citation into a
5764    /// chapter drops the reader at the top of it to hunt for the verse. Which is
5765    /// also why it is a *document* query rather than a caret one — the document
5766    /// being asked is usually not the one the reader is in.
5767    ///
5768    /// Three readings, tried in order, because the same `#some-heading` is
5769    /// written three ways across the formats leaf opens:
5770    ///
5771    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
5772    ///    the auto-ids djot mints for its headings. The only exact answer, so it
5773    ///    goes first — a document that says `{#v1}` has settled the question.
5774    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
5775    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
5776    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
5777    ///    authored anywhere land on a djot heading.
5778    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
5779    ///    none and `{#custom}` is literal text in a Markdown heading — so for
5780    ///    the format most vaults are written in, the heading's own words are the
5781    ///    only thing a fragment can name. This is the rule every Markdown
5782    ///    renderer already follows, which is what makes `#a-heading` mean in
5783    ///    diaryx what it means on the web.
5784    ///
5785    /// Ties go to the earliest match, then to the widest: a duplicated id is the
5786    /// document's mistake and the first one is the answer every anchor
5787    /// implementation gives, while preferring the wider span picks the section
5788    /// over the heading that opens it — more for a peek to show, same place to
5789    /// land.
5790    pub fn locate(&mut self, id: &str) -> Option<Landing> {
5791        let id = id.trim();
5792        if id.is_empty() {
5793            return None;
5794        }
5795        let nodes = self.nodes();
5796
5797        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
5798        // is picking, among nodes that start together, the one that ends last.
5799        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
5800            matches
5801                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
5802                .map(|n| Landing {
5803                    start: n.span.start,
5804                    end: n.span.end,
5805                })
5806        };
5807
5808        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
5809            return Some(landing);
5810        }
5811        let want = slug(id);
5812        if want.is_empty() {
5813            return None;
5814        }
5815        if let Some(landing) = pick(
5816            &mut nodes
5817                .iter()
5818                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
5819        ) {
5820            return Some(landing);
5821        }
5822
5823        // A heading by its words. Its span is one line, so the end comes from
5824        // where the *section* it opens gives out — the next heading that is not
5825        // under it, or the end of the document. A Markdown heading has no
5826        // section node to ask (twig only builds those for djot), and a peek that
5827        // showed the heading alone would answer "what does that say" with the
5828        // title of the thing it says.
5829        let heading = nodes
5830            .iter()
5831            .filter(|n| n.kind == Kind::Heading)
5832            .filter(|n| {
5833                n.content_span
5834                    .clone()
5835                    .and_then(|s| self.source.get(s))
5836                    .is_some_and(|text| slug(text) == want)
5837            })
5838            .min_by_key(|n| n.span.start)?;
5839        let level = heading.level.unwrap_or(u32::MAX);
5840        let end = nodes
5841            .iter()
5842            .filter(|n| n.kind == Kind::Heading)
5843            .filter(|n| n.span.start > heading.span.start)
5844            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
5845            .map(|n| n.span.start)
5846            .min()
5847            .unwrap_or(self.source.len());
5848        Some(Landing {
5849            start: heading.span.start,
5850            end,
5851        })
5852    }
5853
5854    /// Write a footnote at the caret — the toolbar's Footnote button, and the
5855    /// one gesture in the footnote story that *authors* rather than follows.
5856    ///
5857    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
5858    /// the `[^1]:` definition at the end of the document. Half a footnote is not
5859    /// a footnote — a bare reference with nothing defining it renders as literal
5860    /// brackets — so a single button that wrote only the reference would leave
5861    /// the author to hand-spell the other half in a document that had just
5862    /// stopped showing them what the first half meant. One edit also means one
5863    /// undo takes both back.
5864    ///
5865    /// The definition's body is left empty and **the caret lands in it**, which
5866    /// is the whole point of pressing the button: nobody wants a reference to a
5867    /// note they have not written yet. Getting back to where they were writing
5868    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
5869    /// the same return leg a reader following a reference already uses, so the
5870    /// author is left standing on the near end of a round trip that works.
5871    ///
5872    /// A selection collapses to its *end* rather than being replaced: a
5873    /// reference annotates the words before it, so "select the claim, add a
5874    /// footnote" should mark that claim, not consume it.
5875    pub fn insert_footnote(&mut self) {
5876        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
5877            return;
5878        }
5879        let at = self.selection().map_or(self.caret, |(_, end)| end);
5880        self.anchor = None;
5881        self.caret = at;
5882        self.record_caret();
5883        let label = self.next_footnote_label();
5884        match self.editor.insert_footnote(at, &label) {
5885            Ok(change) => {
5886                self.last_edit_kind = None;
5887                self.refresh();
5888                self.anchor = None;
5889                // `change.new` runs from the reference to the end of the
5890                // document, so its start is the `[^1]` just written and
5891                // `footnote_at` resolves it to the note the same way a reader's
5892                // tap does — and to the note's *body*, which is already a caret
5893                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
5894                // and has none), so this needs no snap on top. The fallback is
5895                // the reference's own offset: a format that spelled the pair some
5896                // way leaf can't read back should still leave the caret on the
5897                // edit rather than at the far end of a document it just grew.
5898                self.caret = self
5899                    .footnote_at(change.new.start)
5900                    .and_then(|note| note.offset)
5901                    .unwrap_or(change.new.start);
5902                self.dirty = self.source != self.clean_source;
5903                self.status = None;
5904                self.clamp_caret();
5905                self.record_caret();
5906            }
5907            Err(e) => self.status = Some(format!("footnote: {e}")),
5908        }
5909    }
5910
5911    /// The label to give a footnote the author has not named: the lowest counting
5912    /// number no footnote in the document is already wearing.
5913    ///
5914    /// twig takes the label rather than minting one, because it holds no opinion
5915    /// about what a document's footnotes should be called — and it is right not
5916    /// to. Numbering them is what every author of a numbered note expects, and
5917    /// re-using a taken number would silently point the new reference at somebody
5918    /// else's note (twig reuses an existing definition rather than appending a
5919    /// second one, which is the right rule for citing a note twice on purpose and
5920    /// exactly the wrong accident to have by default).
5921    ///
5922    /// *References* are counted alongside definitions, not just definitions: a
5923    /// document carrying a dangling `[^2]` has a 2 that means something to
5924    /// whoever wrote it, and minting a definition for it here would answer a
5925    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
5926    /// count entirely — they take no number, so they block none.
5927    fn next_footnote_label(&mut self) -> String {
5928        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
5929            .into_iter()
5930            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
5931            .filter_map(|label| label.parse().ok())
5932            .collect();
5933        taken.extend(
5934            self.nodes()
5935                .into_iter()
5936                .filter(|n| n.kind == Kind::FootnoteReference)
5937                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
5938                .filter_map(|label| label.parse::<u32>().ok()),
5939        );
5940        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
5941    }
5942
5943    /// The footnote reference under the caret, resolved to the note it names.
5944    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
5945    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
5946        self.footnote_at(self.caret)
5947    }
5948
5949    /// The footnote reference at `off`, resolved to the note it names — what a
5950    /// frontend shows when a reader activates a `[^1]`.
5951    ///
5952    /// A reference is not a link node, so
5953    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
5954    /// (and should not) answer for one: a link names a destination to leave for,
5955    /// a reference names a note that is already in this document. Following one
5956    /// is a move within the page, which is why this hands back an `offset`
5957    /// rather than something to open.
5958    ///
5959    /// Offset-based rather than caret-only because the gesture that wants this
5960    /// most is the one that must not move the caret: a pointer hovering a `[1]`
5961    /// asks what note it names without disturbing where the reader was typing.
5962    /// The caret is just the offset a click already placed —
5963    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
5964    ///
5965    /// `None` when `off` stands in no reference. A reference whose note the
5966    /// document never defines is *not* `None` — it answers with the label it
5967    /// looked for and no text, which is what lets a frontend say so instead of
5968    /// silently doing nothing.
5969    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
5970        // Innermost-wins by latest start, the rule its link sibling uses.
5971        let span = self
5972            .nodes()
5973            .into_iter()
5974            .filter(|n| n.kind == Kind::FootnoteReference)
5975            .filter(|n| n.span.start <= off && off < n.span.end)
5976            .max_by_key(|n| n.span.start)?
5977            .span;
5978        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
5979
5980        // The note itself. Definitions are roots beside `doc` rather than
5981        // children of it, so they're asked for directly — see
5982        // `wysiwyg::footnote_definitions`.
5983        let note = wysiwyg::footnote_definitions(&mut self.editor)
5984            .into_iter()
5985            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
5986        let Some(note) = note else {
5987            return Some(FootnoteRef {
5988                label,
5989                text: None,
5990                offset: None,
5991                end: None,
5992            });
5993        };
5994        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
5995        Some(FootnoteRef {
5996            label,
5997            text: body
5998                .clone()
5999                .and_then(|b| self.source.get(b))
6000                .map(str::to_string),
6001            // The body's start, not the definition's — see `FootnoteRef::offset`.
6002            offset: body.clone().map(|b| b.start),
6003            end: body.map(|b| b.end),
6004        })
6005    }
6006
6007    /// The footnote *definition* the caret stands in, and where the reference
6008    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6009    /// at the caret's offset.
6010    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6011        self.footnote_definition_at(self.caret)
6012    }
6013
6014    /// The footnote definition spanning `off`, and where the reference that
6015    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6016    ///
6017    /// The mirror image, deliberately: the same gesture that takes a reader from
6018    /// `[1]` down to the note takes them from the note back up to `[1]`, so
6019    /// following a footnote is a round trip rather than a fall. It needs no
6020    /// memory of how the reader arrived — the document says where the reference
6021    /// is — which is what makes it work for a reader who scrolled to the notes
6022    /// themselves, and what keeps it right after an edit moves either end.
6023    ///
6024    /// `None` when `off` stands in no definition. A definition nothing cites is
6025    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6026    /// its label and no offset, so a frontend can say "nothing refers to this"
6027    /// rather than offer a jump that goes nowhere.
6028    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6029        // Definitions are roots beside `doc`, so `nodes()` — which walks the
6030        // document body — never reports one. They're asked for directly, the way
6031        // `footnote_at` asks for the note it resolves to.
6032        //
6033        // Closed at the end, unlike the half-open test its neighbours use. A
6034        // definition's span stops at its last content byte — the newline ending
6035        // the line is outside it — so `span.end` is the caret stop at the end of
6036        // the note's own row, not the first byte of anything after. Excluding it
6037        // meant the one caret an author is guaranteed to have, the one left
6038        // sitting at the end of the note they just typed, was in no definition at
6039        // all: writing a note and then asking to go back to its reference
6040        // answered nothing. Two definitions in a row still can't both match —
6041        // there is a blank line between them — and `max_by_key` decides anyway.
6042        let note = wysiwyg::footnote_definitions(&mut self.editor)
6043            .into_iter()
6044            .filter(|m| m.span.start <= off && off <= m.span.end)
6045            .max_by_key(|m| m.span.start)?;
6046        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6047
6048        // The earliest reference carrying this label. `min` rather than a `find`,
6049        // because `nodes()` reports a flattened walk whose order is twig's
6050        // business, not document order. Bound first: the walk needs `&mut self`
6051        // and reading the labels back out needs `&self.source`.
6052        let nodes = self.nodes();
6053        let offset = nodes
6054            .into_iter()
6055            .filter(|n| n.kind == Kind::FootnoteReference)
6056            .filter(|n| {
6057                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6058            })
6059            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6060            .map(|n| n.span.start + 2)
6061            .min();
6062        Some(FootnoteDef { label, offset })
6063    }
6064
6065    /// The destination of the image under the caret — what an image prompt shows
6066    /// so editing an existing image starts from its current URL instead of blank,
6067    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6068    /// `None` when the caret stands in no image. A caret resting just after a
6069    /// block image (its trailing stop) is still "in" it — the half-open span test
6070    /// excludes that offset, which is the intended precision: past the image is
6071    /// past it.
6072    pub fn image_destination_at_caret(&mut self) -> Option<String> {
6073        let off = self.caret;
6074        self.nodes()
6075            .into_iter()
6076            .filter(|n| n.kind == Kind::Image)
6077            .filter(|n| n.span.start <= off && off < n.span.end)
6078            .max_by_key(|n| n.span.start)
6079            .and_then(|n| n.destination)
6080    }
6081
6082    /// The language of the fenced code block the caret stands in — what a
6083    /// language prompt shows so editing it starts from the current value rather
6084    /// than blank. `None` when the caret is in no code block, or in one whose
6085    /// fence carries no language (or an indented block, which has no fence).
6086    pub fn code_language_at_caret(&mut self) -> Option<String> {
6087        let start = self.code_block_start_at_caret()?;
6088        wysiwyg::code_language(&self.source, start)
6089    }
6090
6091    /// Whether the caret stands in a fenced code block — the one a language
6092    /// prompt could edit. A frontend gates its "set language" affordance on this
6093    /// (an indented block, which can't carry a language, reports `false`).
6094    pub fn caret_in_fenced_code(&mut self) -> bool {
6095        self.code_block_start_at_caret()
6096            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6097    }
6098
6099    /// Set (or clear, with `""`) the language of the fenced code block the caret
6100    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6101    /// block, and a reported error for a language the format's fence cannot
6102    /// carry.
6103    ///
6104    /// twig rewrites the info string, so the fence's own width — measured
6105    /// against a body neither side touches — is kept, and a language holding a
6106    /// space, a line end or the fence character is refused rather than written
6107    /// out to reparse as something else. Leaf used to splice over the info span
6108    /// itself and `trim()` the input, which handled the one bad case it had
6109    /// thought of.
6110    pub fn set_code_language(&mut self, lang: &str) {
6111        // The read-only gate — this door reaches twig without the splice.
6112        if self.read_only {
6113            return;
6114        }
6115        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6116            return;
6117        }
6118        if self.code_block_start_at_caret().is_none() {
6119            return;
6120        }
6121        let lang = lang.trim();
6122        // `None` clears the info string; `Some("")` asks for an empty one. Both
6123        // write a bare fence, and the prompt's empty value means "clear".
6124        let want = (!lang.is_empty()).then_some(lang);
6125        self.record_caret();
6126        match self.editor.set_code_language(self.caret, want) {
6127            Ok(_) => {
6128                self.last_edit_kind = None;
6129                self.refresh();
6130                self.anchor = None;
6131                self.dirty = self.source != self.clean_source;
6132                self.status = None;
6133                self.clamp_caret();
6134                self.record_caret();
6135            }
6136            Err(e) => self.status = Some(format!("code language: {e}")),
6137        }
6138    }
6139
6140    /// The `span.start` of the code block covering the caret — the anchor
6141    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6142    /// in none.
6143    fn code_block_start_at_caret(&mut self) -> Option<usize> {
6144        let off = self.caret;
6145        self.nodes()
6146            .into_iter()
6147            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6148            .max_by_key(|n| n.span.start)
6149            .map(|n| n.span.start)
6150    }
6151
6152    /// The source range of the text inside the link covering `off` — what sits
6153    /// between its `[` and `]`. `None` when twig reports no link there.
6154    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6155        self.nodes()
6156            .into_iter()
6157            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6158            // the other's `span.start`; the link that starts latest at or before
6159            // `off` is the one `off` is actually in.
6160            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6161            .max_by_key(|n| n.span.start)
6162            .and_then(|n| n.content_span)
6163    }
6164
6165    // ── undo / redo ───────────────────────────────────────────────────────────
6166    // twig owns the history of *bytes* (it owns the buffer) and now carries the
6167    // caret through it too: `record_caret` stashes each state's caret in twig's
6168    // opaque per-step blob, and undo/redo hand it back with the source they
6169    // restore. So leaf keeps no history of its own — no parallel stacks to march
6170    // in lockstep and silently drift out of it.
6171
6172    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6173    /// where they were when that step began.
6174    pub fn undo(&mut self) {
6175        if self.read_only {
6176            return;
6177        }
6178        let (undone, redoable) = (self.undo_steps, self.redo_steps);
6179        match self.editor.undo() {
6180            Ok(Some(change)) => {
6181                self.after_history(change);
6182                // `refresh` counted the restore as an edit; it was a step back.
6183                self.undo_steps = undone.saturating_sub(1);
6184                self.redo_steps = redoable + 1;
6185            }
6186            Ok(None) => {
6187                self.undo_steps = 0;
6188                self.status = Some("nothing to undo".into());
6189            }
6190            Err(e) => self.status = Some(format!("undo: {e}")),
6191        }
6192    }
6193
6194    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6195    /// selection back where that step originally left them.
6196    pub fn redo(&mut self) {
6197        if self.read_only {
6198            return;
6199        }
6200        let (undone, redoable) = (self.undo_steps, self.redo_steps);
6201        match self.editor.redo() {
6202            Ok(Some(change)) => {
6203                self.after_history(change);
6204                // `refresh` counted the restore as an edit; it was a step forward.
6205                self.undo_steps = undone + 1;
6206                self.redo_steps = redoable.saturating_sub(1);
6207            }
6208            Ok(None) => {
6209                self.redo_steps = 0;
6210                self.status = Some("nothing to redo".into());
6211            }
6212            Err(e) => self.status = Some(format!("redo: {e}")),
6213        }
6214    }
6215
6216    /// Refresh the cached source and put the caret back where the step being
6217    /// undone/redone had it, clearing any active run.
6218    ///
6219    /// The caret comes from twig's blob for the restored state (what
6220    /// `record_caret` stored). `change` is only the fallback for a state with no
6221    /// blob — a caret at the end of the restored text, which is where this always
6222    /// landed before the blobs were kept. It is the edit site, not where the user
6223    /// was standing, so it's a floor and not the behaviour: undoing should hand
6224    /// back the document *and* the place you were working, which for an edit made
6225    /// anywhere but under the caret are two different places.
6226    fn after_history(&mut self, change: Change) {
6227        self.refresh();
6228        match self
6229            .editor
6230            .caret_blob()
6231            .ok()
6232            .and_then(|b| CaretState::from_blob(&b))
6233        {
6234            Some(state) => {
6235                self.caret = state.caret.min(self.source.len());
6236                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6237            }
6238            None => {
6239                self.caret = change.new.end.min(self.source.len());
6240                self.anchor = None;
6241            }
6242        }
6243        self.goal_col = None;
6244        self.last_edit_kind = None;
6245        self.dirty = self.source != self.clean_source;
6246        self.status = None;
6247        self.clamp_caret();
6248    }
6249
6250    // ── the file ──────────────────────────────────────────────────────────────
6251
6252    #[cfg(feature = "fs")]
6253    pub fn save(&mut self) {
6254        if self.is_untitled() {
6255            // No path to write and no name to invent: ⌘S on an untitled document
6256            // is a Save As, and only a frontend has a picker to ask with. Say so
6257            // rather than failing at the filesystem with an empty path.
6258            self.status = Some("untitled — save as…".into());
6259            return;
6260        }
6261        let path = self.path.clone();
6262        if self.write(&path) {
6263            self.mark_saved();
6264        }
6265    }
6266
6267    /// Save As: write the document to `path` and *move* it there — `self.path`
6268    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6269    /// what Save As means; a copy would leave the user editing a document whose
6270    /// name is no longer where their keystrokes go.
6271    ///
6272    /// The move only happens if the bytes actually landed. A failed write leaves
6273    /// the path, `dirty`, and the disk watermark exactly as they were, with the
6274    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6275    /// must never come away believing it was saved.
6276    ///
6277    /// An existing `path` is overwritten, and the caller is the one that knows
6278    /// whether to ask first: a Save As picker has already run that prompt, and a
6279    /// second confirmation from down here would be the same question twice.
6280    ///
6281    /// `format` does **not** follow the new extension. The buffer is parsed as
6282    /// the format it was opened with, and re-reading it as another one is a
6283    /// conversion — a different, lossy operation that would throw away the undo
6284    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6285    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6286    /// until it's reopened.
6287    #[cfg(feature = "fs")]
6288    pub fn save_as(&mut self, path: PathBuf) {
6289        if !self.write(&path) {
6290            return;
6291        }
6292        self.path = path;
6293        self.mark_saved();
6294    }
6295
6296    /// Put `source` on disk at `path`, reporting whether it got there. The one
6297    /// place leaf writes a document, so a save and a Save As can't disagree
6298    /// about what a failure looks like.
6299    #[cfg(feature = "fs")]
6300    fn write(&mut self, path: &Path) -> bool {
6301        match std::fs::write(path, self.source.as_bytes()) {
6302            Ok(()) => true,
6303            Err(e) => {
6304                self.status = Some(format!("save failed: {e}"));
6305                false
6306            }
6307        }
6308    }
6309
6310    /// Re-base the document's saved watermark to the current bytes: clears
6311    /// `dirty`, records `source` as the new clean state (so undoing back to here
6312    /// clears the flag again), and re-stamps the on-disk hash.
6313    ///
6314    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
6315    /// the hook a **filesystem-free host** calls itself once it has persisted
6316    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
6317    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
6318    /// are already where that host wants them, and this just tells the model they
6319    /// are safe.
6320    pub fn mark_saved(&mut self) {
6321        self.clean_source = self.source.clone();
6322        self.dirty = false;
6323        // The bytes on disk are now ours, so this is the new watermark: without
6324        // re-stamping it, every save would report its own work as an external
6325        // change forever after.
6326        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
6327        self.status = Some(format!("saved {}", self.file_name()));
6328    }
6329
6330    /// What the file looks like now against the bytes leaf last read or wrote.
6331    ///
6332    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
6333    /// so this is a filesystem round-trip, not a per-frame question — ask it
6334    /// when a window regains focus, on a timer, or before a save.
6335    ///
6336    /// This *only* reports the file. Whether the document also has unsaved edits
6337    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
6338    /// [`DiskState::Changed`] means a save overwrites someone's work and a
6339    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
6340    /// it has no way to ask — so it hands a frontend both halves and lets it put
6341    /// the question to the person who can answer it.
6342    #[cfg(feature = "fs")]
6343    pub fn disk_state(&self) -> DiskState {
6344        let Some(want) = self.disk_hash else {
6345            return DiskState::Untitled;
6346        };
6347        match std::fs::read(&self.path) {
6348            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
6349            Ok(_) => DiskState::Changed,
6350            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
6351            Err(_) => DiskState::Unreadable,
6352        }
6353    }
6354
6355    /// Re-read the file and replace the document with what's there — the other
6356    /// answer to a [`DiskState::Changed`].
6357    ///
6358    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
6359    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
6360    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
6361    /// document shouldn't have to argue with a guard.
6362    ///
6363    /// **The undo history survives, and the reload is one step in it.** The
6364    /// whole buffer is spliced with the file's bytes through the same door every
6365    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
6366    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
6367    /// swapped the document out from under a reader gives them back what they
6368    /// were looking at, marked dirty, and ^Z again carries on into whatever they
6369    /// had done before it. This used to build a fresh parse and drop the stack,
6370    /// on the reasoning that twig's history belongs to the buffer and these are
6371    /// different bytes; that is true of *rebasing* a step onto them and not of
6372    /// recording the swap itself as one, which is all this is. A splice twig
6373    /// won't take falls back to the fresh parse, and only that path still costs
6374    /// the history.
6375    ///
6376    /// The caret keeps its byte offset, clamped to the new length; the selection
6377    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
6378    /// file changed, so it can't know where the caret "still" is. Clamping keeps
6379    /// it where the user left it in the common case (a change further down the
6380    /// file, or none in the text they're sitting in), and never puts it
6381    /// somewhere invalid. A selection has two such offsets and no such excuse —
6382    /// silently reinterpreting one over changed bytes would arm the *next*
6383    /// keystroke to delete something the user never selected.
6384    ///
6385    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
6386    /// document alone with a status.
6387    #[cfg(feature = "fs")]
6388    pub fn reload(&mut self) {
6389        if self.is_untitled() {
6390            self.status = Some("no file to reload".into());
6391            return;
6392        }
6393        let bytes = match std::fs::read(&self.path) {
6394            Ok(b) => b,
6395            Err(e) => {
6396                self.status = Some(format!("reload failed: {e}"));
6397                return;
6398            }
6399        };
6400        let Ok(source) = String::from_utf8(bytes) else {
6401            self.status = Some("reload failed: file is not UTF-8".into());
6402            return;
6403        };
6404        // Already these bytes — someone saved a file back unchanged, or leaf's
6405        // own write is being read back. Re-baseline against it and stop: a
6406        // splice of the text onto itself would put an undo step on the stack for
6407        // something nobody did.
6408        if source == self.source {
6409            self.disk_hash = Some(hash_bytes(source.as_bytes()));
6410            self.clean_source = source;
6411            self.dirty = false;
6412            self.status = Some(format!("reloaded {}", self.file_name()));
6413            return;
6414        }
6415        let caret = self.caret;
6416        // The pre-reload caret, so undoing the swap puts it back where the
6417        // reader was standing — the same bracketing `splice_exact` does.
6418        self.record_caret();
6419        if self
6420            .editor
6421            .edit_range(0, self.source.len(), &source)
6422            .is_ok()
6423        {
6424            self.refresh();
6425        } else {
6426            // twig wouldn't take the splice. Start over from the bytes, which is
6427            // what this always did, and is the one path that still costs the
6428            // history — `format` is the format this document *is*, not what the
6429            // (unchanged) name now says, see `save_as`.
6430            match new_editor(source.as_bytes(), self.format) {
6431                Ok(editor) => {
6432                    self.editor = editor;
6433                    self.source = source.clone();
6434                    // Not going through `refresh`, so the revision has to move
6435                    // here or every frontend keeps painting the old file from
6436                    // cache.
6437                    self.revision += 1;
6438                }
6439                Err(e) => {
6440                    self.status = Some(format!("reload failed: {e}"));
6441                    return;
6442                }
6443            }
6444        }
6445        self.disk_hash = Some(hash_bytes(source.as_bytes()));
6446        self.clean_source = self.source.clone();
6447        self.caret = caret.min(self.source.len());
6448        self.anchor = None;
6449        self.goal_col = None;
6450        self.last_edit_kind = None;
6451        self.dirty = false;
6452        self.status = Some(format!("reloaded {}", self.file_name()));
6453        self.clamp_caret();
6454        // And the post-reload caret, so a redo restores it.
6455        self.record_caret();
6456    }
6457
6458    /// Re-read the source from twig after it has changed the document. The one
6459    /// funnel every edit, undo, and redo comes through — so it's where the
6460    /// revision moves, and anything cached against the text dies here.
6461    fn refresh(&mut self) {
6462        if let Ok(s) = self.editor.source_str() {
6463            self.source = s;
6464        }
6465        self.revision += 1;
6466        // An edit is a step onto the history and the end of anything undone;
6467        // `undo`/`redo` come through here too and correct this after.
6468        self.undo_steps += 1;
6469        self.redo_steps = 0;
6470        self.clamp_caret();
6471    }
6472
6473    /// Whether [`undo`](Self::undo) has a step to take back — for a native
6474    /// Edit menu to enable its item by. See the note on `undo_steps` for what
6475    /// "has" means here.
6476    pub fn can_undo(&self) -> bool {
6477        !self.read_only && self.undo_steps > 0
6478    }
6479
6480    /// Whether [`redo`](Self::redo) has an undone step to restore.
6481    pub fn can_redo(&self) -> bool {
6482        !self.read_only && self.redo_steps > 0
6483    }
6484
6485    // ── caret movement ─────────────────────────────────────────────────────────
6486    // `extend` grows the selection (Shift+motion): it pins the anchor on the
6487    // first extended step and moves only the caret; an un-extended motion drops
6488    // the selection.
6489
6490    /// Place the caret at byte `offset` (clamped to a char boundary), extending
6491    /// the selection when `extend` is set. The public form of `move_to`, for a
6492    /// frontend that hit-tests pixels straight to a source offset.
6493    pub fn place_caret(&mut self, offset: usize, extend: bool) {
6494        self.goal_col = None;
6495        let before = self.caret;
6496        // A pixel hit-test can land between the visible caret stops — in the
6497        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
6498        // Snap to the nearest real stop so the caret can't come to rest where it
6499        // would draw in one place and type in another. The `(row, col)` click
6500        // path (`click`) already snaps this way through `offset_of_pos`; the
6501        // source view reaches every byte, so it snaps to nothing.
6502        let target = match self.view {
6503            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
6504            // The source view reaches every byte, so there is no stop to snap
6505            // to — but "every byte" still means every *character* boundary. A
6506            // caret resting inside a multi-byte character draws nowhere real
6507            // and panics the next time anything slices there.
6508            View::Source => self.char_boundary_at_or_before(offset),
6509        };
6510        self.move_to(target, extend);
6511        self.clamp_caret();
6512        self.debug_assert_on_a_stop(before);
6513    }
6514
6515    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
6516    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
6517    /// grab the metadata) while the source view still selects the literal whole.
6518    pub fn select_all(&mut self) {
6519        self.anchor = Some(self.caret_floor());
6520        self.caret = self.source.len();
6521        self.goal_col = None;
6522        self.last_edit_kind = None;
6523        self.status = None;
6524    }
6525
6526    /// Select the word (or whitespace / punctuation run) at `offset` — the
6527    /// double-click gesture. Anchors on the run's start with the caret at its
6528    /// end so a following Shift-motion extends from the far edge.
6529    pub fn select_word_at(&mut self, offset: usize) {
6530        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
6531        self.anchor = Some(s);
6532        self.caret = e;
6533        self.goal_col = None;
6534        self.last_edit_kind = None;
6535        self.status = None;
6536        self.clamp_caret();
6537    }
6538
6539    /// Select the whole enclosing text block (paragraph, heading, list item's
6540    /// text…) at `offset` — the triple-click gesture. Reads the range straight
6541    /// from the AST (twig's `content_span`), so it selects the entire *logical*
6542    /// paragraph even when that paragraph soft-wraps across several visual rows —
6543    /// where a visual-row-based select breaks down, because one source offset at
6544    /// a wrap boundary belongs to two rows at once.
6545    pub fn select_block_at(&mut self, offset: usize) {
6546        let off = offset.min(self.source.len());
6547        let range = self
6548            .editor
6549            .ancestors_at(off)
6550            .ok()
6551            .and_then(|chain| {
6552                // Ancestors run root → deepest; the deepest node that is neither
6553                // an inline span nor a multi-block container is the text block
6554                // the caret sits in (a paragraph, a heading, a code block…).
6555                chain
6556                    .into_iter()
6557                    .rev()
6558                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6559                    .map(|m| m.content_span.unwrap_or(m.span))
6560            })
6561            .unwrap_or_else(|| source_line_range(&self.source, off));
6562        self.anchor = Some(range.start.min(self.source.len()));
6563        self.caret = range.end.min(self.source.len());
6564        self.goal_col = None;
6565        self.last_edit_kind = None;
6566        self.status = None;
6567        self.clamp_caret();
6568    }
6569
6570    /// Select the exact source range `[start, end)` — anchor at `start`, caret
6571    /// at `end` — without snapping either end to a visible caret stop.
6572    ///
6573    /// The one caret verb that takes a range it was *handed* rather than one it
6574    /// worked out, for a host that already knows the bytes it means: a search
6575    /// hit, an annotation's footprint, a quote re-anchored through
6576    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
6577    /// wrong tool for that, and not by a little — it snaps to the nearest
6578    /// *visible* stop, and where a range butts up against a hidden delimiter
6579    /// the nearest stop is the one before it, so selecting the "needle" of
6580    /// `**needle**` comes back with "needl" and an edit against it strands the
6581    /// "e".
6582    ///
6583    /// What `place_caret` does that is bookkeeping rather than snapping still
6584    /// happens here, because a host handing in a range is not asking to opt out
6585    /// of the invariants:
6586    ///
6587    /// - both ends are clamped into the document and up to
6588    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
6589    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
6590    ///   types into the metadata;
6591    /// - both land on character boundaries, so nothing slices a `é` in half;
6592    /// - the sticky vertical goal column is dropped, and any armed inline mark
6593    ///   disarmed, since a range from outside inherits neither.
6594    ///
6595    /// An empty range is a caret rather than a selection —
6596    /// [`selection`](Self::selection) reports `None` for it, as it does for any
6597    /// anchor that has met the caret.
6598    pub fn select_range(&mut self, start: usize, end: usize) {
6599        let floor = self.caret_floor();
6600        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
6601        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
6602        self.anchor = Some(anchor);
6603        self.caret = caret;
6604        self.goal_col = None;
6605        self.status = None;
6606        self.last_edit_kind = None;
6607        self.clear_pending();
6608    }
6609
6610    /// `offset` itself if it is a character boundary, else the boundary before
6611    /// it. An offset that isn't one draws nowhere real and panics the next time
6612    /// anything slices there.
6613    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
6614        let mut o = offset.min(self.source.len());
6615        while o > 0 && !self.source.is_char_boundary(o) {
6616            o -= 1;
6617        }
6618        o
6619    }
6620
6621    /// The lowest source offset the caret may occupy in the active view. In
6622    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
6623    /// the first rendered offset; the source view reaches everything, so it's 0.
6624    fn caret_floor(&self) -> usize {
6625        match self.view {
6626            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
6627            View::Source => 0,
6628        }
6629    }
6630
6631    /// Land in a table cell with its whole content selected — the anchor at the
6632    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
6633    /// like tabbing into a form field: the text comes up selected, so typing
6634    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
6635    /// end`) collapses to a plain caret home (an empty selection is no selection).
6636    fn select_cell(&mut self, start: usize, end: usize) {
6637        self.select_range(start, end);
6638    }
6639
6640    fn move_to(&mut self, offset: usize, extend: bool) {
6641        if extend {
6642            if self.anchor.is_none() {
6643                self.anchor = Some(self.caret);
6644            }
6645        } else {
6646            self.anchor = None;
6647        }
6648        self.caret = offset.min(self.source.len()).max(self.caret_floor());
6649        self.status = None;
6650        // A caret move ends the current typing/deletion run, so the next edit
6651        // starts a fresh undo group rather than coalescing across the gap.
6652        self.last_edit_kind = None;
6653        // Moving away disarms any sticky mark — "start bold" applies only where
6654        // it was asked for, not wherever the caret next lands.
6655        self.clear_pending();
6656    }
6657
6658    // In the source view, motion walks source bytes / source lines. In the
6659    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
6660    // what steps the caret cleanly over hidden delimiters.
6661
6662    pub fn move_left(&mut self, extend: bool) {
6663        self.goal_col = None;
6664        if !extend && let Some((s, _e)) = self.selection() {
6665            self.move_to(s, false);
6666            return;
6667        }
6668        let target = match self.view {
6669            View::Source => {
6670                if self.caret > 0 {
6671                    prev_boundary(&self.source, self.caret)
6672                } else {
6673                    0
6674                }
6675            }
6676            // Walks caret *stops*, not columns: decoration (a table border, a
6677            // cell's padding) is stepped over in one press, and a hidden
6678            // delimiter never holds the caret up — though the end of a mark's
6679            // content is a stop of its own (`VisualMap::mark_ends`), so
6680            // leaving `**bold**` from past its `**` is a press onto the end of
6681            // the bold and another onto the `d`.
6682            View::Wysiwyg => self
6683                .vmap
6684                .caret_stop_before(self.caret)
6685                .unwrap_or(self.caret),
6686        };
6687        let before = self.caret;
6688        self.move_to(target, extend);
6689        self.debug_assert_on_a_stop(before);
6690    }
6691
6692    pub fn move_right(&mut self, extend: bool) {
6693        self.goal_col = None;
6694        if !extend && let Some((_s, e)) = self.selection() {
6695            self.move_to(e, false);
6696            return;
6697        }
6698        let target = match self.view {
6699            View::Source => {
6700                if self.caret < self.source.len() {
6701                    next_boundary(&self.source, self.caret)
6702                } else {
6703                    self.caret
6704                }
6705            }
6706            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
6707        };
6708        let before = self.caret;
6709        self.move_to(target, extend);
6710        self.debug_assert_on_a_stop(before);
6711    }
6712
6713    /// Move to the start of the previous word (⌥← / Ctrl+←).
6714    pub fn move_word_left(&mut self, extend: bool) {
6715        self.goal_col = None;
6716        let before = self.caret;
6717        let target = self.word_left_from(self.caret);
6718        self.move_to(target, extend);
6719        self.debug_assert_on_a_stop(before);
6720    }
6721
6722    /// Move to the end of the next word (⌥→ / Ctrl+→).
6723    pub fn move_word_right(&mut self, extend: bool) {
6724        self.goal_col = None;
6725        let before = self.caret;
6726        let target = self.word_right_from(self.caret);
6727        self.move_to(target, extend);
6728        self.debug_assert_on_a_stop(before);
6729    }
6730
6731    // Word boundaries are found in the space the *view* is in. The source view
6732    // walks the source, because there the source is what's rendered. WYSIWYG
6733    // walks the rendered text instead: `**` is invisible to the user, so it has
6734    // to be invisible to word motion too — a caret parked inside one draws in
6735    // the column after `bold` and types two bytes earlier, and a word-delete
6736    // that stops there shreds the markup into `a ** c`.
6737
6738    /// The word boundary to the left of `off` in the active view's space.
6739    fn word_left_from(&self, off: usize) -> usize {
6740        match self.view {
6741            View::Source => prev_word(&self.source, off),
6742            View::Wysiwyg => self.glyph_word_left(off),
6743        }
6744    }
6745
6746    /// The word boundary to the right of `off` in the active view's space.
6747    fn word_right_from(&self, off: usize) -> usize {
6748        match self.view {
6749            View::Source => next_word(&self.source, off),
6750            View::Wysiwyg => self.glyph_word_right(off),
6751        }
6752    }
6753
6754    /// The character class of the glyph drawn at stop `off`.
6755    ///
6756    /// Read from the source, because a stop points at the source byte its glyph
6757    /// came from — the source *is* where the rendered character is written. What
6758    /// makes the walk glyph space rather than source space is that it only ever
6759    /// visits stops, and the hidden bytes between them have none.
6760    fn class_at(&self, off: usize) -> Class {
6761        self.source
6762            .get(off..)
6763            .and_then(|s| s.chars().next())
6764            .map_or(Class::Space, classify)
6765    }
6766
6767    /// [`next_word`] in glyph space: skip any leading separators, then consume
6768    /// the following word run, with the stop table standing in for the source's
6769    /// characters.
6770    fn glyph_word_right(&self, from: usize) -> usize {
6771        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
6772            return from;
6773        };
6774        let mut in_word = false;
6775        loop {
6776            match self.class_at(off) {
6777                Class::Word => in_word = true,
6778                _ if in_word => return off,
6779                _ => {}
6780            }
6781            match self.vmap.stop_after(off) {
6782                Some(next) => off = next,
6783                None => return off,
6784            }
6785        }
6786    }
6787
6788    /// [`prev_word`] in glyph space: skip separators walking left, then consume
6789    /// the preceding word run.
6790    fn glyph_word_left(&self, from: usize) -> usize {
6791        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
6792            return from;
6793        };
6794        let mut in_word = false;
6795        while let Some(prev) = self.vmap.stop_before(off) {
6796            match self.class_at(prev) {
6797                Class::Word => in_word = true,
6798                _ if in_word => return off,
6799                _ => {}
6800            }
6801            off = prev;
6802        }
6803        off
6804    }
6805
6806    /// After a motion that walks the visual map, the caret must be *on* the map.
6807    /// A stop is the only offset where the caret draws and edits in the same
6808    /// place, and it's the invariant both a caret parked inside an emoji and one
6809    /// parked inside a `**` were quietly breaking.
6810    ///
6811    /// Only when the caret actually moved: a walk with nowhere to go leaves it
6812    /// where it was, which is wherever the floor or a frontend put it rather
6813    /// than somewhere this motion chose.
6814    fn debug_assert_on_a_stop(&self, before: usize) {
6815        debug_assert!(
6816            self.view != View::Wysiwyg
6817                || self.vmap.num_rows() == 0
6818                || self.caret == before
6819                || self.vmap.is_stop(self.caret),
6820            "motion left the caret at {}, which is not a caret stop: it would draw in \
6821             one place and type in another",
6822            self.caret
6823        );
6824    }
6825
6826    // Up and Down run off the ends of the document rather than stopping dead at
6827    // them: Up from the first row lands at the document's start, Down from the
6828    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
6829    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
6830    // reaching the end of the text is what a reader means by it.
6831    //
6832    // The views used to disagree here by accident rather than by decision: the
6833    // source view fell into the edge behaviour through `row_col_to_offset`
6834    // clamping an out-of-range row to the end of the string, while WYSIWYG had
6835    // no row below to walk to and did nothing at all. They share the rule now,
6836    // each in its own space — the source view reaches every byte, WYSIWYG only
6837    // the offsets it draws.
6838
6839    pub fn move_up(&mut self, extend: bool) {
6840        let (row, col) = self.caret_pos();
6841        let goal = self.goal_col.unwrap_or(col);
6842        let target = match self.view {
6843            View::Source => match row.checked_sub(1) {
6844                Some(r) => row_col_to_offset(&self.source, r, goal),
6845                None => self.reachable_start(),
6846            },
6847            // A table's border rules are drawn but hold no caret, so Up steps
6848            // over them to the row that does.
6849            View::Wysiwyg => match self.vmap.navigable_above(row) {
6850                Some(r) => self.row_target(r, goal),
6851                None => self.reachable_start(),
6852            },
6853        };
6854        self.step_vertical(target, goal, extend);
6855    }
6856
6857    pub fn move_down(&mut self, extend: bool) {
6858        let (row, col) = self.caret_pos();
6859        let goal = self.goal_col.unwrap_or(col);
6860        let target = match self.view {
6861            View::Source => match self.source_row_below(row) {
6862                Some(r) => row_col_to_offset(&self.source, r, goal),
6863                None => self.reachable_end(),
6864            },
6865            View::Wysiwyg => match self.vmap.navigable_below(row) {
6866                Some(r) => self.row_target(r, goal),
6867                None => self.reachable_end(),
6868            },
6869        };
6870        self.step_vertical(target, goal, extend);
6871    }
6872
6873    /// Land a vertical motion at `target`, latching the `goal` column it aimed
6874    /// with so the rest of the run keeps aiming there.
6875    ///
6876    /// A motion with nowhere to go changes *nothing*, the goal column included:
6877    /// the latch used to run before the early return at the top of the document,
6878    /// so an Up that did nothing still armed a column, and the next Down aimed
6879    /// at one the caret had never been in.
6880    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
6881        let before = self.caret;
6882        if target == before {
6883            return;
6884        }
6885        self.goal_col = Some(goal);
6886        self.move_to(target, extend);
6887        self.debug_assert_on_a_stop(before);
6888    }
6889
6890    /// The source line below `row`, or `None` when `row` is the last one. Lines
6891    /// are counted by newline, so a trailing one leaves a real, empty last line
6892    /// for the caret to sit on — the document ends below it, not on it.
6893    fn source_row_below(&self, row: usize) -> Option<usize> {
6894        let last = self.source.bytes().filter(|&b| b == b'\n').count();
6895        (row < last).then_some(row + 1)
6896    }
6897
6898    /// Where a vertical motion aiming at the `goal` column lands on visual row
6899    /// `r`: the column clamped to the row, mapped to its offset, then held
6900    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
6901    /// column belongs to the row below, and a gutter's column 0 points at the
6902    /// block rather than at this row.
6903    fn row_target(&self, r: usize, goal: usize) -> usize {
6904        let (start, end) = self.row_bounds(r);
6905        self.vmap
6906            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
6907            .clamp(start, end)
6908    }
6909
6910    /// The first and last offsets the caret can reach in the active view.
6911    ///
6912    /// Not the same span in both: the source view shows every byte, so it can
6913    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
6914    /// sits below the first stop, and a document's trailing newline is drawn
6915    /// nowhere and so sits past the last.
6916    fn reachable_start(&self) -> usize {
6917        match self.view {
6918            View::Source => 0,
6919            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
6920        }
6921    }
6922
6923    fn reachable_end(&self) -> usize {
6924        match self.view {
6925            View::Source => self.source.len(),
6926            View::Wysiwyg => self
6927                .vmap
6928                .stop_at_or_before(self.source.len())
6929                .unwrap_or(self.caret),
6930        }
6931    }
6932
6933    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
6934    /// including the space a soft wrap ate off its end, which is drawn on this
6935    /// row however much the offset past it belongs to the next one.
6936    fn row_span(&self, r: usize) -> (usize, usize) {
6937        let start = self
6938            .vmap
6939            .row_start(r)
6940            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
6941        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
6942        (start.min(end), end)
6943    }
6944
6945    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
6946    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
6947    /// this row's last position is the one before it — the offset before the
6948    /// space the wrap ate, where the caret draws just past the row's last word
6949    /// and types there too.
6950    ///
6951    /// Aiming at the shared offset instead is what stalled End: it is the row's
6952    /// last *column*, so End pressed on the row reached it and then read back as
6953    /// the row below's start, where a second press ran on to that row's end and
6954    /// the next to the one after — End walking down the paragraph a row a press.
6955    fn row_bounds(&self, r: usize) -> (usize, usize) {
6956        let (start, end) = self.row_span(r);
6957        let wraps = self
6958            .vmap
6959            .navigable_below(r)
6960            .and_then(|b| self.vmap.row_start(b))
6961            .is_some_and(|off| off == end);
6962        match wraps {
6963            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
6964            false => (start, end),
6965        }
6966    }
6967
6968    /// The `[start, end]` of the line Home and End aim at: the visual row in
6969    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
6970    ///
6971    /// A soft-wrapped row is a line here, because it is one to the eye and the
6972    /// eye is what these keys are aimed by — a reader pressing End means the end
6973    /// of the line they can see. (`select_block_at` wants the opposite and reads
6974    /// the AST for it: a triple-click grabs the whole paragraph, however many
6975    /// rows it folds into.)
6976    fn line_bounds(&self) -> (usize, usize) {
6977        let (row, _) = self.caret_pos();
6978        match self.view {
6979            View::Source => {
6980                let start = line_start(&self.source, row);
6981                (start, line_end_from(&self.source, start))
6982            }
6983            View::Wysiwyg => self.row_bounds(row),
6984        }
6985    }
6986
6987    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
6988    /// *drawn* — what a kill takes.
6989    ///
6990    /// The two part only at a soft wrap, over the space the wrap ate: the caret
6991    /// can't stand after it (that offset opens the row below, and End stopping
6992    /// there would walk), but it is on this row, and a kill that spared it would
6993    /// leave a double space behind where the row's text had been. Deleting it
6994    /// joins nothing — a wrap is drawn, not written.
6995    fn line_span(&self) -> (usize, usize) {
6996        let (row, _) = self.caret_pos();
6997        match self.view {
6998            View::Source => self.line_bounds(),
6999            View::Wysiwyg => self.row_span(row),
7000        }
7001    }
7002
7003    /// The first offset in `[start, end]` holding something other than
7004    /// whitespace, or `end` when the line holds nothing else — where Home aims.
7005    ///
7006    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7007    /// so a hidden delimiter is never taken for the line's first character (nor
7008    /// landed on), and the source view steps the source it is showing.
7009    fn first_non_space(&self, start: usize, end: usize) -> usize {
7010        let mut off = start;
7011        while off < end {
7012            if self.class_at(off) != Class::Space {
7013                return off;
7014            }
7015            off = match self.view {
7016                View::Source => next_boundary(&self.source, off),
7017                View::Wysiwyg => match self.vmap.stop_after(off) {
7018                    Some(next) => next,
7019                    None => return end,
7020                },
7021            };
7022        }
7023        end
7024    }
7025
7026    /// Home: to the first character on the line, or to column 0 when the caret
7027    /// is already on it — the two-press toggle every editor spells this way.
7028    /// The indentation is somewhere the caret has to be able to reach and almost
7029    /// never where a reader is headed, so it costs the second press.
7030    pub fn move_home(&mut self, extend: bool) {
7031        self.goal_col = None;
7032        let (start, end) = self.line_bounds();
7033        let text = self.first_non_space(start, end);
7034        let target = if self.caret == text { start } else { text };
7035        let before = self.caret;
7036        self.move_to(target, extend);
7037        self.debug_assert_on_a_stop(before);
7038    }
7039
7040    /// End: to the end of the line.
7041    pub fn move_end(&mut self, extend: bool) {
7042        self.goal_col = None;
7043        let (_, end) = self.line_bounds();
7044        let before = self.caret;
7045        self.move_to(end, extend);
7046        self.debug_assert_on_a_stop(before);
7047    }
7048
7049    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7050    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7051    /// when the caret isn't in a table, or is already in the last/first cell — the
7052    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7053    /// meaning everywhere else.
7054    pub fn cell_hop(&mut self, forward: bool) -> bool {
7055        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7056            return false;
7057        };
7058        // Flatten to document (row-major) order and step one cell either way.
7059        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7060        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7061        let next = if forward {
7062            i.checked_add(1)
7063        } else {
7064            i.checked_sub(1)
7065        };
7066        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7067            return false; // at the table's edge; leave Tab to the frontend
7068        };
7069        self.select_cell(start, end);
7070        true
7071    }
7072
7073    /// Move the caret to the cell directly above (`down == false`) or below in
7074    /// the same column, landing with the cell's whole content selected (see
7075    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7076    /// when the caret isn't in a table), so the frontend can fall through — the
7077    /// vertical counterpart of [`Self::cell_hop`].
7078    ///
7079    /// A ragged row that is short a column clamps to its last cell, so Down never
7080    /// falls out of the table over a gap the row above happened to have.
7081    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7082        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7083            return false;
7084        };
7085        let target = match down {
7086            true => r + 1,
7087            false if r == 0 => return false,
7088            false => r - 1,
7089        };
7090        let Some(row) = grid.get(target) else {
7091            return false;
7092        };
7093        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7094            return false;
7095        };
7096        self.select_cell(start, end);
7097        true
7098    }
7099
7100    /// The table containing `off` as a row-major grid of `(start, end)` cell
7101    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7102    /// isn't in a table. Read straight off the visual map's laid-out grid, so
7103    /// every cell (an empty one included, whose derived home twig gives no
7104    /// `content_span` for) is present and in the order Tab walks them.
7105    // Grid, row, column — three returns that only ever travel together, and a
7106    // named type for the pair of them would be read at one call site.
7107    #[allow(clippy::type_complexity)]
7108    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7109        for t in &self.vmap.tables {
7110            let mut pos = None;
7111            let grid: Vec<Vec<(usize, usize)>> = t
7112                .grid
7113                .iter()
7114                .enumerate()
7115                .map(|(r, row)| {
7116                    row.cells
7117                        .iter()
7118                        .enumerate()
7119                        .map(|(c, cell)| {
7120                            if pos.is_none() && off >= cell.start && off <= cell.end {
7121                                pos = Some((r, c));
7122                            }
7123                            (cell.start, cell.end)
7124                        })
7125                        .collect()
7126                })
7127                .collect();
7128            if let Some((r, c)) = pos {
7129                return Some((grid, r, c));
7130            }
7131        }
7132        None
7133    }
7134
7135    // ── table key policy ──────────────────────────────────────────────────────
7136    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7137    // as one policy every frontend shares, rather than each re-deriving it. Each
7138    // reports whether it acted *as a table key*; a `false` hands the key back to
7139    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7140    // everywhere else.
7141
7142    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7143    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7144    /// back and simply stays put at the very first cell. `false` when the caret
7145    /// isn't in a table.
7146    pub fn cell_tab(&mut self, forward: bool) -> bool {
7147        if !self.caret_in_table() {
7148            return false;
7149        }
7150        if self.cell_hop(forward) {
7151            return true;
7152        }
7153        // Off the last cell: grow the table by a row and step into its first
7154        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7155        if forward {
7156            self.append_row_and_enter(0);
7157        }
7158        true
7159    }
7160
7161    /// Return inside a table: drop to the cell below in the same column,
7162    /// appending a new row when the caret is already in the last one. `false`
7163    /// when the caret isn't in a table, so the frontend inserts a newline.
7164    pub fn cell_return(&mut self) -> bool {
7165        if !self.caret_in_table() {
7166            return false;
7167        }
7168        if self.cell_move_vertical(true) {
7169            return true;
7170        }
7171        // Already on the last row: grow one below and drop into the same column.
7172        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7173        self.append_row_and_enter(col);
7174        true
7175    }
7176
7177    /// Append a row below the caret's (last) row and land in `col` of it. The
7178    /// caret is in the last row, so twig's "insert below" makes the fresh row the
7179    /// table's new last — but twig re-spells the whole table, moving every byte,
7180    /// so the destination is read back from the rebuilt grid by the table's
7181    /// position (stable across a row insert), not from the pre-edit caret.
7182    fn append_row_and_enter(&mut self, col: usize) {
7183        let table = self.caret_table_index();
7184        self.table_insert_row(true);
7185        self.rebuild_map();
7186        let Some((start, end)) = table
7187            .and_then(|ti| self.vmap.tables.get(ti))
7188            .and_then(|t| t.grid.last())
7189            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7190            .map(|cell| (cell.start, cell.end))
7191        else {
7192            return;
7193        };
7194        self.select_cell(start, end);
7195    }
7196
7197    /// The index, among the document's tables, of the one the caret sits in —
7198    /// `None` when it's in none. Used to re-find a table after an edit re-spells
7199    /// it (a row insert leaves the table order unchanged).
7200    fn caret_table_index(&self) -> Option<usize> {
7201        let off = self.caret;
7202        self.vmap.tables.iter().position(|t| {
7203            t.grid
7204                .iter()
7205                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7206        })
7207    }
7208
7209    /// Shift+Return inside a table: insert a hard line break *within* the current
7210    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7211    /// table, so the frontend inserts an ordinary line break.
7212    ///
7213    /// A table row is a single source line, so the newline-spelled hard break
7214    /// can't live in a cell. twig spells the in-cell break the format's way
7215    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7216    /// break round-trips as structure the renderer reads back as a line — not the
7217    /// opaque raw HTML the old raw-splice left behind.
7218    ///
7219    /// Djot has no idiomatic in-cell break, so twig refuses it
7220    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7221    /// would render as the literal text `<br>`. The gesture is still *consumed*
7222    /// there — returning `false` would let the frontend insert a real newline,
7223    /// which splits the one-line row — it just leaves the cell unchanged and says
7224    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7225    ///
7226    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7227    /// have to be read together: djot is not the only `false`, and naming it in
7228    /// the message was already a guess that HTML — which spells the break as its
7229    /// own `<br>` — would have made wrong.
7230    pub fn cell_line_break(&mut self) -> bool {
7231        if self.read_only || !self.caret_in_table() {
7232            return false;
7233        }
7234        self.record_caret();
7235        match self.editor.insert_line_break(self.caret) {
7236            Ok(change) => {
7237                self.last_edit_kind = None;
7238                self.refresh();
7239                self.caret = change.new.end;
7240                self.anchor = None;
7241                self.goal_col = None;
7242                self.clamp_caret();
7243                self.dirty = self.source != self.clean_source;
7244                self.status = None;
7245                self.record_caret();
7246            }
7247            Err(twig::Error::UnsupportedFormat) => {
7248                self.status = Some(format!(
7249                    "in-cell line breaks aren't supported in {}",
7250                    self.format_name()
7251                ));
7252            }
7253            Err(_) => {}
7254        }
7255        true
7256    }
7257
7258    /// Rebuild the visual map at the width the last build used. A structural edit
7259    /// bumps the revision and swaps the source in, but leaves the *map* stale;
7260    /// when a single gesture edits and then moves over the result (Tab appending
7261    /// a row, then stepping into it), the move needs the map to already show the
7262    /// edit rather than waiting for the frontend's next frame.
7263    fn rebuild_map(&mut self) {
7264        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7265        self.build_map(wrap);
7266    }
7267
7268    /// Move the caret to the very start of the document (⌘↑ on macOS,
7269    /// Ctrl+Home on Windows/Linux).
7270    pub fn move_doc_start(&mut self, extend: bool) {
7271        self.goal_col = None;
7272        self.move_to(0, extend);
7273    }
7274
7275    /// Move the caret to the very end of the document (⌘↓ on macOS,
7276    /// Ctrl+End on Windows/Linux).
7277    pub fn move_doc_end(&mut self, extend: bool) {
7278        self.goal_col = None;
7279        let end = self.source.len();
7280        self.move_to(end, extend);
7281    }
7282
7283    /// Point the caret at the body cell `(row, col)` the mouse landed on —
7284    /// `col` being a cell of the terminal grid, which is what a display column
7285    /// is. A click on the far cell of a wide character lands at that
7286    /// character's start; the mapping's own doc-comments carry the rule.
7287    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
7288        self.goal_col = None;
7289        let target = match self.view {
7290            View::Source => row_col_to_offset(&self.source, row, col),
7291            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
7292        };
7293        let before = self.caret;
7294        self.move_to(target, extend);
7295        self.debug_assert_on_a_stop(before);
7296    }
7297
7298    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
7299    /// screen if it has moved since the last frame, and never scroll past the
7300    /// last of `rows`.
7301    ///
7302    /// Only if it has *moved* — that's the whole point. Revealing the caret on
7303    /// every frame ties the viewport to it, and a scroll wheel that fights the
7304    /// caret for the viewport loses: the view snaps back the instant it tries to
7305    /// pass the caret's row, so the document can't be scrolled beyond what's
7306    /// already on screen. A caret move is the frontend's cue to follow; a scroll
7307    /// with the caret sitting still is the reader's cue to leave it alone.
7308    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
7309        if self.drawn_caret != Some(self.caret) {
7310            if caret_row < self.scroll {
7311                self.scroll = caret_row;
7312            } else if height > 0 && caret_row >= self.scroll + height {
7313                self.scroll = caret_row + 1 - height;
7314            }
7315            self.drawn_caret = Some(self.caret);
7316        }
7317        self.scroll = self.scroll.min(rows.saturating_sub(1));
7318    }
7319
7320    /// The caret's screen position `(row, col)` in the active view's grid, with
7321    /// `col` a display column: the cell to draw the caret in, which on a line of
7322    /// `你好` or emoji is not the count of characters before it.
7323    pub fn caret_pos(&self) -> (usize, usize) {
7324        match self.view {
7325            View::Source => offset_to_row_col(&self.source, self.caret),
7326            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
7327        }
7328    }
7329
7330    fn clamp_caret(&mut self) {
7331        if self.caret > self.source.len() {
7332            self.caret = self.source.len();
7333        }
7334        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
7335        // any selection anchor) to the first rendered offset.
7336        let floor = self.caret_floor();
7337        if self.caret < floor {
7338            self.caret = floor;
7339        }
7340        if let Some(a) = self.anchor
7341            && a < floor
7342        {
7343            self.anchor = Some(floor);
7344        }
7345        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
7346            self.caret -= 1;
7347        }
7348    }
7349}
7350
7351// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
7352
7353// Left/right motion and backspace/delete step by *grapheme cluster*, not
7354// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
7355// marks moves and deletes as the single character a user sees. Grapheme
7356// boundaries are a superset of char boundaries, so the caret stays valid for twig.
7357
7358/// How an insert of `text` groups for undo: a single typed character folds into
7359/// the run of typing around it, while a newline or a multi-character insert is a
7360/// step of its own.
7361fn typed_edit_kind(text: &str) -> EditKind {
7362    if text.chars().take(2).count() == 1 && text != "\n" {
7363        EditKind::Insert
7364    } else {
7365        EditKind::Other
7366    }
7367}
7368
7369fn prev_boundary(s: &str, i: usize) -> usize {
7370    let mut cursor = GraphemeCursor::new(i, s.len(), true);
7371    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
7372}
7373
7374fn next_boundary(s: &str, i: usize) -> usize {
7375    let mut cursor = GraphemeCursor::new(i, s.len(), true);
7376    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
7377}
7378
7379// ── word boundaries ──────────────────────────────────────────────────────────
7380// The shared primitive behind word-wise motion, word deletion, and
7381// double-click-to-select-a-word. A "word" is a maximal run of one character
7382// class; whitespace and punctuation are their own classes, so motion skips
7383// cleanly between them the way native text fields do.
7384
7385#[derive(PartialEq, Eq, Clone, Copy)]
7386enum Class {
7387    Word,
7388    Space,
7389    Other,
7390}
7391
7392/// The source range of an inline node's own visible text — the part of it a
7393/// WYSIWYG caret can reach, as against the delimiters that only spell it.
7394/// `None` for a node with no interior to empty (a `str`, a break).
7395///
7396/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
7397/// one delimiter in from the span — the same place the renderer maps it to. A
7398/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
7399/// against the source rather than trusted: a range guessed wrong here is text
7400/// deleted wrong.
7401fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
7402    if let Some(span) = n.content_span.clone() {
7403        return Some(span);
7404    }
7405    match n.kind.as_str() {
7406        "verbatim" | "inline_math" => {
7407            let text = n.text.as_ref()?;
7408            let start = n.span.start + 1;
7409            let range = start..start + text.len();
7410            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
7411        }
7412        _ => None,
7413    }
7414}
7415
7416/// The `id` a node declares, or `None` for one that declares none — the
7417/// attribute djot writes for a `{#v1}` and mints for a heading.
7418///
7419/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
7420/// names nothing, so it reads as absent rather than as the empty string.
7421fn declared_id(n: &FlatNode) -> Option<&str> {
7422    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
7423}
7424
7425/// A heading's words reduced to the form a link fragment spells them in:
7426/// lowercase, runs of anything else collapsed to a single `-`, with none left
7427/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
7428///
7429/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
7430/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
7431/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
7432/// any other language is still a heading someone will link to. Underscores
7433/// survive for the same reason they do on the web: they are word characters
7434/// wherever identifiers are written.
7435fn slug(text: &str) -> String {
7436    let mut out = String::new();
7437    let mut pending = false;
7438    for c in text.chars() {
7439        if c.is_alphanumeric() || c == '_' {
7440            if pending && !out.is_empty() {
7441                out.push('-');
7442            }
7443            pending = false;
7444            out.extend(c.to_lowercase());
7445        } else {
7446            pending = true;
7447        }
7448    }
7449    out
7450}
7451
7452fn is_block_container(kind: &Kind) -> bool {
7453    matches!(
7454        kind,
7455        Kind::Doc
7456            | Kind::Section
7457            | Kind::BlockQuote
7458            | Kind::BulletList
7459            | Kind::OrderedList
7460            | Kind::TaskList
7461            | Kind::ListItem
7462            | Kind::TaskListItem
7463            // Every `container` — a directive in any of its three forms, or a
7464            // promoted HTML element. A *text* directive is really inline, so
7465            // claiming it here is a small overreach, and the deliberate one this
7466            // function's kind-only peer `is_inline_kind` documents: the pair is
7467            // consulted together, and answering "block container" for something
7468            // inline is what keeps an ancestor walk from stopping short of the
7469            // paragraph that actually holds it.
7470            | Kind::Container
7471    )
7472}
7473
7474/// The `[start, end)` byte range of the source line containing `off` (newline
7475/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
7476/// line between paragraphs).
7477fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
7478    let off = off.min(s.len());
7479    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
7480    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
7481    start..end
7482}
7483
7484/// How many leading bytes an outdent takes off `line`: a whole indent level
7485/// where the line has one, and whatever it has where it has less.
7486///
7487/// A leading tab counts as a level on its own. It's indentation some other
7488/// editor wrote, and one tab is one level everywhere it came from — measuring it
7489/// in spaces it doesn't contain would leave it untouchable.
7490fn outdent_width(line: &str, unit: usize) -> usize {
7491    if line.starts_with('\t') {
7492        return 1;
7493    }
7494    line.bytes().take(unit).take_while(|b| *b == b' ').count()
7495}
7496
7497/// A list marker found at the head of a line, together with everything before it
7498/// that a sibling line has to repeat.
7499///
7500/// The three offsets differ only inside a block quote, where `>   - b` opens with
7501/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
7502/// `line_start == marker_start`, and `text` is the plain `"  - "`.
7503#[derive(Clone, Debug)]
7504struct ListMarker {
7505    /// The line's first byte.
7506    line_start: usize,
7507    /// Where the marker proper begins, past any quote prefix. The offset to hand
7508    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
7509    marker_start: usize,
7510    /// `line_start` through the marker's trailing space — quote prefix, indent
7511    /// and bullet together, which is what the next item's line opens with.
7512    text: String,
7513}
7514
7515impl ListMarker {
7516    /// Where the item's content starts — one past the marker's trailing space.
7517    fn content_start(&self) -> usize {
7518        self.line_start + self.text.len()
7519    }
7520}
7521
7522fn classify(c: char) -> Class {
7523    if c == '_' || c.is_alphanumeric() {
7524        Class::Word
7525    } else if c.is_whitespace() {
7526        Class::Space
7527    } else {
7528        Class::Other
7529    }
7530}
7531
7532/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
7533/// skip any leading separators, then consume the following word run.
7534fn next_word(s: &str, i: usize) -> usize {
7535    let mut off = i;
7536    let mut in_word = false;
7537    for c in s[i..].chars() {
7538        if classify(c) == Class::Word {
7539            in_word = true;
7540        } else if in_word {
7541            break;
7542        }
7543        off += c.len_utf8();
7544    }
7545    off
7546}
7547
7548/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
7549/// skip separators walking left, then consume the preceding word run.
7550fn prev_word(s: &str, i: usize) -> usize {
7551    let mut off = i;
7552    let mut in_word = false;
7553    for c in s[..i].chars().rev() {
7554        if classify(c) == Class::Word {
7555            in_word = true;
7556        } else if in_word {
7557            break;
7558        }
7559        off -= c.len_utf8();
7560    }
7561    off
7562}
7563
7564/// The `[start, end)` run of same-class characters surrounding `off` — the
7565/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
7566/// the run ending there is used.
7567fn word_range_at(s: &str, off: usize) -> (usize, usize) {
7568    if s.is_empty() {
7569        return (0, 0);
7570    }
7571    let off = off.min(s.len());
7572    let reference = if off < s.len() {
7573        s[off..].chars().next()
7574    } else {
7575        s[..off].chars().next_back()
7576    };
7577    let Some(rc) = reference else {
7578        return (off, off);
7579    };
7580    let class = classify(rc);
7581
7582    let mut start = off;
7583    for c in s[..start].chars().rev() {
7584        if classify(c) == class {
7585            start -= c.len_utf8();
7586        } else {
7587            break;
7588        }
7589    }
7590    let mut end = off;
7591    for c in s[end..].chars() {
7592        if classify(c) == class {
7593            end += c.len_utf8();
7594        } else {
7595            break;
7596        }
7597    }
7598    (start, end)
7599}
7600
7601/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
7602/// the line's start — terminal cells, not characters, so the column names the
7603/// cell the caret is drawn in even on a line of `你好` or emoji.
7604fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
7605    let off = off.min(s.len());
7606    let mut row = 0;
7607    let mut line_start = 0;
7608    for (i, &b) in s.as_bytes().iter().enumerate() {
7609        if i >= off {
7610            break;
7611        }
7612        if b == b'\n' {
7613            row += 1;
7614            line_start = i + 1;
7615        }
7616    }
7617    (row, wysiwyg::text_width(&s[line_start..off]))
7618}
7619
7620/// The byte offset at display column `col` of `row` (clamped to that line's
7621/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
7622///
7623/// A column landing *inside* a character — the second cell of `你`, or any cell
7624/// but the first of an emoji — resolves to that character's start, which is the
7625/// column the caret would have been drawn at to begin with. So both cells of a
7626/// wide character mean the character, and every offset survives the round trip
7627/// out to a column and back. The walk steps by grapheme cluster for the same
7628/// reason the caret does: a cluster is the character, and the cells belong to it
7629/// rather than to the codepoints spelling it.
7630fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
7631    let start = line_start(s, row);
7632    let end = line_end_from(s, start);
7633    let mut off = start;
7634    let mut at = 0; // the display column `off` sits at
7635    while off < end {
7636        let next = next_boundary(s, off).min(end);
7637        let cells = wysiwyg::text_width(&s[off..next]);
7638        if at + cells > col {
7639            break; // `col` is one of this cluster's own cells
7640        }
7641        at += cells;
7642        off = next;
7643    }
7644    off
7645}
7646
7647fn line_start(s: &str, row: usize) -> usize {
7648    if row == 0 {
7649        return 0;
7650    }
7651    let mut r = 0;
7652    for (i, &b) in s.as_bytes().iter().enumerate() {
7653        if b == b'\n' {
7654            r += 1;
7655            if r == row {
7656                return i + 1;
7657            }
7658        }
7659    }
7660    s.len()
7661}
7662
7663fn line_end_from(s: &str, start: usize) -> usize {
7664    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
7665}
7666
7667/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
7668/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
7669/// twig applies writing the mark out, so the toolbar can light the same button
7670/// that made the node.
7671///
7672/// `None` for every other kind, including the inline nodes that aren't marks at
7673/// all (`str`, `link`, `image`, the math and break kinds): they're things a
7674/// caret stands in, not formatting a button toggles.
7675/// Whether a match from an ancestor chain is an inline run whose delimiters
7676/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
7677/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
7678/// span is a [`Kind::Container`], which the kind alone cannot tell from a
7679/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
7680/// answer is the node's own. Every delete and caret step that walks over a
7681/// `**` walks over a span's tags by this test; without it Backspace after
7682/// `</span>` took the `>` and left the paragraph unparseable.
7683fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
7684    inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
7685}
7686
7687fn inline_kind(kind: &Kind) -> Option<InlineKind> {
7688    Some(match kind {
7689        Kind::Strong => InlineKind::Strong,
7690        Kind::Emph => InlineKind::Emph,
7691        Kind::Verbatim => InlineKind::Verbatim,
7692        Kind::Mark => InlineKind::Mark,
7693        Kind::Superscript => InlineKind::Superscript,
7694        Kind::Subscript => InlineKind::Subscript,
7695        Kind::Insert => InlineKind::Insert,
7696        Kind::Delete => InlineKind::Delete,
7697        _ => return None,
7698    })
7699}
7700
7701/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
7702/// a highlight's opening `==`.
7703///
7704/// Two enums for one closed vocabulary, and the duplication is the boundary
7705/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
7706/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
7707/// the editor writes. Spelled as a match rather than routed through the two
7708/// crates' name strings so that a colour added on either side is a compile
7709/// error here, where the pairing is decided, rather than a runtime `None` that
7710/// would read as "clear the colour".
7711fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
7712    match color {
7713        MarkColor::Red => twig::MarkColor::Red,
7714        MarkColor::Orange => twig::MarkColor::Orange,
7715        MarkColor::Yellow => twig::MarkColor::Yellow,
7716        MarkColor::Green => twig::MarkColor::Green,
7717        MarkColor::Blue => twig::MarkColor::Blue,
7718        MarkColor::Purple => twig::MarkColor::Purple,
7719        MarkColor::Brown => twig::MarkColor::Brown,
7720    }
7721}
7722
7723/// Where an offset lands after a splice it didn't make — twig's own rule, from
7724/// [`Change`]: shift anything at or past the replaced range's end by the length
7725/// the replacement gained or lost, and leave anything before it alone.
7726///
7727/// An offset *inside* the replaced range has no text of its own to ride any
7728/// more, and lands at the end of what replaced it: for
7729/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
7730/// the prefix is cleared, which then sits where the highlighted text begins.
7731/// One node's attribute list, twig's own `(key, value)` pairs owned — what
7732/// every presentation gesture reads, edits one key of, and passes back whole.
7733type Attrs = Vec<(String, Option<String>)>;
7734
7735/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
7736/// writes it and the walker draws it, and a frontend that paginates matches a
7737/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
7738/// stated once.
7739pub const PAGE_BREAK: &str = "page-break";
7740
7741/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
7742/// every other attribute kept in its place — the read-edit-write half of twig's
7743/// replace-not-merge contract for a `data-` key.
7744///
7745/// **A key that is already there is rewritten where it stands**, and only a key
7746/// the node did not have goes on the end. That is what makes the proposal's
7747/// worked example true: `class="lead center" id="intro"
7748/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
7749/// data-line-height="1.5"` — the same document with one token changed, and a
7750/// one-line diff. Removing the key and pushing it back would reorder the
7751/// author's attributes on every press, so a document that passed through the
7752/// editor came out shuffled even where nothing about it had changed.
7753///
7754/// A duplicate key — which no format leaf opens can spell, but twig reports
7755/// verbatim — collapses onto the first of its copies, since twig is handed one
7756/// value for one key either way.
7757fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
7758    let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
7759    let mut written = false;
7760    for (k, v) in attrs {
7761        if k != key {
7762            out.push((k.clone(), v.clone()));
7763            continue;
7764        }
7765        if let Some(new) = value.filter(|_| !written) {
7766            out.push((k.clone(), Some(new.to_string())));
7767            written = true;
7768        }
7769    }
7770    if let Some(new) = value.filter(|_| !written) {
7771        out.push((key.to_string(), Some(new.to_string())));
7772    }
7773    out
7774}
7775
7776/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
7777/// `token` added, with the rest of the list kept in order.
7778///
7779/// `class` is a space-separated token list, and leaf owns three of the tokens in
7780/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
7781/// goes out as `class="lead right"`; one whose last owned token goes and which
7782/// carried nothing else loses the key, so a block that has lost its whole
7783/// vocabulary is spelled bare again. `class` itself keeps its place among the
7784/// attributes, because [`with_attr`] does the writing.
7785fn with_class_token(
7786    attrs: &[(String, Option<String>)],
7787    mine: impl Fn(&str) -> bool,
7788    token: Option<&str>,
7789) -> Attrs {
7790    let kept: Vec<&str> = attrs
7791        .iter()
7792        .find(|(k, _)| k == "class")
7793        .and_then(|(_, v)| v.as_deref())
7794        .unwrap_or_default()
7795        .split_whitespace()
7796        .filter(|t| !mine(t))
7797        .collect();
7798    let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
7799    with_attr(
7800        attrs,
7801        "class",
7802        (!class.is_empty()).then_some(class.as_str()),
7803    )
7804}
7805
7806/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
7807///
7808/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
7809/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
7810/// (djot has no bare attribute, so no format reads one back everywhere), and
7811/// `hidden=""` is the same document where `hidden` is; dropping it instead
7812/// would lose what the author wrote, which is the one thing these gestures
7813/// promise not to do.
7814fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
7815    attrs
7816        .iter()
7817        .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
7818        .collect()
7819}
7820
7821fn reanchor(off: usize, change: &Change) -> usize {
7822    if off < change.old.start {
7823        return off;
7824    }
7825    if off < change.old.end {
7826        return change.new.end;
7827    }
7828    (off + change.new.end).saturating_sub(change.old.end)
7829}
7830
7831/// [`reanchor`] for an edit that respells the markup *around* a block and
7832/// leaves the block's own bytes alone — which is every attribute gesture.
7833///
7834/// `block` is that block's content span before and after the splice, so an
7835/// offset standing in the text keeps its distance from the text's start and how
7836/// many bytes twig wrote above it never enters the arithmetic. That is the whole
7837/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
7838/// on the same div lengthens the attribute line, clearing the last one takes the
7839/// div away entirely, and both are the same sum. `None` where the splice named
7840/// no block at either end, which is every djot case — the `{…}` line is written
7841/// above the block, and the block itself only shifts past it.
7842///
7843/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
7844/// shifted by its delta after it, and at the splice's end for an offset that
7845/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
7846/// text to keep.
7847fn reanchor_in_block(
7848    off: usize,
7849    change: &Change,
7850    block: Option<(&Range<usize>, &Range<usize>)>,
7851) -> usize {
7852    if let Some((was, now)) = block
7853        && was.start <= off
7854        && off <= was.end
7855    {
7856        return now.start + (off - was.start).min(now.end - now.start);
7857    }
7858    reanchor(off, change)
7859}
7860
7861/// A watermark for a file's contents (see `Doc::disk_hash`).
7862///
7863/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
7864/// watermark is compared only against one taken by the same process moments
7865/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
7866/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
7867fn hash_bytes(bytes: &[u8]) -> u64 {
7868    use std::hash::{Hash, Hasher};
7869    let mut h = std::collections::hash_map::DefaultHasher::new();
7870    bytes.hash(&mut h);
7871    h.finish()
7872}
7873
7874#[cfg(feature = "fs")]
7875fn detect_format(path: &Path) -> Result<Format> {
7876    let ext = path
7877        .extension()
7878        .and_then(|e| e.to_str())
7879        .unwrap_or("")
7880        .to_ascii_lowercase();
7881    Ok(match ext.as_str() {
7882        "dj" | "djot" => Format::Djot,
7883        "md" | "markdown" => Format::Markdown,
7884        "xml" => Format::Xml,
7885        "html" | "htm" => Format::Html,
7886        other => return Err(anyhow!("unknown document extension: .{other}")),
7887    })
7888}
7889
7890#[cfg(test)]
7891mod tests {
7892    use super::*;
7893    use crate::style::{FontFamily, LineSpacing, SizeStep};
7894
7895    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
7896    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
7897    /// without one is a view no user is ever in.
7898    fn doc_in(view: View, name: &str, body: &str) -> Doc {
7899        // The fixture name doubles as the temp file's, so two tests picking the
7900        // same one raced under the parallel runner and read each other's body —
7901        // a green suite proving the wrong thing. The counter makes that
7902        // unreachable rather than asking every future caller to notice.
7903        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7904        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7905        let mut p = std::env::temp_dir();
7906        p.push(format!("leaf_test_{name}_{seq}.md"));
7907        std::fs::write(&p, body).unwrap();
7908        let mut d = Doc::open(p).unwrap();
7909        d.view = view;
7910        if view == View::Wysiwyg {
7911            d.build_visual(80);
7912        }
7913        d
7914    }
7915
7916    // Source-view document for the source-behaviour tests. `Doc::open` now
7917    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
7918    // `wysiwyg_doc` builds the rich-text variant on top of this.
7919    fn doc_with(name: &str, body: &str) -> Doc {
7920        doc_in(View::Source, name, body)
7921    }
7922
7923    /// Every visual row's drawn text — what the reader actually sees, which is
7924    /// the only thing the reveal preference is supposed to change.
7925    fn drawn_rows(d: &Doc) -> Vec<String> {
7926        d.vmap
7927            .rows
7928            .iter()
7929            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7930            .collect()
7931    }
7932
7933    /// Put the caret at the first byte of `needle` and rebuild, so the row under
7934    /// it becomes the revealed line.
7935    fn caret_at(d: &mut Doc, needle: &str) {
7936        d.caret = d.source.find(needle).expect("needle in source");
7937        d.build_visual(80);
7938    }
7939
7940    #[test]
7941    fn blockquote_after_a_list_is_not_bulleted() {
7942        // twig nests a following top-level block quote under the `bullet_list`
7943        // (a direct child, not a `list_item`). The map must render it de-nested —
7944        // `│ quote`, never `• │ quote` — with a blank separator, like any block
7945        // that follows a list. Regression for the "combined list + blockquote" bug.
7946        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
7947        d.build_visual(80);
7948        let rows: Vec<String> = d
7949            .vmap
7950            .rows
7951            .iter()
7952            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
7953            .collect();
7954        assert!(
7955            rows.iter().any(|r| r == "│ quote"),
7956            "block quote should render on its own gutter, got rows: {rows:?}"
7957        );
7958        assert!(
7959            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
7960            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
7961        );
7962    }
7963
7964    // ── the map is built at most once per (revision, wrap) ───────────────────
7965    //
7966    // A frontend repaints for reasons that have nothing to do with the text — a
7967    // blinking caret, a scroll — and rebuilding the map is O(document). These
7968    // pin *that the cache fires*, which a passing suite can't tell you: a cache
7969    // that never hits is invisible to every other test in this file.
7970    //
7971    // The probe is to wreck the built map and ask for it again. A rebuild
7972    // repairs it; a cache hit hands the wreckage straight back. Nothing else
7973    // can distinguish the two from outside.
7974
7975    #[test]
7976    fn a_rebuild_with_nothing_changed_reuses_the_map() {
7977        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
7978        d.build_visual(80);
7979        assert!(!d.vmap.rows.is_empty());
7980        d.vmap.rows.clear(); // wreck it
7981        d.build_visual(80);
7982        assert!(
7983            d.vmap.rows.is_empty(),
7984            "the map was rebuilt though nothing changed — the cache never fired"
7985        );
7986    }
7987
7988    #[test]
7989    fn an_edit_rebuilds_the_map() {
7990        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
7991        d.build_visual(80);
7992        let before = d.revision();
7993        d.vmap.rows.clear();
7994        d.insert("x");
7995        d.build_visual(80);
7996        assert!(d.revision() > before, "an edit must move the revision");
7997        assert!(
7998            !d.vmap.rows.is_empty(),
7999            "an edited document must not paint from a stale map"
8000        );
8001    }
8002
8003    #[test]
8004    fn a_width_change_rebuilds_the_map() {
8005        // The map is a function of the wrap width too, so a resize is a miss
8006        // even though the text is untouched.
8007        let mut d = doc_in(
8008            View::Wysiwyg,
8009            "cache_width",
8010            "one two three four five six\n",
8011        );
8012        d.build_visual(80);
8013        d.vmap.rows.clear();
8014        d.build_visual(12);
8015        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8016        // And the unwrapped map is its own key, not the same as any width.
8017        d.vmap.rows.clear();
8018        d.build_visual_unwrapped();
8019        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8020    }
8021
8022    #[test]
8023    fn a_motion_does_not_rebuild_the_map() {
8024        // The whole point: moving the caret changes nothing the map is built
8025        // from. If a motion bumped the revision, every arrow key would cost a
8026        // full rebuild and the cache would be worthless.
8027        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8028        d.build_visual(80);
8029        let rev = d.revision();
8030        d.move_right(false);
8031        d.move_right(true);
8032        d.move_down(false);
8033        assert_eq!(d.revision(), rev, "a motion must not move the revision");
8034        d.vmap.rows.clear();
8035        d.build_visual(80);
8036        assert!(
8037            d.vmap.rows.is_empty(),
8038            "a motion should not rebuild the map"
8039        );
8040    }
8041
8042    #[test]
8043    fn saving_does_not_rebuild_the_map() {
8044        // Saving changes `dirty`, not the text.
8045        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8046        d.insert("x");
8047        d.build_visual(80);
8048        let rev = d.revision();
8049        d.save();
8050        assert_eq!(d.revision(), rev, "a save must not move the revision");
8051        assert!(!d.dirty, "the save should have cleaned the document");
8052    }
8053
8054    #[test]
8055    fn a_reload_rebuilds_the_map() {
8056        // Reload replaces the text without going through `refresh`, so it has to
8057        // move the revision itself — else the editor paints the old file.
8058        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8059        d.build_visual(80);
8060        let rev = d.revision();
8061        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8062        d.reload();
8063        assert!(d.revision() > rev, "a reload must move the revision");
8064        d.build_visual(80);
8065        let text: String = d
8066            .vmap
8067            .rows
8068            .iter()
8069            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8070            .collect();
8071        assert!(
8072            text.contains("wholly new"),
8073            "the reloaded text should be on screen, got {text:?}"
8074        );
8075    }
8076
8077    // ── golden-case harness ──────────────────────────────────────────────────
8078    // The pattern the whole parity suite can reuse: write a fixture with the
8079    // caret marked by `|`, run one action, and compare the rendered result —
8080    // also caret-marked — against the expected string. One readable line per
8081    // behavior, and it exercises the exact `Doc` ops both frontends call.
8082
8083    /// Split a `|`-marked fixture into `(source, caret_offset)`.
8084    fn parse_caret(marked: &str) -> (String, usize) {
8085        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8086        (marked.replacen('|', "", 1), caret)
8087    }
8088
8089    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8090    /// selection) so a result reads like the fixtures.
8091    fn render_caret(d: &Doc) -> String {
8092        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8093        // so the caret always renders inside its own selection.
8094        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8095        if let Some((s, e)) = d.selection() {
8096            marks.push((s, 0, '['));
8097            marks.push((e, 2, ']'));
8098        }
8099        // Insert right-to-left: descending offset, then descending rank.
8100        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8101        let mut out = d.source.clone();
8102        for (at, _, ch) in marks {
8103            out.insert(at, ch);
8104        }
8105        out
8106    }
8107
8108    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8109    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8110        golden_in(View::Source, name, marked, action)
8111    }
8112
8113    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8114    /// the same fixture has to read the same way in both.
8115    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8116        let (src, caret) = parse_caret(marked);
8117        let mut d = doc_in(view, name, &src);
8118        d.caret = caret;
8119        action(&mut d);
8120        render_caret(&d)
8121    }
8122
8123    #[test]
8124    fn word_motion_walks_word_by_word() {
8125        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8126        assert_eq!(
8127            g("hello wor|ld", |d| d.move_word_left(false)),
8128            "hello |world"
8129        );
8130        assert_eq!(
8131            g("hello| world", |d| d.move_word_left(false)),
8132            "|hello world"
8133        );
8134        assert_eq!(
8135            g("hel|lo world", |d| d.move_word_right(false)),
8136            "hello| world"
8137        );
8138        assert_eq!(
8139            g("hello| world", |d| d.move_word_right(false)),
8140            "hello world|"
8141        );
8142        // Punctuation is its own class, so motion stops at the boundary.
8143        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8144    }
8145
8146    #[test]
8147    fn word_motion_extends_the_selection_when_asked() {
8148        assert_eq!(
8149            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8150            "hello [world|]"
8151        );
8152    }
8153
8154    #[test]
8155    fn delete_word_removes_a_whole_word() {
8156        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8157        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8158        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8159        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8160    }
8161
8162    // ── Home / End ───────────────────────────────────────────────────────────
8163
8164    #[test]
8165    fn home_toggles_between_the_line_s_text_and_its_margin() {
8166        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8167        // indent to the markup it spells everywhere it means one, so the fixture
8168        // with whitespace left to walk is a code block, which is verbatim.
8169        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8170        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
8171        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
8172        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
8173        // A line with no indentation has one place to go, so the toggle is a
8174        // no-op rather than a trip to nowhere.
8175        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8176        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8177
8178        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
8179        let indent = d.source.find("    indented").unwrap();
8180        d.caret = indent + 6; // inside "indented"
8181        d.move_home(false);
8182        assert_eq!(
8183            d.caret,
8184            indent + 4,
8185            "wysiwyg: Home aims at the code line's text"
8186        );
8187        d.move_home(false);
8188        assert_eq!(
8189            d.caret, indent,
8190            "wysiwyg: the second press takes the indent"
8191        );
8192        d.move_home(false);
8193        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8194    }
8195
8196    #[test]
8197    fn end_takes_the_line_the_view_is_showing() {
8198        // The line differs by view for the same document, and that is the point:
8199        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8200        // as a space on one row and the source view as two lines.
8201        let mut d = doc_with("end_src", "one two\nthree\n");
8202        d.caret = 1;
8203        d.move_end(false);
8204        assert_eq!(d.caret, 7, "source: the end of the source line");
8205
8206        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8207        d.caret = 1;
8208        d.move_end(false);
8209        assert_eq!(
8210            d.caret, 13,
8211            "wysiwyg: the end of the row, soft break and all"
8212        );
8213    }
8214
8215    #[test]
8216    fn home_and_end_extend_the_selection_when_asked() {
8217        for (view, tag) in VIEWS {
8218            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
8219            d.caret = 6;
8220            d.move_end(true);
8221            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
8222            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
8223            d.caret = 6;
8224            d.move_home(true);
8225            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
8226        }
8227    }
8228
8229    // ── kill to the line's start / end ───────────────────────────────────────
8230
8231    #[test]
8232    fn kill_to_the_line_start_and_end_in_both_views() {
8233        for (view, tag) in VIEWS {
8234            // The gap that reads as a paragraph break in each view: the source
8235            // view's lines are the renderer's rows only where the source says so.
8236            let gap = if view == View::Source { "\n" } else { "\n\n" };
8237            let mut d = doc_in(
8238                view,
8239                &format!("kill_end_{tag}"),
8240                &format!("one two{gap}three\n"),
8241            );
8242            d.caret = 3;
8243            d.delete_to_line_end();
8244            assert_eq!(
8245                d.source,
8246                format!("one{gap}three\n"),
8247                "{tag}: ^K to the line's end"
8248            );
8249            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
8250
8251            let mut d = doc_in(
8252                view,
8253                &format!("kill_start_{tag}"),
8254                &format!("one two{gap}three\n"),
8255            );
8256            d.caret = 7; // the end of the first line
8257            d.delete_to_line_start();
8258            assert_eq!(
8259                d.source,
8260                format!("{gap}three\n"),
8261                "{tag}: ⌘⌫ to the line's start"
8262            );
8263            assert_eq!(d.caret, 0, "{tag}");
8264        }
8265    }
8266
8267    #[test]
8268    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
8269        // The decision: at the boundary both kills do nothing, rather than
8270        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
8271        // at a soft wrap as often as at a newline, where there is nothing
8272        // written to delete — and a source newline is only half of the blank
8273        // line between two paragraphs, so taking it leaves a soft break rather
8274        // than the join it looks like. Backspace and Delete are the keys for it.
8275        for (view, tag) in VIEWS {
8276            let gap = if view == View::Source { "\n" } else { "\n\n" };
8277            let src = format!("one{gap}three\n");
8278            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
8279            d.caret = 3; // the end of "one"
8280            d.delete_to_line_end();
8281            assert_eq!(
8282                d.source, src,
8283                "{tag}: ^K at the line's end joined it to the next"
8284            );
8285
8286            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
8287            d.caret = 3 + gap.len(); // the start of "three"
8288            d.delete_to_line_start();
8289            assert_eq!(
8290                d.source, src,
8291                "{tag}: ⌘⌫ at the line's start joined it to the last"
8292            );
8293        }
8294    }
8295
8296    #[test]
8297    fn a_kill_takes_the_selection_when_there_is_one() {
8298        // What every other delete here does with one, so these two as well.
8299        for (view, tag) in VIEWS {
8300            for (name, kill) in [
8301                (
8302                    "end",
8303                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
8304                ),
8305                ("start", |d: &mut Doc| d.delete_to_line_start()),
8306            ] {
8307                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
8308                d.anchor = Some(4);
8309                d.caret = 7; // "two"
8310                kill(&mut d);
8311                assert_eq!(
8312                    d.source, "one  three\n",
8313                    "{tag}: {name} ignored the selection"
8314                );
8315                assert_eq!(d.selection(), None, "{tag}: {name}");
8316            }
8317        }
8318    }
8319
8320    #[test]
8321    fn a_kill_takes_the_markup_it_empties_with_it() {
8322        // The same hazard a word-delete has: a WYSIWYG range covers what the
8323        // user can see, which for `**bold**` is the word and never the
8324        // delimiters, so a kill that stopped at the text would leave `a ****` —
8325        // markup wrapped around nothing.
8326        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
8327        d.caret = d.source.find("bold").unwrap();
8328        d.delete_to_line_end();
8329        assert_eq!(d.source, "a \n");
8330    }
8331
8332    #[test]
8333    fn a_kill_is_undone_in_one_step() {
8334        for (view, tag) in VIEWS {
8335            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
8336            d.caret = 3;
8337            d.delete_to_line_end();
8338            assert_eq!(d.source, "one\n", "{tag}");
8339            d.undo();
8340            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
8341        }
8342    }
8343
8344    #[test]
8345    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
8346        // Regression: triple-click used move_home/move_end over visual rows, so
8347        // it only worked on a paragraph's first row (a wrap-boundary offset maps
8348        // to the earlier row). select_block_at reads the AST, so every offset in
8349        // the paragraph selects the whole thing.
8350        let body = "one two three four five six seven eight\n";
8351        let mut d = doc_with("sel_block", body);
8352        d.view = View::Wysiwyg;
8353        d.build_visual(12); // force the paragraph to wrap into several rows
8354        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
8355        let para = (0, "one two three four five six seven eight".len());
8356        for off in [0usize, 8, 19, 28, 38] {
8357            d.caret = 0;
8358            d.anchor = None;
8359            d.select_block_at(off);
8360            assert_eq!(
8361                d.selection(),
8362                Some(para),
8363                "offset {off} should select the paragraph"
8364            );
8365        }
8366    }
8367
8368    #[test]
8369    fn select_block_uses_content_span_for_a_heading() {
8370        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
8371        d.select_block_at(4); // inside "Title"
8372        // content_span excludes the "# " marker.
8373        assert_eq!(d.selected_text(), Some("Title"));
8374        d.select_block_at(10); // inside "body"
8375        assert_eq!(d.selected_text(), Some("body"));
8376    }
8377
8378    #[test]
8379    fn select_all_spans_the_document() {
8380        let mut d = doc_with("sel_all", "abc\n\ndef\n");
8381        d.select_all();
8382        assert_eq!(d.selection(), Some((0, d.source.len())));
8383    }
8384
8385    #[test]
8386    fn select_word_at_picks_the_surrounding_word() {
8387        let mut d = doc_with("sel_word", "hello world\n");
8388        d.select_word_at(8); // inside "world"
8389        assert_eq!(d.selection(), Some((6, 11)));
8390        // Double-clicking at end-of-word still grabs the word to its left.
8391        d.select_word_at(5); // the space between the words
8392        assert_eq!(d.selection(), Some((5, 6)));
8393    }
8394
8395    #[test]
8396    fn word_helpers_respect_utf8_boundaries() {
8397        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
8398        assert_eq!(
8399            golden("utf8", "|café ok", |d| d.move_word_right(false)),
8400            "café| ok"
8401        );
8402        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
8403    }
8404
8405    #[test]
8406    fn typing_inserts_at_the_caret_and_advances_it() {
8407        let mut d = doc_with("type", "hello\n");
8408        d.insert("Hi ");
8409        assert_eq!(d.source, "Hi hello\n");
8410        assert_eq!(d.caret, 3);
8411        assert!(d.dirty);
8412    }
8413
8414    #[test]
8415    fn backspace_deletes_the_char_before_the_caret() {
8416        let mut d = doc_with("bs", "hello\n");
8417        d.caret = 3; // after "hel"
8418        d.backspace();
8419        assert_eq!(d.source, "helo\n");
8420        assert_eq!(d.caret, 2);
8421    }
8422
8423    #[test]
8424    fn typing_replaces_the_selection() {
8425        let mut d = doc_with("replace", "a word b\n");
8426        d.anchor = Some(2);
8427        d.caret = 6; // "word" selected
8428        d.insert("X");
8429        assert_eq!(d.source, "a X b\n");
8430        assert_eq!(d.caret, 3);
8431        assert_eq!(d.anchor, None);
8432    }
8433
8434    #[test]
8435    fn toggle_bold_wraps_then_unwraps_the_selection() {
8436        let mut d = doc_with("bold", "a word b\n");
8437        d.anchor = Some(2);
8438        d.caret = 6;
8439        d.toggle(InlineKind::Strong);
8440        assert_eq!(d.source, "a **word** b\n");
8441        // The toggled region stays selected, so a second toggle reverses it.
8442        d.toggle(InlineKind::Strong);
8443        assert_eq!(d.source, "a word b\n");
8444        d.toggle(InlineKind::Strong);
8445        assert_eq!(d.source, "a **word** b\n");
8446    }
8447
8448    #[test]
8449    fn toggle_code_wraps_then_unwraps_the_selection() {
8450        let mut d = doc_with("code_rt", "a word b\n");
8451        d.anchor = Some(2);
8452        d.caret = 6;
8453        d.toggle(InlineKind::Verbatim);
8454        assert_eq!(d.source, "a `word` b\n");
8455        d.toggle(InlineKind::Verbatim);
8456        assert_eq!(d.source, "a word b\n");
8457    }
8458
8459    #[test]
8460    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
8461        // ⌘b at a bare caret, then type: the text comes out bold with no
8462        // selection ever made — the word-processor "start bold here" gesture.
8463        let mut d = doc_with("sticky_wrap", "xy\n");
8464        d.caret = 1; // between x and y
8465        d.toggle(InlineKind::Strong);
8466        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
8467        d.insert("A");
8468        assert_eq!(d.source, "x**A**y\n");
8469    }
8470
8471    #[test]
8472    fn sticky_bold_lights_the_toolbar_before_any_typing() {
8473        // The button must light the instant ⌘b is pressed, or the mode is
8474        // invisible until the first character lands.
8475        let mut d = doc_with("sticky_light", "xy\n");
8476        d.caret = 1;
8477        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8478        d.toggle(InlineKind::Strong);
8479        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8480    }
8481
8482    #[test]
8483    fn sticky_bold_toggled_off_types_normally_again() {
8484        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
8485        // in the flow of typing, the exact sequence the user described.
8486        let mut d = doc_with("sticky_off", "\n");
8487        d.caret = 0;
8488        d.toggle(InlineKind::Strong);
8489        d.insert("a");
8490        d.insert("b"); // continues inside the run, no re-arming
8491        assert_eq!(d.source, "**ab**\n");
8492        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
8493        d.insert("c");
8494        assert_eq!(d.source, "**ab**c\n");
8495    }
8496
8497    #[test]
8498    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
8499        // Once a mark is realised the caret sits inside the run, so plain typing
8500        // extends it rather than starting a second, adjacent bold span.
8501        let mut d = doc_with("sticky_cont", "\n");
8502        d.caret = 0;
8503        d.toggle(InlineKind::Emph);
8504        d.insert("h");
8505        d.insert("i");
8506        assert_eq!(d.source, "*hi*\n");
8507    }
8508
8509    #[test]
8510    fn moving_the_caret_disarms_a_sticky_mark() {
8511        // Arming a mark and then moving away must not style text elsewhere.
8512        let mut d = doc_with("sticky_disarm", "xy\n");
8513        d.caret = 0;
8514        d.toggle(InlineKind::Strong);
8515        d.move_right(false); // caret 0 → 1, disarms
8516        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8517        d.insert("A");
8518        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
8519    }
8520
8521    #[test]
8522    fn stacked_sticky_marks_apply_together() {
8523        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
8524        let mut d = doc_with("sticky_stack", "\n");
8525        d.caret = 0;
8526        d.toggle(InlineKind::Strong);
8527        d.toggle(InlineKind::Emph);
8528        d.insert("x");
8529        // Land the caret on the styled character and confirm both marks are live.
8530        d.anchor = Some(d.source.find('x').unwrap());
8531        d.caret = d.anchor.unwrap() + 1;
8532        let marks = d.active_inline_marks();
8533        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
8534        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
8535    }
8536
8537    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
8538
8539    #[test]
8540    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
8541        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
8542        // The space inside the run made `**bold **`, which is *not* bold — four
8543        // literal asterisks — so the rich view drew them, correctly and
8544        // uselessly, until the next character happened to close the run again.
8545        let mut d = wysiwyg_doc("edge_typing", "a \n");
8546        d.caret = 2;
8547        d.toggle(InlineKind::Strong);
8548        for c in "bold".chars() {
8549            d.insert(&c.to_string());
8550        }
8551        assert_eq!(d.source, "a **bold**\n");
8552        d.insert(" ");
8553        assert_eq!(
8554            d.source, "a **bold** \n",
8555            "the space belongs outside the run"
8556        );
8557        assert!(
8558            d.active_inline_marks().contains(InlineKind::Strong),
8559            "bold is still what's being typed, so the button stays lit"
8560        );
8561        // What the writer is looking at while all this happens: their words.
8562        d.build_visual(80);
8563        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8564        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
8565        for c in "hey".chars() {
8566            d.insert(&c.to_string());
8567        }
8568        assert_eq!(
8569            d.source, "a **bold hey**\n",
8570            "one bold phrase, not two runs"
8571        );
8572    }
8573
8574    #[test]
8575    fn typing_past_a_space_can_still_leave_the_bold_behind() {
8576        // The other half: the marks stay armed across the space, so ⌘b turns
8577        // them off again there and the next word is plain — the run isn't
8578        // rejoined by a caret that was told not to.
8579        let mut d = wysiwyg_doc("edge_shed", "\n");
8580        d.caret = 0;
8581        d.toggle(InlineKind::Strong);
8582        for c in "bold ".chars() {
8583            d.insert(&c.to_string());
8584        }
8585        assert_eq!(d.source, "**bold** \n");
8586        d.toggle(InlineKind::Strong);
8587        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8588        d.insert("x");
8589        assert_eq!(d.source, "**bold** x\n");
8590    }
8591
8592    #[test]
8593    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
8594        // ⌘b and then a space before any word: the space is not marked (nothing
8595        // is), and the word after it is.
8596        let mut d = wysiwyg_doc("edge_space_first", "a\n");
8597        d.caret = 1;
8598        d.toggle(InlineKind::Strong);
8599        d.insert(" ");
8600        assert_eq!(d.source, "a \n");
8601        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8602        d.insert("b");
8603        assert_eq!(d.source, "a **b**\n");
8604    }
8605
8606    #[test]
8607    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
8608        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
8609        d.caret = 8; // the caret's home at the end of the run's text
8610        d.insert(" ");
8611        assert_eq!(
8612            d.source, "x **bold** \n",
8613            "the space lands past the delimiters"
8614        );
8615        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
8616
8617        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
8618        d.caret = 4; // in front of the "b"
8619        d.insert(" ");
8620        assert_eq!(d.source, "x  **bold** y\n");
8621        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
8622    }
8623
8624    #[test]
8625    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
8626        // Backspace over the last letter of a bold phrase.
8627        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
8628        d.caret = 10; // past the "h"
8629        d.backspace();
8630        assert_eq!(d.source, "a **bold** \n");
8631        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
8632        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8633        d.insert("x");
8634        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
8635    }
8636
8637    #[test]
8638    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
8639        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
8640        // mark, which is only text. The marks live on in the caret instead.
8641        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
8642        d.caret = 5;
8643        d.backspace();
8644        assert_eq!(d.source, "a  c\n");
8645        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8646        d.insert("x");
8647        assert_eq!(d.source, "a **x** c\n");
8648    }
8649
8650    #[test]
8651    fn typing_over_a_whole_bold_word_keeps_it_bold() {
8652        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
8653        d.anchor = Some(4);
8654        d.caret = 8; // the word, not its delimiters
8655        d.insert("x");
8656        assert_eq!(d.source, "a **x** c\n");
8657    }
8658
8659    #[test]
8660    fn a_code_span_keeps_the_space_it_is_given() {
8661        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
8662        // is still verbatim, so nothing is re-spelt. The repair asks the parser
8663        // rather than a table of kinds, and this is the answer it gets.
8664        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
8665        d.caret = 7;
8666        d.insert(" ");
8667        assert_eq!(d.source, "a `code ` c\n");
8668    }
8669
8670    #[test]
8671    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
8672        // A run's closing delimiter has a caret home on each side of it, one
8673        // column apart on screen — and a plain ← off the space after a bold word
8674        // lands on the outer one. The character drawn behind the caret there is
8675        // still the last letter of the phrase, so that is what Backspace takes;
8676        // the byte behind it is a `*` nobody can see.
8677        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
8678        d.caret = 9;
8679        d.move_left(false);
8680        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
8681        d.backspace();
8682        assert_eq!(
8683            d.source, "**bol** x\n",
8684            "a letter of the phrase, not its `*`"
8685        );
8686        assert_eq!(d.caret, 5);
8687
8688        // And the mirror in front of the opening delimiter, where Delete's
8689        // character is the first letter of the run.
8690        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
8691        d.caret = 1;
8692        d.delete_forward();
8693        assert_eq!(d.source, "x**old**\n");
8694        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
8695    }
8696
8697    #[test]
8698    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
8699        // The byte beside the caret at either edge of a bold word is a `*` the
8700        // rich view draws nothing for. Taking it is not the character delete the
8701        // key was pressed for — it unspells the run and puts a literal asterisk
8702        // on screen (`a *bold** c`). The visible character is the one that goes.
8703        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
8704        d.caret = 4; // in front of the "b"
8705        d.backspace();
8706        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
8707
8708        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
8709        d.caret = 8; // past the "d"
8710        d.delete_forward();
8711        assert_eq!(d.source, "a **bold**c\n");
8712        assert_eq!(d.caret, 8, "and the caret stays inside the run");
8713        d.insert("x");
8714        assert_eq!(d.source, "a **boldx**c\n");
8715
8716        // A code span's backticks are hidden the same way, so they are covered
8717        // by the same rule and not by a list of kinds.
8718        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
8719        d.caret = 3;
8720        d.backspace();
8721        assert_eq!(d.source, "a`code` c\n");
8722    }
8723
8724    #[test]
8725    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
8726        // The asterisks are on the screen there and the caret can stand between
8727        // them, so a delete takes exactly the byte it is aimed at.
8728        let mut d = doc_with("edge_open_src", "a **bold** c\n");
8729        d.caret = 4;
8730        d.backspace();
8731        assert_eq!(d.source, "a *bold** c\n");
8732
8733        let mut d = doc_with("edge_close_src", "a **bold** c\n");
8734        d.caret = 8;
8735        d.delete_forward();
8736        assert_eq!(d.source, "a **bold* c\n");
8737    }
8738
8739    #[test]
8740    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
8741        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
8742        // The space had stepped outside the run (the mark-edge rule), taking the
8743        // caret with it, so the delete put it back down on the far side of the
8744        // closing `**` — one place on screen, and the wrong side of it. Typing
8745        // came out plain and the toolbar went dark, with nothing to see.
8746        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
8747        d.caret = 0;
8748        d.toggle(InlineKind::Strong);
8749        for c in "bold".chars() {
8750            d.insert(&c.to_string());
8751        }
8752        d.insert(" ");
8753        assert_eq!(d.source, "**bold** \n");
8754        d.backspace();
8755        assert_eq!(
8756            d.source, "**bold**\n",
8757            "the space goes, the delimiters stay"
8758        );
8759        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
8760        assert!(
8761            d.active_inline_marks().contains(InlineKind::Strong),
8762            "so the button is still lit"
8763        );
8764        d.insert("x");
8765        assert_eq!(
8766            d.source, "**boldx**\n",
8767            "and the next character is still bold"
8768        );
8769    }
8770
8771    #[test]
8772    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
8773        // What the stranded caret did next: the byte behind it was the closing
8774        // `*`, so a second press took that instead of a letter — `**bold*`, the
8775        // styling gone and an asterisk on the screen where the word had been.
8776        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
8777        d.caret = 0;
8778        d.toggle(InlineKind::Strong);
8779        for c in "bold ".chars() {
8780            d.insert(&c.to_string());
8781        }
8782        assert_eq!(d.source, "**bold** \n");
8783        d.backspace();
8784        d.backspace();
8785        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
8786        assert_eq!(d.caret, 5);
8787    }
8788
8789    #[test]
8790    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
8791        // `***both***` closes two runs with one stack of asterisks: the caret has
8792        // to walk in through all of them, or it lands between the emph and the
8793        // strong and types half-marked.
8794        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
8795        d.caret = 11;
8796        d.backspace();
8797        assert_eq!(d.source, "***both***\n");
8798        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
8799        d.insert("x");
8800        assert_eq!(d.source, "***bothx***\n");
8801    }
8802
8803    #[test]
8804    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
8805        // The settle only moves a caret a run actually closed over. Ordinary
8806        // deletes — inside a run, or in plain prose — are untouched.
8807        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
8808        d.caret = 8;
8809        d.backspace();
8810        assert_eq!(d.source, "a **bol** c\n");
8811        assert_eq!(d.caret, 7);
8812
8813        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
8814        d.caret = 5;
8815        d.backspace();
8816        assert_eq!(d.source, "plai\n");
8817        assert_eq!(d.caret, 4);
8818    }
8819
8820    #[test]
8821    fn the_source_view_leaves_a_delete_where_it_landed() {
8822        // The delimiters are on the screen there, so the offset past them is a
8823        // place the caret can be seen to be — nothing to settle.
8824        let mut d = doc_with("edge_bksp_src", "**bold** \n");
8825        d.caret = 9;
8826        d.backspace();
8827        assert_eq!(d.source, "**bold**\n");
8828        assert_eq!(d.caret, 8);
8829    }
8830
8831    #[test]
8832    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
8833        // `***both***` closes two runs with one stack of asterisks; a space that
8834        // clears only the inner one lands against the outer's and breaks that
8835        // instead.
8836        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
8837        d.caret = 9;
8838        d.insert(" ");
8839        assert_eq!(d.source, "a ***both*** \n");
8840        assert_eq!(d.caret, 13);
8841        d.insert("x");
8842        assert_eq!(d.source, "a ***both x***\n");
8843    }
8844
8845    #[test]
8846    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
8847        // The delimiter shuffle is not an edit the writer made, so it is not a
8848        // step they have to undo past.
8849        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
8850        d.caret = 8;
8851        d.insert(" ");
8852        assert_eq!(d.source, "a **bold** \n");
8853        d.undo();
8854        assert_eq!(d.source, "a **bold**\n");
8855    }
8856
8857    #[test]
8858    fn the_source_view_types_the_space_where_it_was_asked_to() {
8859        // The rule is a rich-view courtesy. In the source view the delimiters are
8860        // on the screen and the user is editing the bytes they can see.
8861        let mut d = doc_with("edge_src", "a **bold** c\n");
8862        d.caret = 8;
8863        d.insert(" ");
8864        assert_eq!(d.source, "a **bold ** c\n");
8865    }
8866
8867    #[test]
8868    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
8869        // Double-clicking a word takes the space after it; bolding that must not
8870        // spell `**word **`, which is not bold at all.
8871        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
8872        d.anchor = Some(2);
8873        d.caret = 7; // "word "
8874        d.toggle(InlineKind::Strong);
8875        assert_eq!(d.source, "a **word** b\n");
8876        d.toggle(InlineKind::Strong);
8877        assert_eq!(d.source, "a word b\n");
8878        d.toggle(InlineKind::Strong);
8879        assert_eq!(
8880            d.source, "a **word** b\n",
8881            "reapplying the mark must not wrap stale delimiter offsets"
8882        );
8883        // And a selection of nothing but whitespace has no word to mark.
8884        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
8885        d.anchor = Some(6);
8886        d.caret = 7;
8887        d.toggle(InlineKind::Strong);
8888        assert_eq!(d.source, "a word b\n");
8889        assert!(d.status.is_some());
8890    }
8891
8892    #[test]
8893    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
8894        let mut d = doc_with("head_set", "hello\n");
8895        d.caret = 2; // caret inside the paragraph, no selection
8896        d.set_block(BlockKind::Heading(1));
8897        assert_eq!(d.source, "# hello\n");
8898    }
8899
8900    #[test]
8901    fn set_block_heading_works_in_wysiwyg_view() {
8902        // The app defaults to WYSIWYG; the caret is a source offset either way.
8903        let mut d = wysiwyg_doc("head_wys", "hello\n");
8904        d.caret = 2;
8905        d.set_block(BlockKind::Heading(1));
8906        assert_eq!(d.source, "# hello\n");
8907    }
8908
8909    #[test]
8910    fn toggle_heading_applies_switches_and_reverts() {
8911        let mut d = doc_with("head_toggle", "hello\n");
8912        d.caret = 2;
8913        d.toggle_heading(1);
8914        assert_eq!(d.source, "# hello\n"); // paragraph → H1
8915        d.toggle_heading(2);
8916        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
8917        d.toggle_heading(2);
8918        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
8919    }
8920
8921    #[test]
8922    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
8923        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
8924        // the blank line but the caret rendered on the *next* line, because the
8925        // separator was a non-navigable decoration row. In Preserve flow that
8926        // blank line is a real caret home — the caret must resolve onto it, and
8927        // typing there makes the soft break that continues the paragraph.
8928        let src = "line one:\nsecond line\n";
8929        let mut d = wysiwyg_doc("pre_enter_lineend", src);
8930        d.set_line_flow(LineFlow::Preserve);
8931        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
8932        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
8933        d.newline();
8934        d.build_visual_unwrapped();
8935        assert_eq!(d.source, "line one:\n\nsecond line\n");
8936        assert_eq!(
8937            d.caret, 10,
8938            "caret sits on the new blank line, not the next line"
8939        );
8940        // The blank line is row 1, and the caret resolves onto it — not row 2.
8941        assert_eq!(
8942            d.vmap.pos_of_offset(10),
8943            (1, 0),
8944            "caret renders on the blank row"
8945        );
8946        assert!(
8947            !d.vmap.rows[1].decoration,
8948            "the blank line is navigable in Preserve"
8949        );
8950        // Typing there makes a soft break: one paragraph, three lines.
8951        d.insert("new clause,");
8952        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
8953    }
8954
8955    #[test]
8956    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
8957        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
8958        // that keeps it one paragraph — where Fold would open a second paragraph.
8959        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
8960        d.set_line_flow(LineFlow::Preserve);
8961        d.caret = 3;
8962        d.newline();
8963        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
8964
8965        // End-of-paragraph: Enter then typing continues the same paragraph on a
8966        // new line (a soft break), not a fresh paragraph.
8967        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
8968        d.set_line_flow(LineFlow::Preserve);
8969        d.caret = 3;
8970        d.newline();
8971        d.insert("def");
8972        assert_eq!(
8973            d.source, "abc\ndef\n",
8974            "end-of-line Enter + typing is a soft break"
8975        );
8976    }
8977
8978    #[test]
8979    fn preserve_double_enter_still_makes_a_paragraph() {
8980        // Two Enters in a row promote to a real paragraph break: the second lands
8981        // on the blank line the first opened and takes the empty-line branch.
8982        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
8983        d.set_line_flow(LineFlow::Preserve);
8984        d.caret = 3;
8985        d.newline();
8986        d.newline();
8987        d.insert("def");
8988        assert_eq!(
8989            d.source, "abc\n\ndef\n",
8990            "double Enter is a paragraph break"
8991        );
8992    }
8993
8994    #[test]
8995    fn preserve_backspace_joins_across_a_soft_break() {
8996        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
8997        // soft break it deletes the single newline and joins the two lines.
8998        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
8999        d.set_line_flow(LineFlow::Preserve);
9000        d.build_visual(80);
9001        d.caret = 4; // start of "def", just past the soft break
9002        d.backspace();
9003        assert_eq!(
9004            d.source, "abcdef\n",
9005            "Backspace joins across the soft break"
9006        );
9007        assert_eq!(d.caret, 3, "caret lands where the lines meet");
9008    }
9009
9010    #[test]
9011    fn fold_enter_still_starts_a_new_paragraph() {
9012        // The default flow is unchanged: a lone `\n` would render as an invisible
9013        // space, so Enter keeps opening the paragraph break that actually shows.
9014        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9015        d.caret = 3;
9016        d.newline();
9017        assert_eq!(
9018            d.source, "abc\n\ndef\n",
9019            "Fold mid-line Enter is a paragraph break"
9020        );
9021    }
9022
9023    #[test]
9024    fn wysiwyg_one_enter_starts_a_new_paragraph() {
9025        // Regression: one Enter left the caret between the two newlines, so typing
9026        // made a soft break (one paragraph) and you needed a second Enter.
9027        let mut d = wysiwyg_doc("wys_enter", "abc\n");
9028        d.caret = 3;
9029        d.newline();
9030        d.insert("def");
9031        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9032    }
9033
9034    #[test]
9035    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9036        // Regression: Enter at the caret's natural End-of-line resting place
9037        // after a bold run with nothing following it (on screen: right after
9038        // "bold", before the hidden closing "**") spliced the paragraph break
9039        // at that very byte offset — which sits *before* the closing "**" in
9040        // the source, since the delimiter is hidden and emits no glyph of its
9041        // own for `push_row`'s "end of row" fallback to count. That severed the
9042        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9043        // "**" alone on the new line instead of leaving "**bold**" intact with
9044        // a fresh empty paragraph after it.
9045        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9046        d.move_end(false); // the WYSIWYG End key, from caret 0
9047        assert_eq!(
9048            d.caret, 6,
9049            "caret rests right after \"bold\", before the hidden \"**\""
9050        );
9051        d.newline();
9052        assert!(
9053            d.source.starts_with("**bold**"),
9054            "the closing ** must stay attached to \"bold\": got {:?}",
9055            d.source
9056        );
9057        assert_eq!(
9058            d.source, "**bold**\n\n\n",
9059            "a fresh empty paragraph follows the still-intact bold run"
9060        );
9061    }
9062
9063    #[test]
9064    fn source_view_enter_is_a_single_newline() {
9065        let mut d = doc_with("src_enter", "abc\n");
9066        d.caret = 3;
9067        d.newline();
9068        assert_eq!(d.source, "abc\n\n");
9069    }
9070
9071    #[test]
9072    fn heading_applies_at_the_end_of_a_paragraph() {
9073        // The caret at a line end sits at the doc level; set_block must still find
9074        // the block on that line.
9075        let mut d = doc_with("head_end", "abc\n");
9076        d.caret = 3; // end of "abc"
9077        d.toggle_heading(1);
9078        assert_eq!(d.source, "# abc\n");
9079    }
9080
9081    #[test]
9082    fn heading_on_an_empty_new_paragraph_creates_one() {
9083        let mut d = wysiwyg_doc("head_empty", "abc\n");
9084        d.caret = 3;
9085        d.newline(); // caret now on a fresh, empty paragraph
9086        d.toggle_heading(1);
9087        d.insert("Title");
9088        assert!(d.source.contains("# Title"), "got {:?}", d.source);
9089    }
9090
9091    #[test]
9092    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9093        // The reported bug, end to end: click a blank line with another one under
9094        // it, press H1, type. The text landed in the heading and the caret's
9095        // offset was right (the source view drew it there), but the rich view
9096        // drew it two rows lower, on the trailing blank line — the empty `# `
9097        // heading had left every row below it short by the marker's two bytes,
9098        // and the blank line ended up claiming the heading's own end offset.
9099        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9100        d.build_visual_unwrapped();
9101        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9102        d.toggle_heading(1);
9103        for c in "title".chars() {
9104            d.insert(&c.to_string());
9105            d.build_visual_unwrapped(); // as a frontend does, one frame per key
9106        }
9107        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9108        assert_eq!(
9109            d.caret_pos(),
9110            (4, 5),
9111            "the caret draws at the end of the heading"
9112        );
9113    }
9114
9115    #[test]
9116    fn clicking_an_empty_heading_types_after_its_marker() {
9117        // The same anchor from the other side: the empty heading's row is its own
9118        // caret home, so a click on it must land past the hidden `# `. Landing in
9119        // front of the hashes made the first keystroke un-heading the line.
9120        let mut d = wysiwyg_doc("head_click", "# \n");
9121        d.build_visual_unwrapped();
9122        d.caret = d.vmap.offset_of_pos(0, 0);
9123        d.insert("x");
9124        assert_eq!(d.source, "# x\n");
9125    }
9126
9127    #[test]
9128    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9129        let mut d = wysiwyg_doc("head_enter", "# Title\n");
9130        d.caret = 7; // end of the heading
9131        d.newline();
9132        d.insert("body");
9133        assert_eq!(d.source, "# Title\n\nbody\n");
9134    }
9135
9136    #[test]
9137    fn wysiwyg_enter_continues_a_bullet_list() {
9138        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9139        d.caret = 6; // end of "item"
9140        d.newline();
9141        d.insert("two");
9142        assert_eq!(d.source, "- item\n- two\n");
9143    }
9144
9145    #[test]
9146    fn wysiwyg_enter_increments_an_ordered_list() {
9147        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9148        d.caret = 6; // end of "one"
9149        d.newline();
9150        d.insert("two");
9151        assert_eq!(d.source, "1. one\n2. two\n");
9152    }
9153
9154    #[test]
9155    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9156        // Regression for the "extra newline" left between a list and the paragraph
9157        // below it. Enter, Enter leaves the list on a fresh empty paragraph
9158        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9159        // Backspace should then take the caret cleanly back to the end of the list
9160        // item, `- item\n\nnext`, not delete a single newline and strand it on the
9161        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9162        // no caret can land on. The map is rebuilt between keystrokes exactly as a
9163        // frontend does, since Backspace reads the stop table to place the delete.
9164        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9165        d.caret = 6; // end of "item"
9166        d.newline();
9167        d.build_visual(80);
9168        d.newline(); // leave the list onto a fresh empty paragraph
9169        d.build_visual(80);
9170        assert_eq!(
9171            d.source, "- item\n\n\n\nnext\n",
9172            "double-Enter opens the empty paragraph"
9173        );
9174        d.backspace();
9175        assert_eq!(
9176            d.source, "- item\n\nnext\n",
9177            "one Backspace collapses the whole gap"
9178        );
9179        assert_eq!(
9180            d.caret, 6,
9181            "and lands the caret back at the end of the list item"
9182        );
9183    }
9184
9185    #[test]
9186    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9187        // The stop-wise delete must not over-reach when there is no block boundary
9188        // to cross: two blank lines in a row are one caret stop apart, so pressing
9189        // Enter on an empty line and then Backspace removes exactly the one newline
9190        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9191        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9192        d.caret = 5; // the empty paragraph the first Enter already opened
9193        d.build_visual(80);
9194        d.newline();
9195        d.build_visual(80);
9196        assert_eq!(
9197            d.source, "abc\n\n\n\n",
9198            "Enter on the blank line adds one newline"
9199        );
9200        d.backspace();
9201        assert_eq!(
9202            d.source, "abc\n\n\n",
9203            "Backspace takes back exactly that one newline"
9204        );
9205    }
9206
9207    #[test]
9208    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9209        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9210        d.caret = 6; // end of the empty "- " item
9211        d.newline();
9212        d.insert("p");
9213        assert_eq!(d.source, "- a\n\np\n");
9214    }
9215
9216    #[test]
9217    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
9218        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
9219        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
9220        // not take the list-exit path (which would splice the `- ` away as if
9221        // leaving an empty item); the AST guard sends it to a normal break and
9222        // leaves the underline intact.
9223        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
9224        assert!(
9225            d.nodes().iter().any(|n| n.kind == Kind::Heading),
9226            "precondition: twig parses this as a heading, not a list",
9227        );
9228        d.caret = 7; // on the `- ` underline line
9229        d.newline();
9230        assert!(
9231            d.source.contains("- "),
9232            "the setext underline survives, not spliced away as a list item: {:?}",
9233            d.source,
9234        );
9235    }
9236
9237    #[test]
9238    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
9239        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
9240        d.caret = 7; // end of "abc" inside the fence
9241        d.newline();
9242        d.insert("def");
9243        assert_eq!(d.source, "```\nabc\ndef\n```\n");
9244    }
9245
9246    #[test]
9247    fn wysiwyg_enter_continues_a_block_quote() {
9248        // Enter opens a new *paragraph* inside the quote, not a second line of
9249        // the same one. `> quote\n> more` is a soft break, which under
9250        // `LineFlow::Fold` renders as a space — the keystroke would look like it
9251        // did nothing. The quoted blank line is what makes the break visible, and
9252        // it's the same thing Enter does in running prose.
9253        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
9254        d.caret = 7; // end of "quote"
9255        d.newline();
9256        d.insert("more");
9257        assert_eq!(d.source, "> quote\n>\n> more\n");
9258        // Still one quote, now holding two paragraphs — not a quote and a stray
9259        // line that fell out of it.
9260        let quotes = d
9261            .nodes()
9262            .iter()
9263            .filter(|n| n.kind == Kind::BlockQuote)
9264            .count();
9265        assert_eq!(quotes, 1);
9266    }
9267
9268    #[test]
9269    fn set_block_makes_a_heading_at_the_caret() {
9270        let mut d = doc_with("head", "Title\n\nbody\n");
9271        d.caret = 0;
9272        d.set_block(BlockKind::Heading(2));
9273        assert_eq!(d.source, "## Title\n\nbody\n");
9274        d.set_block(BlockKind::Paragraph);
9275        assert_eq!(d.source, "Title\n\nbody\n");
9276    }
9277
9278    // ── block containers (quote / list) ──────────────────────────────────────
9279
9280    #[test]
9281    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
9282        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
9283        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
9284        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
9285        // A caret at a line end sits at the doc level; the block is still found.
9286        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
9287    }
9288
9289    #[test]
9290    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
9291        // Every source line of the paragraph gets its own `> `, so a caret left
9292        // on its old byte offset falls one prefix per line above it too far
9293        // back — inside the markup it just asked for rather than in its word.
9294        assert_eq!(
9295            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
9296            "> aaa\n> b|bb\n> ccc\n"
9297        );
9298    }
9299
9300    #[test]
9301    fn toggle_blockquote_works_in_wysiwyg_view() {
9302        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9303        assert_eq!(
9304            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
9305            "> hel|lo\n"
9306        );
9307        assert_eq!(
9308            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
9309            "hel|lo\n"
9310        );
9311    }
9312
9313    #[test]
9314    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
9315        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
9316        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9317        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9318        // The *other* kind converts in place instead of nesting, which is what
9319        // makes the two buttons one three-state control.
9320        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9321        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9322        // Its own kind, over the only item the list holds, takes it off.
9323        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
9324    }
9325
9326    #[test]
9327    fn toggle_list_works_in_wysiwyg_view() {
9328        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9329        assert_eq!(
9330            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
9331            "1. hel|lo\n"
9332        );
9333        assert_eq!(
9334            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
9335            "- hel|lo\n"
9336        );
9337        assert_eq!(
9338            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
9339            "hel|lo\n"
9340        );
9341    }
9342
9343    #[test]
9344    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
9345        // The selection has to grow with the markup: twig takes a container off
9346        // only a range covering every block it holds, so the second press can
9347        // reverse the first only if the result is what's selected.
9348        let mut d = doc_with("list_sel", "abc\n\ndef\n");
9349        d.select_all();
9350        d.toggle_list(true);
9351        assert_eq!(d.source, "1. abc\n\n2. def\n");
9352        assert_eq!(d.selection(), Some((0, d.source.len())));
9353        d.toggle_list(true);
9354        assert_eq!(d.source, "abc\n\ndef\n");
9355    }
9356
9357    #[test]
9358    fn toggle_blockquote_nests_a_partly_covered_quote() {
9359        // twig's rule: covering only some of a container's blocks nests, because
9360        // taking the quote off would drag its uncovered siblings out with it.
9361        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
9362        d.caret = 2; // in the first quoted paragraph only
9363        d.toggle_blockquote();
9364        assert_eq!(d.source, "> > a\n>\n> b\n");
9365    }
9366
9367    #[test]
9368    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
9369        // A blank line used to be no block for twig to wrap —
9370        // `toggle_block_container` answered `NotFound` — so Quote and the list
9371        // buttons did nothing on the very line the H1 button works on, and leaf
9372        // lent twig a scratch paragraph to wrap and took it back out again.
9373        // twig 3.2.0 opens an empty container there itself, so what is left here
9374        // is where the caret lands: inside the marker that was just written.
9375        let mut d = doc_with("quote_blank", "\nabc\n");
9376        d.caret = 0;
9377        d.toggle_blockquote();
9378        assert_eq!(d.source, "> \nabc\n");
9379        assert_eq!(
9380            d.caret, 2,
9381            "the caret belongs inside the quote it just opened"
9382        );
9383        assert!(d.status.is_none(), "{:?}", d.status);
9384        assert!(d.dirty);
9385
9386        // And the paragraph below is still its own block: an empty container one
9387        // soft break from `abc` would take that paragraph into the quote with it.
9388        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
9389        d.caret = 0;
9390        d.toggle_blockquote();
9391        d.build_visual(80);
9392        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
9393
9394        // The same from the other side: a blank line directly under a paragraph
9395        // earns the blank line an empty block needs, rather than being read as a
9396        // soft break inside that paragraph.
9397        let mut d = doc_with("list_blank_below", "abc\n");
9398        d.caret = 4;
9399        d.toggle_list(false);
9400        assert_eq!(d.source, "abc\n\n- ");
9401        assert_eq!(d.caret, 7);
9402    }
9403
9404    #[test]
9405    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
9406        // The gesture the rendering fix is for. `newline` inside a quote already
9407        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
9408        // spelling — but the two marker lines it adds belonged to no node until
9409        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
9410        // just made drew as plain prose under the quote.
9411        let mut d = wysiwyg_doc("quote_enter", "> a\n");
9412        d.caret = 3; // past `a`, at the end of the quoted line
9413        d.newline();
9414        assert_eq!(d.source, "> a\n>\n> \n");
9415        d.build_visual(80);
9416        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
9417        // And the caret is on the new line, not stranded on the old one.
9418        assert_eq!(d.caret, 8);
9419    }
9420
9421    #[test]
9422    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
9423        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
9424        // now it is twig's single edit. Either way one ⌘z has to put the blank
9425        // line back rather than undoing into a half-built document.
9426        for open in [
9427            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
9428            &|d: &mut Doc| d.toggle_list(false),
9429            &|d: &mut Doc| d.toggle_list(true),
9430        ] {
9431            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
9432            d.caret = 3;
9433            open(&mut d);
9434            assert_ne!(d.source, "a\n\n\n\nb\n");
9435            d.undo();
9436            assert_eq!(d.source, "a\n\n\n\nb\n");
9437        }
9438    }
9439
9440    #[test]
9441    fn a_container_toggle_is_one_undo_step() {
9442        let mut d = doc_with("quote_undo", "hello\n");
9443        d.caret = 3;
9444        d.insert("X"); // a typing run the structural edit must not fold into
9445        d.toggle_blockquote();
9446        assert_eq!(d.source, "> helXlo\n");
9447        d.undo();
9448        assert_eq!(d.source, "helXlo\n");
9449    }
9450
9451    // ── links ────────────────────────────────────────────────────────────────
9452
9453    #[test]
9454    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
9455        let mut d = doc_with("link_sel", "word here\n");
9456        d.anchor = Some(0);
9457        d.caret = 4;
9458        d.insert_link("http://x.dev");
9459        assert_eq!(d.source, "[word](http://x.dev) here\n");
9460        // The text, not the destination — so a second press re-points the link
9461        // the first one made rather than nesting one inside it.
9462        assert_eq!(d.selected_text(), Some("word"));
9463        d.insert_link("http://y.dev");
9464        assert_eq!(d.source, "[word](http://y.dev) here\n");
9465        assert_eq!(d.selected_text(), Some("word"));
9466    }
9467
9468    #[test]
9469    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
9470        let mut d = doc_with("img_caret", "before after\n");
9471        d.caret = 7; // between "before " and "after"
9472        d.insert_image("cat.png", "a cat");
9473        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
9474        // The caret sits just past the inserted image, nothing selected.
9475        assert_eq!(d.selection(), None);
9476        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
9477    }
9478
9479    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
9480    /// destination at the first space, so the `format!` this used to be wrote
9481    /// something that was not an image at all — and the reader saw the markup as
9482    /// text. twig owns the spelling now, and moves it into the angle form.
9483    #[test]
9484    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
9485        let mut d = doc_with("img_space", "x\n");
9486        d.caret = 0;
9487        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
9488        assert_eq!(
9489            d.source,
9490            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
9491        );
9492        // And it reads back as an image pointing at the unescaped path — the angle
9493        // brackets are spelling, not part of the destination.
9494        d.caret = 2;
9495        assert_eq!(
9496            d.image_destination_at_caret(),
9497            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
9498        );
9499    }
9500
9501    /// A `)` in a caption or a filename must not close the image early.
9502    #[test]
9503    fn insert_image_escapes_a_paren_in_either_half() {
9504        let mut d = doc_with("img_paren", "x\n");
9505        d.caret = 0;
9506        d.insert_image("a)b.png", "");
9507        assert_eq!(d.source, "![](a\\)b.png)x\n");
9508        d.caret = 2;
9509        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
9510    }
9511
9512    #[test]
9513    fn insert_image_uses_the_selection_as_alt_text() {
9514        let mut d = doc_with("img_sel", "caption here\n");
9515        d.anchor = Some(0);
9516        d.caret = 7; // "caption"
9517        d.insert_image("p.png", "ignored fallback");
9518        assert_eq!(d.source, "![caption](p.png) here\n");
9519    }
9520
9521    #[test]
9522    fn insert_image_with_no_alt_leaves_empty_brackets() {
9523        let mut d = doc_with("img_noalt", "\n");
9524        d.caret = 0;
9525        d.insert_image("logo.svg", "");
9526        assert_eq!(d.source, "![](logo.svg)\n");
9527    }
9528
9529    #[test]
9530    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
9531        // The round trip is the point: it's no use writing markup the reader
9532        // can't pick up again. This is the pair that only holds from twig 2.5.1
9533        // on — before it, the one-line form went in fine and came back as a
9534        // paragraph of raw tags, publishing no media at all.
9535        let mut d = doc_with("vid_rt", "\n");
9536        d.caret = 0;
9537        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
9538        assert_eq!(
9539            d.source,
9540            "<video src=\"clip.mp4\" controls>a clip</video>\n"
9541        );
9542
9543        d.build_visual(80);
9544        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
9545        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
9546        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
9547        assert_eq!(d.vmap.media[0].alt, "a clip");
9548    }
9549
9550    #[test]
9551    fn insert_media_spells_audio_with_its_own_tag() {
9552        let mut d = doc_with("aud_rt", "\n");
9553        d.caret = 0;
9554        d.insert_media(MediaKind::Audio, "take.mp3", "");
9555        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
9556        d.build_visual(80);
9557        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
9558    }
9559
9560    #[test]
9561    fn insert_media_uses_the_selection_as_fallback_text() {
9562        // The same courtesy `insert_image` does with alt: select a caption,
9563        // insert, and the caption labels the thing rather than being replaced.
9564        let mut d = doc_with("vid_sel", "the talk here\n");
9565        d.anchor = Some(0);
9566        d.caret = 8; // "the talk"
9567        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
9568        assert_eq!(
9569            d.source,
9570            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
9571        );
9572    }
9573
9574    #[test]
9575    fn insert_media_with_an_image_kind_is_just_insert_image() {
9576        let mut d = doc_with("img_via_media", "\n");
9577        d.caret = 0;
9578        d.insert_media(MediaKind::Image, "logo.svg", "x");
9579        assert_eq!(d.source, "![x](logo.svg)\n");
9580    }
9581
9582    // ── thematic breaks ─────────────────────────────────────────────────────
9583
9584    /// The node the source parses as at `caret` — what confirms an inserted
9585    /// `---` actually reads back as a rule, not stray text or a setext heading.
9586    ///
9587    /// The *narrowest* node covering the offset. Every ancestor covers it too,
9588    /// and since twig 2.8 that includes the `doc` root, which now carries a real
9589    /// span (it reported none before, so taking the first match used to land on
9590    /// the block by luck and now always answers `"doc"`).
9591    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
9592        d.nodes()
9593            .into_iter()
9594            .filter(|n| n.span.start <= caret && caret < n.span.end)
9595            .min_by_key(|n| n.span.end - n.span.start)
9596            .map(|n| n.kind)
9597    }
9598
9599    #[test]
9600    fn a_task_box_toggles_at_the_caret_and_reads_back() {
9601        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
9602        d.caret = 8; // inside "todo"
9603        assert_eq!(d.task_checked_at_caret(), Some(false));
9604        d.toggle_task_checked();
9605        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
9606        assert_eq!(d.task_checked_at_caret(), Some(true));
9607        d.toggle_task_checked();
9608        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
9609    }
9610
9611    #[test]
9612    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
9613        // The whole reason `toggle_task_at` exists apart from the caret form:
9614        // ticking a box elsewhere must not move the cursor out of what's being
9615        // typed.
9616        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
9617        d.caret = 8; // inside "first"
9618        let second = d.source.find("second").unwrap();
9619        d.toggle_task_at(second);
9620        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
9621        assert_eq!(d.caret, 8, "the caret stayed in the first item");
9622    }
9623
9624    #[test]
9625    fn a_plain_item_gains_and_loses_a_box() {
9626        let mut d = doc_with("task_mint", "- plain\n");
9627        d.caret = 4;
9628        assert_eq!(d.task_checked_at_caret(), None);
9629        d.toggle_task_item();
9630        assert_eq!(d.source, "- [ ] plain\n");
9631        assert_eq!(
9632            d.task_checked_at_caret(),
9633            Some(false),
9634            "a new box arrives unticked"
9635        );
9636        d.toggle_task_item();
9637        assert_eq!(d.source, "- plain\n");
9638    }
9639
9640    #[test]
9641    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
9642        // `set checked` must not silently convert a bullet into a task — that is
9643        // `toggle_task_item`'s job, and twig refuses it here.
9644        let mut d = doc_with("task_none", "- plain\n");
9645        d.caret = 4;
9646        d.toggle_task_checked();
9647        assert_eq!(d.source, "- plain\n", "nothing written");
9648        assert!(
9649            d.status.is_some(),
9650            "the refusal should reach the status line"
9651        );
9652    }
9653
9654    #[test]
9655    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
9656        let mut d = doc_with("task_quote", "> - [ ] nested\n");
9657        d.caret = d.source.find("nested").unwrap();
9658        assert_eq!(d.task_checked_at_caret(), Some(false));
9659        d.toggle_task_checked();
9660        assert_eq!(d.source, "> - [x] nested\n");
9661    }
9662
9663    #[test]
9664    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
9665        // A rule is a block, so twig's `insert_thematic_break` alone lands it
9666        // after the whole paragraph. `split_block` parts the paragraph first and
9667        // the rule is aimed at the *first* half, which is what a rule button is
9668        // understood to do — and what leaf spelled by hand until twig grew both
9669        // halves of the gesture.
9670        let mut d = doc_with("hr_mid", "before after\n");
9671        d.caret = 7; // between "before " and "after"
9672        d.insert_thematic_break();
9673        assert_eq!(d.source, "before \n\n---\n\nafter\n");
9674        assert_eq!(d.selection(), None);
9675        assert_eq!(
9676            kind_at(&mut d, "before \n\n".len()),
9677            Some(Kind::ThematicBreak)
9678        );
9679    }
9680
9681    #[test]
9682    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
9683        // At the end there is nothing to part, and a split there writes the
9684        // separator anyway — a blank line and the empty slot the next paragraph
9685        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
9686        // two blank lines nothing fills. Now the rule lands after the paragraph,
9687        // where the split-and-aim was sending it regardless. Both formats, and
9688        // both shapes of a last line — terminated, and still being typed —
9689        // because the two reach the split through different doors: Markdown's
9690        // paragraph span stops before its newline, so `para\n` at 4 never split
9691        // there, but `para` at 4 did.
9692        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9693            for src in ["para\n", "para"] {
9694                let mut d = Doc::from_source(src.into(), fmt).unwrap();
9695                d.caret = 4;
9696                d.insert_thematic_break();
9697                assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
9698                assert_eq!(d.caret, d.source.len());
9699            }
9700            // Mid-document the slot sat between the rule and the next block.
9701            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
9702            d.caret = 4;
9703            d.insert_thematic_break();
9704            assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
9705            // Trailing whitespace is nothing to part either.
9706            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
9707            d.caret = 4;
9708            d.insert_thematic_break();
9709            assert_eq!(d.source, format!("para  \n\n{rule}\n"), "{fmt:?}");
9710        }
9711    }
9712
9713    #[test]
9714    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
9715        // The split at the start parts nothing, but it is kept on purpose:
9716        // `|para` becomes `\npara` with the caret on a blank line, and twig
9717        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
9718        // way "before the paragraph" is reachable through a gesture that only
9719        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
9720        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9721            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
9722            d.caret = 0;
9723            d.insert_thematic_break();
9724            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
9725            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
9726            d.caret = 6;
9727            d.insert_thematic_break();
9728            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
9729        }
9730    }
9731
9732    #[test]
9733    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
9734        // The gap between two blocks is where a click lands the caret; the
9735        // rule goes on the blank, one blank each side.
9736        let mut d = doc_with("hr_gap", "a\n\nb\n");
9737        d.caret = 2;
9738        d.insert_thematic_break();
9739        assert_eq!(d.source, "a\n\n---\n\nb\n");
9740    }
9741
9742    #[test]
9743    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
9744        // The same door as the rule's, through the placement they share.
9745        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9746        d.caret = 4;
9747        d.insert_table(1, 1);
9748        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
9749        let mut d = doc_with("table_end_typed", "para");
9750        d.caret = 4;
9751        d.insert_table(1, 1);
9752        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
9753        assert!(d.caret_in_table());
9754    }
9755
9756    #[test]
9757    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
9758        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
9759        // and leaf wrote the first into both until twig started spelling it.
9760        let mut md = doc_with("hr_md", "para\n");
9761        md.caret = 2;
9762        md.insert_thematic_break();
9763        assert_eq!(md.source, "pa\n\n---\n\nra\n");
9764
9765        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9766        dj.caret = 2;
9767        dj.insert_thematic_break();
9768        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
9769    }
9770
9771    #[test]
9772    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
9773        // The table goes *at* the caret the way the rule does: the paragraph is
9774        // parted first, and twig writes the grid after its first half. The
9775        // caret then sits in the first header cell — selected, as Tab would
9776        // leave it — so the next keystroke is the heading.
9777        let mut d = doc_with("table_mid", "before after\n");
9778        d.caret = 7;
9779        d.insert_table(2, 3);
9780        assert_eq!(
9781            d.source,
9782            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
9783        );
9784        assert!(d.caret_in_table());
9785        let first_bar = d.source.find('|').unwrap();
9786        assert!(
9787            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
9788            "caret {} is not in the header row",
9789            d.caret
9790        );
9791        d.insert("Name");
9792        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
9793        // And the grid the table was written into is one the table keys walk
9794        // (over the map a frontend rebuilds after every edit).
9795        d.build_visual(80);
9796        assert!(d.cell_tab(true));
9797        d.insert("Qty");
9798        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
9799    }
9800
9801    #[test]
9802    fn insert_table_spells_the_grid_the_format_s_own_way() {
9803        // Djot's delimiter row is unpadded, and leaf never has to know that.
9804        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9805        dj.caret = 2;
9806        dj.insert_table(1, 2);
9807        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
9808        assert!(dj.caret_in_table());
9809    }
9810
9811    #[test]
9812    fn insert_table_refuses_where_the_format_spells_no_table() {
9813        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
9814        d.caret = 4;
9815        d.insert_table(1, 1);
9816        assert_eq!(d.source, "<p>ab</p>\n");
9817        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
9818        assert!(!d.capabilities().table);
9819    }
9820
9821    #[test]
9822    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
9823        let mut d = doc_with("table_zero", "para\n");
9824        d.caret = 2;
9825        d.insert_table(0, 2);
9826        assert_eq!(d.source, "para\n");
9827        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
9828    }
9829
9830    #[test]
9831    fn clicking_below_a_final_thematic_break_can_type_after_it() {
9832        let mut d = wysiwyg_doc("hr_final_click", "---\n");
9833        d.build_visual(80);
9834        d.click(d.vmap.num_rows() + 2, 0, false);
9835        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
9836        d.insert("after");
9837        assert_eq!(d.source, "---\nafter");
9838    }
9839
9840    #[test]
9841    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
9842        // The same bytes are two documents. In Markdown `  - b` is a nested item
9843        // and the next one belongs beside it, at its indent. In Djot a list
9844        // marker can't interrupt a paragraph, so those bytes are literal text in
9845        // item `a` and there is only one item — writing `  - ` under it would add
9846        // no item at all, just more text, and the new sibling has to go to
9847        // column zero. Both spellings come out of the *enclosing item's* line.
9848        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
9849        md.caret = "- a\n  - b".len();
9850        md.newline();
9851        assert_eq!(md.source, "- a\n  - b\n  - \n");
9852        assert_eq!(list_items(&mut md), 3);
9853
9854        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
9855        dj.view = View::Wysiwyg;
9856        dj.build_visual(80);
9857        dj.caret = "- a\n  - b".len();
9858        dj.newline();
9859        assert_eq!(dj.source, "- a\n  - b\n- \n");
9860        assert_eq!(list_items(&mut dj), 2);
9861
9862        // Where Djot's nesting is real — opened by a blank line — the indent is
9863        // reproduced there too, and the two formats agree again.
9864        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
9865        dj.view = View::Wysiwyg;
9866        dj.build_visual(80);
9867        dj.caret = "- a\n\n  - b".len();
9868        dj.newline();
9869        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
9870        assert_eq!(list_items(&mut dj), 3);
9871    }
9872
9873    #[test]
9874    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
9875        // Tab replaces the line's whole prefix with the one twig spells, so the
9876        // quote markers, the parent's indent and an ordered marker's extra
9877        // column are all its answer rather than leaf's arithmetic.
9878        for (name, body, caret, want) in [
9879            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
9880            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
9881            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
9882            // A checkbox is markup the item's own text wraps past, but a nested
9883            // list may only open at the *list* marker's column — four in from
9884            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
9885            // parses as one item, not two.
9886            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
9887            (
9888                "quoted task",
9889                "> - [ ] a\n> - [ ] b\n",
9890                18,
9891                "> - [ ] a\n>   - [ ] b\n",
9892            ),
9893        ] {
9894            let mut doc = wysiwyg_doc(name, body);
9895            doc.caret = caret;
9896            doc.indent();
9897            assert_eq!(doc.source, want, "{name}");
9898            // The nesting is real, not just indented text.
9899            assert_eq!(list_items(&mut doc), 2, "{name}");
9900        }
9901    }
9902
9903    #[test]
9904    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
9905        // The same bytes, the two formats disagreeing, and a gesture that used
9906        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
9907        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
9908        // so those bytes are literal text inside item `a` — there is nothing to
9909        // outdent, and treating them as a marker turned one item into two, a
9910        // structural edit from a keystroke that should delete one character.
9911        //
9912        // twig's `line_prefix` is what tells them apart: it reports the marker
9913        // on the Markdown line and nothing on the Djot one, which is a
9914        // continuation. No byte scan can reach that answer.
9915        let src = "- a\n  - b\n";
9916        let at = "- a\n  - ".len();
9917
9918        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
9919        md.view = View::Wysiwyg;
9920        md.build_visual(80);
9921        md.caret = at;
9922        md.backspace();
9923        assert_eq!(md.source, "- a\n- b\n");
9924        assert_eq!(list_items(&mut md), 2);
9925
9926        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
9927        dj.view = View::Wysiwyg;
9928        dj.build_visual(80);
9929        dj.caret = at;
9930        dj.backspace();
9931        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
9932        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
9933    }
9934
9935    #[test]
9936    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
9937        // Leaf used to spell the next item from the marker bytes it scanned, and
9938        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
9939        // and dropped out of the checklist. twig reproduces the whole
9940        // continuation, and a fresh item is always unticked however the one above
9941        // it stands.
9942        for (name, body, want) in [
9943            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
9944            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
9945        ] {
9946            let mut doc = wysiwyg_doc(name, body);
9947            doc.caret = body.trim_end_matches('\n').len();
9948            doc.newline();
9949            assert_eq!(doc.source, want, "{name}");
9950            // Both items are checklist items — the new one is a box, not the
9951            // plain bullet the old marker scan left behind — and it is unticked
9952            // whichever way the one above it faces.
9953            let boxes: Vec<Option<bool>> = doc
9954                .nodes()
9955                .iter()
9956                .filter(|n| n.kind == Kind::TaskListItem)
9957                .map(|n| n.checked)
9958                .collect();
9959            assert_eq!(boxes.len(), 2, "{name}");
9960            assert_eq!(boxes[1], Some(false), "{name}");
9961        }
9962    }
9963
9964    #[test]
9965    fn a_split_takes_the_space_the_caret_was_in_front_of() {
9966        // Splicing a break at the caret strands the space the words were parted
9967        // at on the head of the second block, where it reads as an indent nobody
9968        // typed. twig's split consumes it.
9969        for (name, body, caret, want) in [
9970            ("para", "one two\n", 3, "one\n\ntwo\n"),
9971            ("item", "- one two\n", 5, "- one\n- two\n"),
9972            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
9973            // A heading takes leaf's own path, which has to match.
9974            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
9975        ] {
9976            let mut doc = wysiwyg_doc(name, body);
9977            doc.caret = caret;
9978            doc.newline();
9979            assert_eq!(doc.source, want, "{name}");
9980        }
9981    }
9982
9983    #[test]
9984    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
9985        // The one place leaf keeps its own break: `split_block` repeats the `#`,
9986        // and Enter after a title is how the body under it is asked for.
9987        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
9988        doc.caret = "# Title".len();
9989        doc.newline();
9990        doc.insert("body");
9991        assert_eq!(doc.source, "# Title\n\nbody\n");
9992        assert_eq!(
9993            doc.nodes()
9994                .iter()
9995                .filter(|n| n.kind == Kind::Heading)
9996                .count(),
9997            1
9998        );
9999    }
10000
10001    #[test]
10002    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
10003        // A quoted item's marker doesn't open its line, so a scan that starts at
10004        // column zero finds a `>` where it wanted a bullet, calls the line "not a
10005        // list" and hands Enter to the plain-quote branch — which writes `> ` and
10006        // drops the list. The next item has to carry the whole prefix.
10007        for (name, body, want) in [
10008            ("flat", "> - a\n", "> - a\n> - \n"),
10009            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10010            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
10011            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
10012            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
10013        ] {
10014            let mut doc = wysiwyg_doc(name, body);
10015            doc.caret = body.trim_end_matches('\n').len();
10016            doc.newline();
10017            assert_eq!(doc.source, want, "{name}");
10018            // The marker isn't just spelled right, it parses as an item.
10019            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
10020        }
10021    }
10022
10023    #[test]
10024    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
10025        // Double-Enter exits the list. Unquoted that means a blank line, but a
10026        // *bare* blank line would end the quote too and drop the caret out of it,
10027        // so the separator keeps its `>` and the caret's line keeps its `> `.
10028        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
10029        doc.caret = "> - a\n> - ".len();
10030        doc.newline();
10031        assert_eq!(doc.source, "> - a\n>\n> \n");
10032        assert_eq!(list_items(&mut doc), 1);
10033        // What "still in the quote" means for the next keystroke: the caret sits
10034        // behind the prefix, and what's typed there lands inside the quote as a
10035        // paragraph of its own — not as more of item `a`.
10036        doc.insert("x");
10037        assert_eq!(doc.source, "> - a\n>\n> x\n");
10038        assert!(
10039            doc.editor
10040                .ancestors_at(doc.caret - 1)
10041                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
10042        );
10043    }
10044
10045    #[test]
10046    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
10047        // The marker is hidden block markup, so Backspace over it is structural —
10048        // but only the marker is the list's. Splicing from the line start would
10049        // take the `>` with it and silently unquote the line.
10050        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
10051        doc.caret = "> - ".len();
10052        doc.backspace();
10053        assert_eq!(doc.source, "> a\n");
10054        assert_eq!(list_items(&mut doc), 0);
10055
10056        // A nested one outdents instead, moving the bullet within the quote
10057        // rather than moving the quote.
10058        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
10059        doc.caret = "> - a\n>   - ".len();
10060        doc.backspace();
10061        assert_eq!(doc.source, "> - a\n> - b\n");
10062        assert_eq!(list_items(&mut doc), 2);
10063    }
10064
10065    #[test]
10066    fn only_a_bare_paragraph_is_parted_around_the_caret() {
10067        // The split is deliberately narrow. Parting a fenced block would leave
10068        // two fences with a rule between them, and parting a list item would
10069        // mint an item nobody asked for on the way to a rule that lands after
10070        // the list either way — so both keep the whole block intact and take the
10071        // rule after it. A caret in a quote is likewise left alone.
10072        for (name, body, caret, want) in [
10073            (
10074                "code",
10075                "```\nfn x() {}\n```\n",
10076                8,
10077                "```\nfn x() {}\n```\n\n---\n",
10078            ),
10079            ("list", "- one two\n", 6, "- one two\n\n---\n"),
10080            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
10081        ] {
10082            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
10083            d.caret = caret;
10084            d.insert_thematic_break();
10085            assert_eq!(d.source, want, "{name}: the block should stay whole");
10086        }
10087    }
10088
10089    #[test]
10090    fn insert_thematic_break_replaces_the_selection() {
10091        // Now that the rule lands *at* the caret again, replacing the selection
10092        // is coherent once more: the text goes, and the rule takes its place.
10093        // The space the deletion left leading the second half is consumed by the
10094        // split rather than opening the new paragraph with it.
10095        let mut d = doc_with("hr_sel", "one two three\n");
10096        d.anchor = Some(4);
10097        d.caret = 7; // "two"
10098        d.insert_thematic_break();
10099        assert_eq!(d.source, "one \n\n---\n\nthree\n");
10100        assert_eq!(d.selection(), None);
10101    }
10102
10103    #[test]
10104    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
10105        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
10106        // because writing `---` into one is code, not a rule — twig now walks out
10107        // to the block that owns the caret's line, so there is nothing to refuse.
10108        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
10109        code.caret = 5; // inside the fenced code
10110        code.insert_thematic_break();
10111        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
10112        assert_eq!(code.status, None, "no refusal to report any more");
10113
10114        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
10115        table.caret = 3; // in the header row
10116        table.insert_thematic_break();
10117        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
10118    }
10119
10120    #[test]
10121    fn insert_thematic_break_in_a_list_item_ends_the_list() {
10122        // The un-indented rule cannot continue the list, so it closes the list
10123        // and lands at the top level rather than nested inside it.
10124        let mut d = doc_with("hr_list", "- one\n- two\n");
10125        d.caret = "- one\n- tw".len(); // mid "two"
10126        d.insert_thematic_break();
10127        d.build_visual(80);
10128        let rule_at = d.source.find("---").unwrap();
10129        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10130        assert!(
10131            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
10132                && n.span.start <= rule_at
10133                && rule_at < n.span.end),
10134            "the rule must not be nested inside the list"
10135        );
10136    }
10137
10138    #[test]
10139    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
10140        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
10141        // which is the document the gesture was actually asked for.
10142        let mut d = doc_with("hr_quote", "> hello\n");
10143        d.caret = 4; // inside the quoted text
10144        d.insert_thematic_break();
10145        assert_eq!(d.source, "> hello\n>\n> ---\n");
10146        d.build_visual(80);
10147        let rule_at = d.source.find("---").unwrap();
10148        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10149        assert!(
10150            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
10151                && n.span.start <= rule_at
10152                && rule_at < n.span.end),
10153            "the rule belongs to the quote it was asked for"
10154        );
10155    }
10156
10157    // ── typing against a block picture ────────────────────────────────────────
10158
10159    /// A rendered-view document with the caret parked on one of the picture's two
10160    /// stops, and the map already built — the state a frontend is in between
10161    /// drawing a frame and the next keystroke.
10162    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
10163        let mut d = doc_in(View::Wysiwyg, name, src);
10164        d.build_visual_unwrapped();
10165        let start = src.find("![").unwrap();
10166        d.caret = match side {
10167            MediaStop::Before => start,
10168            MediaStop::After => start + "![](p.png)".len(),
10169        };
10170        d
10171    }
10172
10173    /// The block media the map publishes, after rebuilding it — "is this still a
10174    /// picture, or has it become a line of text with an image in it?"
10175    fn media_count(d: &mut Doc) -> usize {
10176        d.build_visual_unwrapped();
10177        d.vmap.media.len()
10178    }
10179
10180    #[test]
10181    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
10182        // The accident this prevents: tap the blank page under a photo (which
10183        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
10184        // paragraph with an *inline* image — the photo stops being drawn.
10185        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
10186        d.insert("xy");
10187        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
10188        assert_eq!(media_count(&mut d), 1, "still a picture");
10189    }
10190
10191    #[test]
10192    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
10193        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
10194        d.insert("xy");
10195        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
10196        assert_eq!(media_count(&mut d), 1);
10197    }
10198
10199    #[test]
10200    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
10201        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
10202        d.insert("x");
10203        assert_eq!(d.source, "x\n\n![](p.png)\n");
10204        assert_eq!(media_count(&mut d), 1);
10205    }
10206
10207    #[test]
10208    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
10209        // The opened paragraph is part of the keystroke, not an edit the writer
10210        // made — so it undoes with the character, not a step later.
10211        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
10212        d.insert("x");
10213        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
10214        d.undo();
10215        assert_eq!(d.source, "hi\n\n![](p.png)\n");
10216    }
10217
10218    #[test]
10219    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
10220        // ⌘V dissolves the picture exactly as a keystroke does.
10221        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
10222        d.paste("pasted");
10223        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
10224        assert_eq!(media_count(&mut d), 1);
10225    }
10226
10227    #[test]
10228    fn typing_beside_an_inline_image_is_ordinary_editing() {
10229        // An inline image has no placeholder row and no stops of its own. Opening
10230        // a paragraph mid-sentence would be the bug, not the fix.
10231        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
10232        d.build_visual_unwrapped();
10233        d.caret = "see ![](p.png)".len();
10234        d.insert("!");
10235        assert_eq!(d.source, "see ![](p.png)! here\n");
10236    }
10237
10238    #[test]
10239    fn source_view_types_raw_markup_against_an_image_untouched() {
10240        // Source view is for writing the markup itself; a break inserted behind
10241        // the writer's back there would be the editor arguing with them.
10242        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
10243        d.caret = "![](p.png)".len();
10244        d.insert("x");
10245        assert_eq!(d.source, "![](p.png)x\n");
10246    }
10247
10248    #[test]
10249    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
10250        // A selection is replaced, not joined into, so there is nothing to
10251        // protect: the range takes the picture with it.
10252        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
10253        d.anchor = Some(d.caret);
10254        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
10255        d.insert("x");
10256        assert_eq!(d.source, "hi\n\nx\n");
10257    }
10258
10259    #[test]
10260    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
10261        // What this actually cost: a real vault's photo, to one stray Backspace.
10262        // The caret past `![](p.png)` was deleting the closing paren — invisible
10263        // in the rendered view — and the photo became the text `![](p.png`.
10264        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
10265        d.backspace();
10266        assert_eq!(d.source, "hi\n");
10267        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
10268        d.undo();
10269        assert_eq!(
10270            d.source, "hi\n\n![](p.png)\n",
10271            "and comes back in one piece"
10272        );
10273    }
10274
10275    #[test]
10276    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
10277        // Deleting the break here would join the picture to the paragraph above,
10278        // where it is an *inline* image and stops being drawn. Step over the
10279        // boundary; the next press deletes in the paragraph the caret reached.
10280        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
10281        d.backspace();
10282        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
10283        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
10284        d.backspace();
10285        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
10286        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
10287    }
10288
10289    #[test]
10290    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
10291        // The mirror. A byte-step here eats the `!` and leaves a link.
10292        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
10293        d.delete_forward();
10294        assert_eq!(d.source, "hi\n\nbye\n");
10295        assert_eq!(media_count(&mut d), 0);
10296    }
10297
10298    #[test]
10299    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
10300        let mut d = doc_at_picture(
10301            "pic_del_after",
10302            "hi\n\n![](p.png)\n\nbye\n",
10303            MediaStop::After,
10304        );
10305        d.delete_forward();
10306        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
10307        assert_eq!(
10308            d.caret,
10309            d.source.find("bye").unwrap(),
10310            "the caret stepped down to `bye`"
10311        );
10312    }
10313
10314    #[test]
10315    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
10316        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
10317        d.backspace();
10318        assert_eq!(d.source, "\n");
10319        assert_eq!(media_count(&mut d), 0);
10320    }
10321
10322    #[test]
10323    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
10324        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
10325        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
10326        d.delete_word_back();
10327        assert_eq!(d.source, "hi there\n");
10328
10329        // And in front of one it runs *through* the paragraph break into the
10330        // prose above, which merges the picture inline — so it steps out first,
10331        // and the second press deletes the word it was aimed at.
10332        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
10333        d.delete_word_back();
10334        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
10335        d.delete_word_back();
10336        assert_eq!(
10337            d.source, "hi \n\n![](p.png)\n",
10338            "the word above went, the picture stayed"
10339        );
10340        assert_eq!(media_count(&mut d), 1);
10341    }
10342
10343    #[test]
10344    fn source_view_deletes_raw_markup_against_an_image_untouched() {
10345        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
10346        d.caret = "![](p.png)".len();
10347        d.backspace();
10348        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
10349    }
10350
10351    #[test]
10352    fn image_destination_at_caret_reads_the_image_under_the_caret() {
10353        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
10354        d.caret = 3; // inside the image markup
10355        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
10356        // Past the image, the caret is in no image.
10357        d.caret = "![a cat](cat.png)".len();
10358        assert_eq!(d.image_destination_at_caret(), None);
10359    }
10360
10361    #[test]
10362    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
10363        // The image is one placeholder row by default, and `set_media_rows` grows
10364        // it to the height the frontend measured: the label row plus blank
10365        // `decoration` fillers that hold the vertical space a raster is drawn into.
10366        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
10367        assert_eq!(d.vmap.media.len(), 1);
10368        let img_row = d.vmap.media[0].rows_span.start;
10369        assert_eq!(
10370            d.vmap.media[0].rows_span,
10371            img_row..img_row + 1,
10372            "default is one row"
10373        );
10374
10375        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
10376        d.build_visual(80);
10377        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
10378        let span = d.vmap.media[0].rows_span.clone();
10379        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
10380        // The label row carries the mark and its glyphs; the three below are blank
10381        // decoration — drawn, but no caret and no text.
10382        assert!(
10383            d.vmap.rows[span.start].media.is_some(),
10384            "mark rides the first row"
10385        );
10386        for r in (span.start + 1)..span.end {
10387            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
10388            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
10389            assert!(
10390                d.vmap.rows[r].media.is_none(),
10391                "only the first row is marked"
10392            );
10393        }
10394    }
10395
10396    #[test]
10397    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
10398        // The extra rows are pure spacers: the caret's only homes stay the stop in
10399        // front of the image and the one just past it, so walking the document top
10400        // to bottom visits the same offsets whether the image is 1 row or 5.
10401        let body = "ab\n\n![x](p.png)\n\ncd\n";
10402        let stops_at = |rows: usize| -> Vec<usize> {
10403            let mut d = wysiwyg_doc("img_stops", body);
10404            if rows > 1 {
10405                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
10406                d.build_visual(80);
10407            }
10408            d.caret = 0;
10409            let mut seen = vec![d.caret];
10410            loop {
10411                d.move_right(false);
10412                if *seen.last().unwrap() == d.caret {
10413                    break;
10414                }
10415                seen.push(d.caret);
10416            }
10417            seen
10418        };
10419        assert_eq!(
10420            stops_at(1),
10421            stops_at(5),
10422            "reserving rows must not add stops"
10423        );
10424    }
10425
10426    #[test]
10427    fn insert_link_repoints_the_link_at_a_bare_caret() {
10428        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
10429        d.caret = 3; // in the link's text, nothing selected
10430        d.insert_link("http://y.dev");
10431        assert_eq!(d.source, "[word](http://y.dev)\n");
10432        assert_eq!(d.selected_text(), Some("word"));
10433    }
10434
10435    #[test]
10436    fn insert_link_on_an_empty_range_autolinks_a_url() {
10437        // A link with no text of its own is an autolink, and twig spells it —
10438        // `<…>` is the canonical form and needs no text typed into it, so the
10439        // caret lands after it rather than selecting a finished link.
10440        let mut d = doc_with("link_empty", "\n");
10441        d.caret = 0;
10442        d.insert_link("http://x.dev");
10443        assert_eq!(d.source, "<http://x.dev>\n");
10444        assert_eq!(d.selection(), None);
10445        assert_eq!(d.caret, 14);
10446    }
10447
10448    #[test]
10449    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
10450        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
10451        // in Markdown, so a destination that can't autolink doubles as the text
10452        // instead — which is then selected, ready to be typed over.
10453        let mut d = doc_with("link_rel", "\n");
10454        d.caret = 0;
10455        d.insert_link("./notes.md");
10456        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
10457        assert_eq!(d.selection(), Some((1, 11)));
10458        d.insert("Notes");
10459        assert_eq!(d.source, "[Notes](./notes.md)\n");
10460    }
10461
10462    #[test]
10463    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
10464        // The autolink's text is its URL, so re-pointing replaces the whole
10465        // node — the caret must not splice a second link inside the first.
10466        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
10467        d.caret = 10;
10468        d.insert_link("https://y.dev");
10469        assert_eq!(d.source, "see <https://y.dev> ok\n");
10470    }
10471
10472    #[test]
10473    fn code_language_reads_and_edits_through_the_fence() {
10474        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
10475        d.caret = 10; // inside the code body
10476        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10477        assert!(d.caret_in_fenced_code());
10478
10479        d.set_code_language("python");
10480        assert!(
10481            d.source.starts_with("```python\n"),
10482            "source: {:?}",
10483            d.source
10484        );
10485        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
10486
10487        // Clearing it leaves a bare fence and no label.
10488        d.set_code_language("");
10489        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
10490        assert_eq!(d.code_language_at_caret(), None);
10491
10492        // A caret outside any code block edits nothing.
10493        let mut p = doc_with("code_lang_none", "just prose\n");
10494        assert!(!p.caret_in_fenced_code());
10495        p.set_code_language("rust");
10496        assert_eq!(p.source, "just prose\n");
10497    }
10498
10499    #[test]
10500    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
10501        // Markdown's info string ends at whitespace, so `two words` would write
10502        // a fence that reads back with a different language than the one asked
10503        // for. twig refuses it; leaf reports that and leaves the source alone.
10504        // The old splice trimmed the ends and wrote whatever was left.
10505        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
10506        d.caret = 10;
10507        d.set_code_language("two words");
10508        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
10509        assert!(d.status.is_some(), "the refusal should be reported");
10510        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10511    }
10512
10513    #[test]
10514    fn link_destination_at_caret_reads_both_spellings() {
10515        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
10516        d.caret = 5;
10517        assert_eq!(
10518            d.link_destination_at_caret().as_deref(),
10519            Some("https://x.dev")
10520        );
10521        d.caret = 0;
10522        assert_eq!(d.link_destination_at_caret(), None);
10523
10524        // An autolink has no `destination`; its text is the URL.
10525        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
10526        a.caret = 10;
10527        assert_eq!(
10528            a.link_destination_at_caret().as_deref(),
10529            Some("https://x.dev")
10530        );
10531        a.caret = 21;
10532        assert_eq!(a.link_destination_at_caret(), None);
10533    }
10534
10535    #[test]
10536    fn locate_finds_the_block_a_declared_id_names() {
10537        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
10538        // verse. The locator has to land on the *verse*, which is the whole
10539        // reason a link carries one.
10540        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
10541                   {#v2}\nYea, I make a record in the language of my father.\n";
10542        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10543        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
10544        assert_eq!(
10545            d.source[v2.start..v2.end].trim_end(),
10546            "Yea, I make a record in the language of my father."
10547        );
10548        // The attribute line is not part of it: `start` is a place to put a
10549        // caret, and `{#v2}` is markup the caret has no business landing in.
10550        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
10551        assert_eq!(d.locate("v99"), None);
10552    }
10553
10554    #[test]
10555    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
10556        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
10557        // Markdown heading is literal text. So `#the-second-part` can only be
10558        // the heading's own words, which is the rule every Markdown renderer
10559        // already follows and therefore the one a link was authored against.
10560        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
10561        let mut d = doc_with("locate_md", src);
10562        let hit = d.locate("the-second-part").expect("the heading's slug");
10563        assert!(d.source[hit.start..].starts_with("## The Second Part"));
10564        // Bounded by the next heading that isn't under it, so a peek shows the
10565        // section rather than only its title.
10566        assert_eq!(
10567            &d.source[hit.start..hit.end],
10568            "## The Second Part\n\nbody\n\n"
10569        );
10570
10571        // A subsection does not end its parent: `# Title` runs to `## Third`'s
10572        // sibling only because there is no other `#`, so it covers the lot.
10573        let title = d.locate("title").expect("the top heading");
10574        assert_eq!(title.end, d.source.len());
10575    }
10576
10577    #[test]
10578    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
10579        // djot mints `Some-Heading-Here`; a link to it is written
10580        // `#some-heading-here` by nearly everything that writes links. Both
10581        // spellings are one question.
10582        let src = "## Some Heading Here\n\nbody\n";
10583        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10584        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
10585        let slugged = d.locate("some-heading-here").expect("the link's spelling");
10586        assert_eq!(exact, slugged);
10587        // The section, not the heading line — there is more to show than a title.
10588        assert_eq!(&d.source[exact.start..exact.end], src);
10589    }
10590
10591    #[test]
10592    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
10593        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
10594        assert_eq!(d.locate(""), None);
10595        assert_eq!(d.locate("   "), None);
10596        // All punctuation: it names nothing, and must not be read as "match the
10597        // first heading whose slug is also empty".
10598        assert_eq!(d.locate("!!!"), None);
10599    }
10600
10601    #[test]
10602    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
10603        // The document's mistake, and the answer every other anchor
10604        // implementation gives — the alternative is for a link to mean whichever
10605        // of the two a walk happened to reach first.
10606        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
10607        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10608        let hit = d.locate("dup").expect("the first `{#dup}`");
10609        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
10610    }
10611
10612    #[test]
10613    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
10614        // The button's whole job: a reference where the caret was, a definition
10615        // to give it meaning, and the caret waiting in the empty note so the
10616        // next keystroke is the note's first word.
10617        let mut d = doc_with("fn_insert", "A claim and more.\n");
10618        d.caret = 7; // just past "A claim"
10619        d.insert_footnote();
10620        assert!(
10621            d.source.starts_with("A claim[^1] and more."),
10622            "{:?}",
10623            d.source
10624        );
10625        assert!(
10626            d.source.contains("[^1]:"),
10627            "the definition too: {:?}",
10628            d.source
10629        );
10630        assert_eq!(d.status, None);
10631
10632        let reference = d.source.find("[^1]").unwrap();
10633        let note = d
10634            .footnote_at(reference + 2)
10635            .expect("the reference just written");
10636        assert_eq!(note.label, "1");
10637        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
10638        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
10639        // …and typing there is typing into the note, not near it.
10640        d.insert("the note");
10641        assert_eq!(
10642            d.footnote_at(reference + 2).and_then(|f| f.text),
10643            Some("the note".to_string())
10644        );
10645    }
10646
10647    #[test]
10648    fn insert_footnote_numbers_past_the_notes_already_written() {
10649        // A second press must not hand back a label somebody else is using: twig
10650        // reuses a defined label rather than appending a rival definition, so a
10651        // repeat of `1` would quietly point the new reference at the old note.
10652        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
10653        d.caret = 7; // past `[^1]`, before " two."
10654        d.insert_footnote();
10655        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
10656        assert_eq!(d.source.matches("[^2]:").count(), 1);
10657    }
10658
10659    #[test]
10660    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
10661        // `[^2]` with no definition is still a 2 that means something to whoever
10662        // wrote it — stepping over it would mint a note for their reference. A
10663        // word label takes no number, so it blocks none.
10664        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
10665        d.caret = d.source.find(" c").unwrap();
10666        d.insert_footnote();
10667        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
10668        assert!(
10669            d.source.starts_with("a[^2] b[^why][^1] c"),
10670            "{:?}",
10671            d.source
10672        );
10673    }
10674
10675    #[test]
10676    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
10677        // A reference annotates the words before it. Consuming the selection —
10678        // which is what an insert normally does — would delete the very claim
10679        // the author selected in order to footnote.
10680        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
10681        d.anchor = Some(2);
10682        d.caret = 7; // "claim" selected
10683        d.insert_footnote();
10684        assert!(
10685            d.source.starts_with("A claim[^1] and more."),
10686            "{:?}",
10687            d.source
10688        );
10689    }
10690
10691    #[test]
10692    fn a_note_just_written_still_knows_where_its_reference_is() {
10693        // The authoring loop in one test: press the button, type the note, ask to
10694        // go back. The caret ends at the note's last byte — which is the *end* of
10695        // the definition's span, the one offset the query used to exclude — so
10696        // this is where the round trip either works or doesn't.
10697        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
10698        d.caret = 7;
10699        d.insert_footnote();
10700        d.insert("the note");
10701        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
10702        let back = d
10703            .footnote_definition_at_caret()
10704            .expect("still in the note we just typed");
10705        assert_eq!(back.label, "1");
10706        // …and following it lands on the reference's label, where a reader's
10707        // return leg lands.
10708        assert_eq!(back.offset, Some(9));
10709        assert_eq!(&d.source[9..10], "1");
10710    }
10711
10712    #[test]
10713    fn insert_footnote_takes_one_undo_for_both_halves() {
10714        // twig writes the pair as a single edit; the point of that is here.
10715        let before = "A claim and more.\n";
10716        let mut d = doc_with("fn_insert_undo", before);
10717        d.caret = 7;
10718        d.insert_footnote();
10719        assert_ne!(d.source, before);
10720        d.undo();
10721        assert_eq!(d.source, before, "one undo takes back both halves");
10722    }
10723
10724    #[test]
10725    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
10726        // HTML is authorable — it spells the inline marks — and has no footnote.
10727        // The refusal says so rather than writing brackets that would render as
10728        // brackets.
10729        let src = "<p>A claim.</p>\n";
10730        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
10731        assert!(!Capabilities::of(Format::Html).footnote);
10732        d.caret = 5;
10733        d.insert_footnote();
10734        assert_eq!(d.source, src, "nothing written");
10735        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
10736    }
10737
10738    #[test]
10739    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
10740        // The empty body is the one place this could go wrong: the definition
10741        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
10742        // byte early would draw up in the paragraph above the note it belongs to.
10743        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
10744        d.place_caret(7, false);
10745        d.insert_footnote();
10746        d.build_visual(80); // the frame a frontend draws after the edit
10747        assert_eq!(
10748            d.vmap.snap_to_stop(d.caret),
10749            d.caret,
10750            "the caret sits on a stop"
10751        );
10752        let (row, _) = d.caret_pos();
10753        assert!(
10754            drawn_rows(&d)[row].contains("[1]"),
10755            "the caret is on the note's row, not above it: {:?}",
10756            drawn_rows(&d)
10757        );
10758    }
10759
10760    #[test]
10761    fn footnote_at_caret_resolves_a_reference_to_its_note() {
10762        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
10763        // blank line, as one has to.
10764        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
10765        d.caret = 9;
10766        let f = d
10767            .footnote_at_caret()
10768            .expect("the caret stands in a reference");
10769        assert_eq!(f.label, "1");
10770        assert_eq!(f.text.as_deref(), Some("the note"));
10771        // The offset points at the note's first word, not at the definition's
10772        // `[` — the marker is decoration with no caret stop on it.
10773        assert_eq!(f.offset, Some(29));
10774        assert_eq!(&d.source[29..37], "the note");
10775        // …and `end` closes the range, so a frontend can ask which rendered rows
10776        // the note occupies rather than re-deriving them from the text.
10777        assert_eq!(f.end, Some(37));
10778        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
10779    }
10780
10781    /// Two definitions in a row: each is its own note, and neither reaches into
10782    /// the other.
10783    ///
10784    /// A djot definition's span used to run past the blank line into the first
10785    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
10786    /// offsets named the *next* note's rows too, showing a reader two footnotes
10787    /// when they had asked about one. twig 3.1 ends the span after the block's
10788    /// own last line; the test outlives the workaround leaf carried for it.
10789    #[test]
10790    fn footnote_at_stops_a_note_at_the_definition_after_it() {
10791        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
10792        for format in [Format::Markdown, Format::Djot] {
10793            let mut d = Doc::from_source(src.to_string(), format).unwrap();
10794            d.caret = 7;
10795            let f = d.footnote_at_caret().expect("a reference");
10796            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
10797            assert_eq!(
10798                &src[f.offset.unwrap()..f.end.unwrap()],
10799                "first note.",
10800                "in {format:?}"
10801            );
10802        }
10803    }
10804
10805    /// The other side of that boundary: a blank line *inside* a definition is
10806    /// interior to it, and the note keeps its second paragraph.
10807    ///
10808    /// This is what the old body scan cost. It stopped at the first line not
10809    /// indented under the note — a blank line is not — so a two-paragraph note
10810    /// came back as its first paragraph, and "go to note" framed half of it.
10811    /// Reading the span twig gives is both simpler and right.
10812    #[test]
10813    fn footnote_at_keeps_a_notes_second_paragraph() {
10814        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
10815        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10816        d.caret = 7;
10817        let f = d.footnote_at_caret().expect("a reference");
10818        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
10819        // And it stops there — `After.` is the next block, not more note.
10820        assert_eq!(
10821            &src[f.offset.unwrap()..f.end.unwrap()],
10822            f.text.as_deref().unwrap()
10823        );
10824        assert!(!f.text.as_deref().unwrap().contains("After"));
10825    }
10826
10827    #[test]
10828    fn footnote_at_bounds_a_note_whose_body_is_empty() {
10829        // `[^1]:` with nothing after it. The range is empty rather than
10830        // inverted, and still points inside the definition — which is what keeps
10831        // a frontend's row lookup from walking off into the block above.
10832        let src = "A claim[^1].\n\n[^1]:\n";
10833        let mut d = doc_with("fn_empty_body", src);
10834        d.caret = 9;
10835        let f = d.footnote_at_caret().expect("a reference");
10836        assert_eq!(f.text.as_deref(), Some(""));
10837        assert_eq!(f.offset, f.end, "an empty note is an empty range");
10838        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
10839    }
10840
10841    #[test]
10842    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
10843        let mut d = doc_with(
10844            "fn_at_caret_none",
10845            "A claim[^1] and more.\n\n[^1]: the note\n",
10846        );
10847        d.caret = 2; // in the prose
10848        assert_eq!(d.footnote_at_caret(), None);
10849    }
10850
10851    #[test]
10852    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
10853        // The two are deliberately separate: a reference names a note in this
10854        // document, a link names somewhere to leave for, and answering one with
10855        // the other is what made a reference click do nothing at all.
10856        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
10857        d.caret = 3; // the `1` of `[^1]`
10858        assert!(d.footnote_at_caret().is_some());
10859        assert_eq!(
10860            d.link_destination_at_caret(),
10861            None,
10862            "a reference is not a link"
10863        );
10864
10865        d.caret = 10; // inside the link's label
10866        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
10867        assert_eq!(
10868            d.link_destination_at_caret().as_deref(),
10869            Some("https://x.dev")
10870        );
10871    }
10872
10873    #[test]
10874    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
10875        // A `[^99]` the document never defines is a real state — a note deleted
10876        // out from under its reference — and the label is what lets a frontend
10877        // say so. `None` here would be indistinguishable from "not on a
10878        // reference", which is the wrong thing to tell a reader.
10879        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
10880        d.caret = 9;
10881        let f = d
10882            .footnote_at_caret()
10883            .expect("the reference is still a reference");
10884        assert_eq!(f.label, "99");
10885        assert_eq!(f.text, None);
10886        assert_eq!(f.offset, None);
10887    }
10888
10889    #[test]
10890    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
10891        // Labels are not always numbers, and a note's body runs past its first
10892        // line — the indented continuation belongs to the note, so it comes back
10893        // with it (source bytes, verbatim, as documented).
10894        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
10895        let mut d = doc_with("fn_word_label", src);
10896        d.caret = 6;
10897        let f = d
10898            .footnote_at_caret()
10899            .expect("the caret stands in a reference");
10900        assert_eq!(f.label, "note");
10901        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
10902    }
10903
10904    #[test]
10905    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
10906        // The point of the offset form: a pointer hovering a reference asks what
10907        // note it names, and must not drag the caret along to ask.
10908        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
10909        d.caret = 0;
10910        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
10911        assert_eq!(f.label, "1");
10912        assert_eq!(f.text.as_deref(), Some("the note"));
10913        assert_eq!(d.caret, 0, "asking must not move the caret");
10914        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
10915    }
10916
10917    #[test]
10918    fn footnote_definition_at_caret_points_back_at_the_reference() {
10919        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
10920        // the caret can rest on — is at 9.
10921        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
10922        d.caret = 30; // inside the note's body
10923        let f = d
10924            .footnote_definition_at_caret()
10925            .expect("the caret stands in a definition");
10926        assert_eq!(f.label, "1");
10927        assert_eq!(f.offset, Some(9));
10928        assert_eq!(&d.source[7..11], "[^1]");
10929    }
10930
10931    #[test]
10932    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
10933        // The two legs have to meet: wherever `footnote_at` sends the caret, the
10934        // definition query must answer for — otherwise arriving at a note leaves
10935        // the reader somewhere the way back isn't offered.
10936        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
10937        let mut d = doc_with("fn_def_marker", src);
10938        let landed = d.footnote_at(9).unwrap().offset.unwrap();
10939        assert_eq!(
10940            d.footnote_definition_at(landed).and_then(|f| f.offset),
10941            Some(9),
10942            "the note a reference sends you to offers the way back"
10943        );
10944    }
10945
10946    #[test]
10947    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
10948        // The two queries answer for disjoint places, which is what lets one
10949        // gesture mean "down to the note" in one and "back up" in the other
10950        // without either having to remember which way the reader is going.
10951        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
10952        d.caret = 2; // prose
10953        assert_eq!(d.footnote_definition_at_caret(), None);
10954        d.caret = 9; // the reference
10955        assert_eq!(d.footnote_definition_at_caret(), None);
10956        assert!(
10957            d.footnote_at_caret().is_some(),
10958            "which is the reference's own query"
10959        );
10960    }
10961
10962    #[test]
10963    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
10964        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
10965        // note", which is false and leaves a frontend unable to explain why the
10966        // way back is missing.
10967        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
10968        let mut d = doc_with("fn_def_orphan", src);
10969        d.caret = src.find("orphan").unwrap();
10970        let f = d
10971            .footnote_definition_at_caret()
10972            .expect("an orphan is still a definition");
10973        assert_eq!(f.label, "2");
10974        assert_eq!(f.offset, None);
10975    }
10976
10977    #[test]
10978    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
10979        // One label, cited twice. The first is where the reader most likely came
10980        // from, and the only answer that doesn't depend on how they got here.
10981        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
10982        let mut d = doc_with("fn_def_repeat", src);
10983        d.caret = src.find("the note").unwrap();
10984        let f = d.footnote_definition_at_caret().expect("a definition");
10985        assert_eq!(
10986            f.offset,
10987            Some(5),
10988            "the first `[^a]`'s label, not the second's"
10989        );
10990        assert_eq!(&src[3..7], "[^a]");
10991    }
10992
10993    #[test]
10994    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
10995        // Down and back up, each leg found from the document rather than from a
10996        // memory of the other — so it still works for a reader who scrolled to
10997        // the notes instead of jumping there.
10998        //
10999        // `place_caret` rather than assigning `caret`, because that is what a
11000        // frontend calls: it snaps to a real caret stop, and a jump that lands
11001        // on a byte the caret can't rest on would arrive somewhere the return
11002        // leg no longer answers for. `build_map` first, since snapping is a
11003        // no-op until the map exists — which is exactly how this went unnoticed
11004        // when the offsets pointed at the `[^` markers.
11005        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
11006        d.build_map(None);
11007        d.place_caret(9, false);
11008        let down = d
11009            .footnote_at_caret()
11010            .expect("a reference")
11011            .offset
11012            .expect("a note");
11013        d.place_caret(down, false);
11014        let up = d
11015            .footnote_definition_at_caret()
11016            .expect("a definition")
11017            .offset
11018            .expect("a reference");
11019        d.place_caret(up, false);
11020        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
11021        assert_eq!(
11022            d.footnote_at_caret().expect("back on the reference").label,
11023            "1"
11024        );
11025    }
11026
11027    #[test]
11028    fn insert_link_hands_the_destination_to_twig_raw() {
11029        // Escaping is twig's, and format-specific: Markdown ends a destination
11030        // at the first space and needs the `<…>` form, where djot would read
11031        // those angle brackets as part of the URL.
11032        let mut d = doc_with("link_space", "word\n");
11033        d.anchor = Some(0);
11034        d.caret = 4;
11035        d.insert_link("a b");
11036        assert_eq!(d.source, "[word](<a b>)\n");
11037    }
11038
11039    #[test]
11040    fn insert_link_reports_a_destination_no_format_can_carry() {
11041        let mut d = doc_with("link_bad", "word\n");
11042        d.anchor = Some(0);
11043        d.caret = 4;
11044        d.insert_link("a\nb");
11045        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
11046        assert!(
11047            d.status.is_some(),
11048            "InvalidArgument should reach the status line"
11049        );
11050        assert!(!d.dirty);
11051    }
11052
11053    #[test]
11054    fn insert_link_works_in_wysiwyg_view() {
11055        let mut d = wysiwyg_doc("link_wys", "word here\n");
11056        d.anchor = Some(0);
11057        d.caret = 4;
11058        d.insert_link("http://x.dev");
11059        assert_eq!(d.source, "[word](http://x.dev) here\n");
11060        assert_eq!(d.selected_text(), Some("word"));
11061        // The map the caret has to keep riding is rebuilt each frame; motion
11062        // over the fresh one must still land on a real stop (the debug_assert).
11063        d.build_visual(80);
11064        d.move_right(false);
11065        d.move_left(false);
11066    }
11067
11068    #[test]
11069    fn click_maps_a_row_col_to_a_byte_offset() {
11070        let mut d = doc_with("click", "ab\ncd\n");
11071        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
11072        assert_eq!(d.caret, 4);
11073    }
11074
11075    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
11076    // stop just as the `(row, col)` click path does, so the caret can never come
11077    // to rest in the blank gap between two paragraphs — where it would draw in one
11078    // place and type in another.
11079    #[test]
11080    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
11081        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
11082        // caret stop (stops are 0,1,3,4).
11083        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
11084        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
11085        d.place_caret(2, false);
11086        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11087        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
11088    }
11089
11090    #[test]
11091    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
11092        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
11093        d.place_caret(0, false); // anchor at the start of "A"
11094        d.place_caret(2, true); // drag into the gap
11095        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11096        let (s, e) = d.selection().expect("a selection");
11097        assert!(
11098            d.vmap.is_stop(s) && d.vmap.is_stop(e),
11099            "selection {s}..{e} off a stop"
11100        );
11101    }
11102
11103    #[test]
11104    fn place_caret_on_a_real_stop_is_left_untouched() {
11105        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
11106        d.place_caret(3, false); // the start of "B" — a genuine stop
11107        assert_eq!(d.caret, 3);
11108    }
11109
11110    // An *empty paragraph* (two blank lines, an intentional blank line the user
11111    // opened) is a real caret stop, unlike the gap — a click into it must stay.
11112    #[test]
11113    fn place_caret_rests_in_an_empty_paragraph() {
11114        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
11115        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
11116        assert!(d.vmap.is_stop(empty));
11117        d.place_caret(empty, false);
11118        assert_eq!(d.caret, empty);
11119    }
11120
11121    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
11122    // a drag over the word `bold` ends there, and a caret placed there stays.
11123    #[test]
11124    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
11125        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
11126        let mut d = wysiwyg_doc("place_mark_end", src);
11127        let start = src.find("bold").unwrap();
11128        d.place_caret(start, false);
11129        d.place_caret(start + 4, true);
11130        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
11131        d.toggle(InlineKind::Strong);
11132        assert_eq!(d.source, src.replace("**bold**", "bold"));
11133    }
11134
11135    #[test]
11136    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
11137        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
11138        d.caret = 7; // before the `d`
11139        d.move_right(false);
11140        assert_eq!(d.caret, 8, "onto the end of the bold");
11141        assert!(d.active_inline_marks().contains(InlineKind::Strong));
11142        d.move_right(false);
11143        assert_eq!(d.caret, 10, "past the closing `**`");
11144        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
11145        d.move_left(false);
11146        assert_eq!(d.caret, 8);
11147        d.move_left(false);
11148        assert_eq!(d.caret, 7);
11149        // Typing at the inner home extends the bold.
11150        d.caret = 8;
11151        d.insert("!");
11152        assert_eq!(d.source, "a **bold!** b");
11153    }
11154
11155    #[test]
11156    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
11157        // The splice path shifts the home with the block it is in, and the
11158        // re-rendered block finds its own again.
11159        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
11160        d.build_visual_unwrapped();
11161        d.edit(0, 0, "zz");
11162        d.build_visual_unwrapped();
11163        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
11164        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11165        let at = d.source.find("bold").unwrap();
11166        d.edit(at, at, "very ");
11167        d.build_visual_unwrapped();
11168        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
11169        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11170    }
11171
11172    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
11173        doc_in(View::Wysiwyg, name, body)
11174    }
11175
11176    /// How many list items the source actually parses into — the check that a
11177    /// marker Leaf wrote is a marker the format agrees is one.
11178    fn list_items(doc: &mut Doc) -> usize {
11179        doc.editor
11180            .nodes()
11181            .unwrap()
11182            .iter()
11183            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
11184            .count()
11185    }
11186
11187    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
11188    /// incremental (`build_spliced` / `build_cached`) path must always match.
11189    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
11190        reference_map_revealing(source, None)
11191    }
11192
11193    /// [`reference_map`] with a reveal line — the ground truth for the
11194    /// `MarkupMode::Full` builds, where the map is a function of the caret's
11195    /// line as well as the text.
11196    fn reference_map_revealing(
11197        source: &str,
11198        reveal: Option<Range<usize>>,
11199    ) -> crate::wysiwyg::VisualMap {
11200        // The same parse `Doc` uses. With twig's plain defaults instead, the two
11201        // sides disagree on what the *document* is before the renderer is even
11202        // reached — a bare `:word` is a text directive to one and prose to the
11203        // other — and the mismatch reads as a splice bug that isn't one.
11204        let mut ed =
11205            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
11206        let nodes = ed.nodes().unwrap();
11207        crate::wysiwyg::build(
11208            &nodes,
11209            source,
11210            None,
11211            false,
11212            &std::collections::HashMap::new(),
11213            reveal,
11214        )
11215    }
11216
11217    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
11218        if a.rows.len() != b.rows.len() {
11219            return true;
11220        }
11221        for (ra, rb) in a.rows.iter().zip(&b.rows) {
11222            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
11223                return true;
11224            }
11225            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
11226                if ga.ch != gb.ch || ga.src != gb.src {
11227                    return true;
11228                }
11229            }
11230        }
11231        false
11232    }
11233
11234    #[test]
11235    fn incremental_build_matches_a_fresh_build_across_edits() {
11236        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
11237        // fast path, gated on twig's `dirty_range`) or falls back to
11238        // `build_cached`. After each edit the map must be byte-identical to a
11239        // from-scratch build — this is the correctness net under the splice.
11240        let docs = [
11241            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
11242            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
11243            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
11244            // A footnote definition is a root beside `doc`, merged back into the
11245            // top-level list by `wysiwyg::top_blocks`. The random edits below
11246            // make and unmake definitions as they go (a deleted `:` turns one
11247            // back into a paragraph, and vice versa), which is exactly the
11248            // structural churn the splice path has to notice and bail out of.
11249            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
11250            // A comment is a top-level block that draws no rows — a layout entry
11251            // at zero rows either side of blocks that do. The edits below type
11252            // into the blocks around it (a splice past a hidden block), and
11253            // break the comment open into prose and back (a structural change).
11254            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
11255            // Link reference definitions: a hidden block that an edit can turn
11256            // into a paragraph (a deleted `:`) and back, and whose own bytes an
11257            // edit can land in.
11258            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
11259        ];
11260        // A deterministic mix: mostly single characters (which stay inside one
11261        // block → splice), plus edits that reshape structure (a paragraph break,
11262        // a heading marker, a code fence → fallback), so both paths are exercised.
11263        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
11264        for src in docs {
11265            let mut d = wysiwyg_doc("diff", src);
11266            d.build_visual_unwrapped();
11267            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
11268
11269            for step in 0..60usize {
11270                let len = d.source.len();
11271                let raw = (step * 13 + 5) % (len + 1);
11272                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11273                let pre = d.source.clone();
11274                let action;
11275                if step % 3 == 0 && pos < len {
11276                    let end = (pos + 1..=len)
11277                        .find(|&i| d.source.is_char_boundary(i))
11278                        .unwrap();
11279                    action = format!("delete [{pos},{end})");
11280                    d.edit(pos, end, "");
11281                } else {
11282                    let ins = inserts[step % inserts.len()];
11283                    action = format!("insert {ins:?} @ {pos}");
11284                    d.edit(pos, pos, ins);
11285                }
11286                d.build_visual_unwrapped();
11287                if maps_differ(&d.vmap, &reference_map(&d.source)) {
11288                    panic!(
11289                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
11290                        d.source
11291                    );
11292                }
11293            }
11294        }
11295    }
11296
11297    /// A frontend is handed [`Doc::vmap`] and may present it differently:
11298    /// leaf-ratatui splices blank filler rows under an oversized heading so the
11299    /// raster it paints there has somewhere to stand, and leaves them in the map
11300    /// because the caret and the mouse both read it between frames. The splice
11301    /// path addresses that map by *row index*, against the block layout the last
11302    /// build recorded — so handed a map with rows in it that no block owns, it
11303    /// laid the re-rendered block over one of the fillers and carried the rows
11304    /// the block really occupied into the suffix. One stranded copy of the
11305    /// edited line, and everything below it a row further down, per keystroke.
11306    ///
11307    /// A map that isn't the one the layout describes is a map this path can't
11308    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
11309    #[test]
11310    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
11311        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
11312        d.build_visual_unwrapped();
11313
11314        // Stand in for the heading filler rows: two blank rows past the heading
11315        // that no block accounts for. Cloning a real row keeps every field
11316        // plausible — it is the row *count* the splice can't survive.
11317        let filler = d.vmap.rows[0].clone();
11318        d.vmap.rows.insert(1, filler.clone());
11319        d.vmap.rows.insert(1, filler);
11320
11321        // An edit inside the last block: the single-block case the splice path
11322        // is for, and the one the frontend hits on every keystroke.
11323        let at = d.source.len() - 1;
11324        d.edit(at, at, "!");
11325        d.build_visual_unwrapped();
11326
11327        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
11328    }
11329
11330    /// A glyph's [`FaceId`] has to mean the same thing however its row was
11331    /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
11332    /// cloned at a shifted offset, and a previous map's rows a splice kept
11333    /// untouched — and only the first of those walks a `data-font` at all. An
11334    /// index into a per-build table would have had the same glyph naming two
11335    /// families the moment a second one appeared; the id is the name's own
11336    /// hash, so nothing is remapped and the table is merged rather than rebuilt.
11337    ///
11338    /// Two families, because one cannot tell a wrong id from a right one.
11339    ///
11340    /// [`FaceId`]: crate::style::FaceId
11341    /// [`BlockCache`]: crate::wysiwyg::BlockCache
11342    #[test]
11343    fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
11344        use crate::style::{FaceId, FaceRef};
11345        let garamond = FaceId::of("Garamond");
11346        let futura = FaceId::of("Futura");
11347        let mut d = wysiwyg_doc(
11348            "two_faces",
11349            "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
11350        );
11351        d.build_visual_unwrapped();
11352
11353        // What the map has to keep saying, whichever path built it.
11354        let check = |d: &Doc, ctx: &str| {
11355            let face_of = |ch: char| {
11356                d.vmap
11357                    .rows
11358                    .iter()
11359                    .flat_map(|r| r.glyphs.iter())
11360                    .find(|g| g.ch == ch)
11361                    .map(|g| g.style.font)
11362            };
11363            assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
11364            assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
11365            assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
11366            assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
11367            assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
11368        };
11369        check(&d, "fresh");
11370
11371        // An edit inside the second block: the single-block case the splice
11372        // path is for. The first block's rows are carried over untouched, so
11373        // its glyphs' ids are the previous build's and the table has to be too.
11374        let at = d.source.find("beta").unwrap();
11375        d.edit(at, at, "z");
11376        d.build_visual_unwrapped();
11377        check(&d, "after an edit in the second block");
11378        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
11379
11380        // And the other way round, so the block that was kept is the one that
11381        // is now re-rendered.
11382        let at = d.source.find("alpha").unwrap();
11383        d.edit(at, at, "z");
11384        d.build_visual_unwrapped();
11385        check(&d, "after an edit in the first block");
11386        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
11387
11388        // A structural edit is one the splice bails out of, so the map is
11389        // reassembled by `build_cached` — where an untouched block is a *cache
11390        // hit* and its rows are cloned without a `data-font` being walked
11391        // again. The names the entry stored are what keeps the table honest
11392        // there.
11393        let at = d.source.find("\n\ny ").unwrap();
11394        d.edit(at, at, "\n\nmiddle");
11395        d.build_visual_unwrapped();
11396        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
11397        // Twice, because that first `build_cached` is what stores the entries:
11398        // this one is the build where the Garamond block is a *hit*, its rows
11399        // cloned with their ids and no attribute walked to explain them.
11400        let at = d.source.len() - 1;
11401        d.edit(at, at, "\n\ntail");
11402        d.build_visual_unwrapped();
11403        check(&d, "after a structural edit, through the block cache");
11404        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
11405
11406        // A family the edit took the last glyph of leaves the glyphs with no
11407        // face and the table with a name nothing asks for — harmless, and the
11408        // price of not walking the rows the splice exists to avoid walking.
11409        let span = d.source.find("<span data-font=\"Futura\">").unwrap();
11410        let end = d.source.rfind("</span>").unwrap() + "</span>".len();
11411        d.edit(span, end, "beta");
11412        d.build_visual_unwrapped();
11413        assert!(!d.source.contains("Futura"), "{:?}", d.source);
11414        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
11415    }
11416
11417    #[test]
11418    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
11419        // The same correctness net as `incremental_build_matches_a_fresh_build_
11420        // across_edits`, under `MarkupMode::Full` — where the map depends on
11421        // the caret's *line* as well as the text, so the two caches have a new
11422        // way to be wrong. Both are exercised: the block cache can hand back
11423        // rows built for a line that is no longer the revealed one, and the
11424        // splice path can reuse a suffix that still has yesterday's line raw.
11425        //
11426        // Caret motion is interleaved with the edits deliberately, because a
11427        // caret that only ever moved with the edit would never cross a line
11428        // without also dirtying it — the case where a stale reveal survives.
11429        let docs = [
11430            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
11431            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
11432        ];
11433        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
11434        for src in docs {
11435            let mut d = wysiwyg_doc("reveal_diff", src);
11436            d.set_markup_mode(MarkupMode::Full);
11437
11438            for step in 0..60usize {
11439                let len = d.source.len();
11440                let raw = (step * 13 + 5) % (len + 1);
11441                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11442                let pre = d.source.clone();
11443                let action;
11444                if step % 3 == 0 && pos < len {
11445                    let end = (pos + 1..=len)
11446                        .find(|&i| d.source.is_char_boundary(i))
11447                        .unwrap();
11448                    action = format!("delete [{pos},{end})");
11449                    d.edit(pos, end, "");
11450                } else {
11451                    let ins = inserts[step % inserts.len()];
11452                    action = format!("insert {ins:?} @ {pos}");
11453                    d.edit(pos, pos, ins);
11454                }
11455                // Walk the caret somewhere else in the document, independently
11456                // of where the edit landed.
11457                let want = (step * 29 + 11) % (d.source.len() + 1);
11458                d.caret = (want..=d.source.len())
11459                    .find(|&i| d.source.is_char_boundary(i))
11460                    .unwrap();
11461                d.build_visual_unwrapped();
11462
11463                let want = reference_map_revealing(&d.source, d.reveal_line());
11464                if maps_differ(&d.vmap, &want) {
11465                    panic!(
11466                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
11467                        d.caret, d.source
11468                    );
11469                }
11470            }
11471        }
11472    }
11473
11474    #[test]
11475    fn caret_motion_across_lines_rebuilds_only_under_full() {
11476        // The cache-key change has to earn its keep in both directions: `Full`
11477        // must rebuild when the caret changes line (or the reveal would never
11478        // move), and the hidden modes must *not* (or every arrow key would pay
11479        // for a feature they don't use). The existing `cache_motion` test pins
11480        // the second for the default mode; this pins the pair against a mode
11481        // change alone.
11482        let body = "*one* here\n\n*two* there\n";
11483
11484        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
11485        full.set_markup_mode(MarkupMode::Full);
11486        caret_at(&mut full, "one");
11487        let before = full.revision();
11488        caret_at(&mut full, "two");
11489        assert_eq!(full.revision(), before, "motion is not an edit");
11490        assert!(
11491            drawn_rows(&full).iter().any(|r| r == "*two* there"),
11492            "the map followed the caret: {:?}",
11493            drawn_rows(&full)
11494        );
11495
11496        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
11497        caret_at(&mut hidden, "one");
11498        let key = hidden.vmap_key.clone();
11499        caret_at(&mut hidden, "two");
11500        assert_eq!(
11501            hidden.vmap_key, key,
11502            "a hidden mode rebuilds nothing on motion"
11503        );
11504    }
11505
11506    #[test]
11507    fn wysiwyg_down_crosses_a_paragraph_boundary() {
11508        // Regression: the blank separator row used to share the previous
11509        // paragraph's end offset, so Down got pinned at the boundary (while Up
11510        // still crossed). Both directions must step through it symmetrically.
11511        //
11512        // It's now stepped *over* rather than onto: the blank line between two
11513        // paragraphs is the boundary being drawn, not a line of the document, so
11514        // one press of Down crosses it. The goal column survives the crossing —
11515        // col 3 at the end of "abc" is col 3 at the end of "def".
11516        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
11517        d.caret = 3; // end of "abc" (row 0)
11518        d.move_down(false);
11519        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
11520        assert_eq!(d.caret, 8); // end of "def", col 3 kept
11521        d.move_up(false);
11522        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
11523        assert_eq!(d.caret, 3);
11524    }
11525
11526    #[test]
11527    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
11528        // The second Up and the second Down here run off the ends of the
11529        // document, which is no longer a place a press is swallowed: they carry
11530        // the caret to the start and the end of the text. The claim in the
11531        // middle — that a Down retraces the Up that crossed the paragraph gap —
11532        // is the one this test is for, and it is asserted where it is made.
11533        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
11534        d.caret = 5; // start of "def"
11535        let start = d.caret_pos();
11536        d.move_up(false);
11537        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
11538        d.move_up(false);
11539        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
11540        d.move_down(false);
11541        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
11542        d.move_down(false);
11543        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
11544    }
11545
11546    #[test]
11547    fn wysiwyg_new_paragraph_shows_before_typing() {
11548        // Regression: two Enters at the end of a paragraph produced trailing
11549        // newlines with no AST node, so the caret appeared stuck on the old line
11550        // until a character was typed. It must ride down onto the new line now.
11551        let mut d = doc_with("wys_newpara", "abc\n");
11552        d.view = View::Wysiwyg;
11553        d.caret = 3;
11554        d.insert("\n");
11555        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
11556        assert_eq!(d.source, "abc\n\n\n");
11557        d.build_visual(80);
11558        let (row, _) = d.caret_pos();
11559        assert!(
11560            row >= 2,
11561            "caret should have moved down to the new line, got row {row}"
11562        );
11563        assert!(
11564            d.vmap.num_rows() >= 3,
11565            "the blank lines should render as rows"
11566        );
11567    }
11568
11569    #[test]
11570    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
11571        // The reported bug: Enter at the end of a paragraph that has another
11572        // paragraph below put the caret at the *start of the next paragraph* —
11573        // the empty paragraph it opened had no row, so the caret snapped onto
11574        // "World". It must now sit on its own empty line, with a blank spacer
11575        // above it (the paragraph gap).
11576        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
11577        d.caret = 5; // end of "Hello"
11578        d.newline();
11579        d.build_visual(80);
11580        let (row, col) = d.caret_pos();
11581        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
11582        assert_eq!(
11583            d.vmap.row_width(row),
11584            0,
11585            "caret's row must be empty, not 'World'"
11586        );
11587        assert!(
11588            row >= 2,
11589            "a blank spacer row should sit above the caret, got row {row}"
11590        );
11591        // The row above the caret is a real (empty) gap, and "Hello" stays put.
11592        assert_eq!(
11593            d.vmap.row_width(row - 1),
11594            0,
11595            "the row above the caret is a gap"
11596        );
11597        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
11598        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
11599    }
11600
11601    #[test]
11602    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
11603        // At the document end a single Enter must also show the paragraph gap —
11604        // a blank spacer row above the caret — so the layout already matches how
11605        // it will look once the new paragraph has text.
11606        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
11607        d.caret = 5; // end of "Hello", no trailing newline
11608        d.newline(); // source becomes "Hello\n\n"
11609        d.build_visual(80);
11610        let (row, col) = d.caret_pos();
11611        assert_eq!(col, 0);
11612        assert!(
11613            row >= 2,
11614            "caret should sit below a blank spacer, got row {row}"
11615        );
11616        assert_eq!(
11617            d.vmap.row_width(row - 1),
11618            0,
11619            "the row above the caret is a gap"
11620        );
11621    }
11622
11623    #[test]
11624    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
11625        // The spacer is view-only: typing the new paragraph must not reflow the
11626        // caret onto a different row — the transient view already matched the
11627        // settled one.
11628        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
11629        d.caret = 5;
11630        d.newline();
11631        d.build_visual(80);
11632        let before = d.caret_pos();
11633        d.insert("New");
11634        d.build_visual(80);
11635        let after = d.caret_pos();
11636        assert_eq!(
11637            after.0, before.0,
11638            "typing must not move the caret to another row ({before:?} -> {after:?})"
11639        );
11640    }
11641
11642    #[test]
11643    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
11644        // Return at the end of the block's last line writes an empty line the
11645        // map used to drop, so the caret landed on `after` and the next
11646        // keystroke went into the paragraph below instead of into the code.
11647        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
11648        d.caret = d.source.find("beta").unwrap() + "beta".len();
11649        d.build_visual(80);
11650        let before = d.caret_pos().0;
11651
11652        d.newline();
11653        d.build_visual(80);
11654        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
11655
11656        let (row, col) = d.caret_pos();
11657        assert_eq!(row, before + 1, "the caret moves down one row");
11658        assert_eq!(col, 0, "onto the head of the empty line");
11659        let span = d.vmap.code_blocks[0].rows_span.clone();
11660        assert!(
11661            span.contains(&row),
11662            "caret row {row} is outside the block's rows {span:?}"
11663        );
11664
11665        // The whole point: what is typed next is code.
11666        d.insert("gamma");
11667        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
11668    }
11669
11670    #[test]
11671    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
11672        let fm = "---\ntitle: hi\n---\n";
11673        let body = format!("{fm}# leaf\n\nbody\n");
11674        let mut d = wysiwyg_doc("wys_fm", &body);
11675        // Opening lifts the caret out of the now-hidden frontmatter.
11676        assert_eq!(
11677            d.caret,
11678            fm.len(),
11679            "caret should start at the first real block"
11680        );
11681        // Left at the content start can't step back into frontmatter.
11682        d.move_left(false);
11683        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
11684        // Doc-start lands on the content floor, not offset 0.
11685        d.move_doc_start(false);
11686        assert_eq!(d.caret, fm.len());
11687        // Select-all + copy never include the frontmatter bytes.
11688        d.select_all();
11689        let sel = d.selected_text().unwrap().to_string();
11690        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
11691        assert!(
11692            sel.starts_with("# leaf"),
11693            "selection should begin at content: {sel:?}"
11694        );
11695    }
11696
11697    #[test]
11698    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
11699        // A fresh note is frontmatter and nothing else. With no rendered block
11700        // to floor the caret it opened at offset 0 — before the opening `---` —
11701        // so the first keystroke wrote itself in front of the metadata and the
11702        // file came out as `This---\ntitle: …`.
11703        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
11704        let mut d = wysiwyg_doc("wys_fm_only", fm);
11705        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
11706        // Nothing is rendered, so the caret draws at the origin of an empty view
11707        // — the same place an empty document puts it.
11708        assert_eq!(d.caret_pos(), (0, 0));
11709        d.insert("This");
11710        assert_eq!(d.source, format!("{fm}This"));
11711    }
11712
11713    /// `select_range` is the verb for a range a host already knows the bytes of,
11714    /// so it must not snap — and must still hold every invariant `place_caret`
11715    /// holds, the frontmatter floor above all.
11716    #[test]
11717    fn select_range_takes_the_range_as_given_but_still_floors_it() {
11718        let fm = "---\ntitle: foo\n---\n\n";
11719        let body = format!("{fm}body foo here\n");
11720        let mut d = wysiwyg_doc("wys_select_range", &body);
11721
11722        // The `foo` in the body: taken exactly, not snapped to a caret stop.
11723        let at = body.rfind("foo").unwrap();
11724        d.select_range(at, at + 3);
11725        assert_eq!(d.selection(), Some((at, at + 3)));
11726        assert_eq!(d.selected_text(), Some("foo"));
11727
11728        // The `foo` in the hidden frontmatter: below the floor, so both ends
11729        // come up to it rather than parking the caret in the metadata, where a
11730        // later keystroke would rewrite `title:`.
11731        let hidden = body.find("foo").unwrap();
11732        assert!(hidden < d.vmap.content_start);
11733        d.select_range(hidden, hidden + 3);
11734        assert!(
11735            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
11736            "a range under the floor must not leave the caret in the frontmatter"
11737        );
11738
11739        // Past the end, and mid-character, are both brought back to something
11740        // sliceable rather than panicking the next reader of the range.
11741        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
11742        let mut d = multi;
11743        d.select_range(2, 9_999);
11744        assert_eq!(d.caret, d.source.len());
11745        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
11746        assert!(d.source.is_char_boundary(d.caret));
11747    }
11748
11749    /// The bug `select_range` exists for: a match butting up against a hidden
11750    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
11751    /// the one before the `**`.
11752    #[test]
11753    fn select_range_does_not_snap_off_a_hidden_delimiter() {
11754        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
11755        let at = d.source.find("needle").unwrap();
11756        d.select_range(at, at + 6);
11757        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
11758    }
11759
11760    #[test]
11761    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
11762        // Backspace deletes `prev_boundary..caret` directly; at the first real
11763        // block that boundary is inside the hidden frontmatter, so it must be a
11764        // no-op rather than eating the closing `---`.
11765        let fm = "---\ntitle: hi\n---\n";
11766        let body = format!("{fm}leaf\n");
11767        let mut d = wysiwyg_doc("wys_fm_bs", &body);
11768        assert_eq!(d.caret, fm.len());
11769        d.backspace();
11770        assert_eq!(d.source, body, "backspace must not touch frontmatter");
11771        d.delete_word_back();
11772        assert_eq!(
11773            d.source, body,
11774            "word-delete must not touch frontmatter either"
11775        );
11776    }
11777
11778    #[test]
11779    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
11780        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
11781        // fenced div, really, since core parses these on for every document
11782        // now (`parse_extensions`). The container is a `directive` node, an
11783        // `is_block_container` kind like `block_quote`, so the caret works
11784        // inside its child paragraph exactly as it would inside a quote: typing
11785        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
11786        // untouched.
11787        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
11788        let mut d = wysiwyg_doc("wys_vis", body);
11789        d.caret = body.find("hello").unwrap() + "hello".len();
11790        d.insert("!");
11791        assert_eq!(
11792            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
11793            "typing inside the block edits its content in place"
11794        );
11795        assert!(
11796            d.source.contains(":::vis{.public .family}"),
11797            "opening fence survives"
11798        );
11799        assert!(d.source.contains(":::\nafter"), "closing fence survives");
11800    }
11801
11802    #[test]
11803    fn source_view_still_reaches_frontmatter() {
11804        // The metadata is only *hidden*, never lost: the source view edits and
11805        // selects it in full, and it's always preserved on save.
11806        let fm = "---\ntitle: hi\n---\n";
11807        let body = format!("{fm}# leaf\n");
11808        let mut d = doc_with("src_fm", &body);
11809        d.select_all();
11810        let sel = d.selected_text().unwrap();
11811        assert!(
11812            sel.contains("title"),
11813            "source view should select everything"
11814        );
11815        d.move_doc_start(false);
11816        assert_eq!(d.caret, 0, "source view can reach offset 0");
11817    }
11818
11819    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
11820
11821    #[test]
11822    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
11823        // The border and padding between two cells all share one source offset,
11824        // so a column-stepping caret would sit on `│` and then stall there
11825        // forever. Right must step: end of "Name" -> start of "Qty".
11826        let mut d = wysiwyg_doc("tbl_right", TABLE);
11827        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
11828        d.move_right(false);
11829        assert_eq!(
11830            d.caret,
11831            TABLE.find("Qty").unwrap(),
11832            "should land in the next cell"
11833        );
11834        let (r, c) = d.caret_pos();
11835        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
11836    }
11837
11838    #[test]
11839    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
11840        let mut d = wysiwyg_doc("tbl_left", TABLE);
11841        d.caret = TABLE.find("Qty").unwrap();
11842        d.move_left(false);
11843        assert_eq!(
11844            d.caret,
11845            TABLE.find("Name").unwrap() + 4,
11846            "end of the previous cell"
11847        );
11848    }
11849
11850    #[test]
11851    fn wysiwyg_down_steps_over_a_table_rule() {
11852        // Between the header and the first body row sits a `├───┼───┤` rule.
11853        // It's drawn but holds no caret, so one Down must reach "Pear".
11854        let mut d = wysiwyg_doc("tbl_down", TABLE);
11855        d.caret = TABLE.find("Name").unwrap();
11856        d.move_down(false);
11857        assert_eq!(
11858            d.caret,
11859            TABLE.find("Pear").unwrap(),
11860            "one Down reaches the body row"
11861        );
11862        d.move_down(false);
11863        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
11864    }
11865
11866    #[test]
11867    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
11868        let mut d = wysiwyg_doc("tbl_tab", TABLE);
11869        d.caret = TABLE.find("Name").unwrap();
11870        // A hop lands with the destination cell's whole content selected, the
11871        // caret at its end — so typing replaces the cell like a form field.
11872        assert!(d.cell_hop(true));
11873        assert_eq!(
11874            d.selected_text(),
11875            Some("Qty"),
11876            "the target cell comes up selected"
11877        );
11878        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
11879        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
11880        assert_eq!(d.selected_text(), Some("Pear"));
11881        assert!(d.cell_hop(false));
11882        assert_eq!(d.selected_text(), Some("Qty"));
11883    }
11884
11885    #[test]
11886    fn tab_outside_a_table_is_not_a_cell_hop() {
11887        // `cell_hop` reports false so the frontend can indent as usual.
11888        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
11889        d.caret = 4;
11890        assert!(!d.cell_hop(true));
11891        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
11892    }
11893
11894    #[test]
11895    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
11896        let mut d = wysiwyg_doc("tbl_edge", TABLE);
11897        d.caret = TABLE.rfind("12").unwrap(); // the final cell
11898        assert!(!d.cell_hop(true), "no cell after the last one");
11899        d.caret = TABLE.find("Name").unwrap();
11900        assert!(!d.cell_hop(false), "no cell before the first one");
11901    }
11902
11903    #[test]
11904    fn wysiwyg_vertical_cell_motion_holds_the_column() {
11905        // Down/Up step to the cell above/below in the *same column*, not back to
11906        // the top-left the way a naive row/col motion over the picture would.
11907        let mut d = wysiwyg_doc("tbl_vert", TABLE);
11908        d.caret = TABLE.find("Qty").unwrap();
11909        // Each vertical hop selects the destination cell, holding the column.
11910        assert!(d.cell_move_vertical(true));
11911        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
11912        assert!(d.cell_move_vertical(true));
11913        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
11914        assert!(!d.cell_move_vertical(true), "no row below the last");
11915        assert!(d.cell_move_vertical(false));
11916        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
11917        assert!(d.cell_move_vertical(false));
11918        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
11919        assert!(!d.cell_move_vertical(false), "no row above the header");
11920    }
11921
11922    #[test]
11923    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
11924        let mut d = wysiwyg_doc("tbl_grow", TABLE);
11925        d.caret = TABLE.rfind("12").unwrap();
11926        let rows_before = d.source.matches('\n').count();
11927        assert!(d.cell_tab(true), "acts as a table key");
11928        assert_eq!(
11929            d.source.matches('\n').count(),
11930            rows_before + 1,
11931            "a fresh row was appended"
11932        );
11933        assert!(d.caret_in_table(), "the caret entered the new row");
11934        // The caret sits in the new row's first cell — past the old last cell.
11935        assert!(d.caret > TABLE.rfind("12").unwrap());
11936    }
11937
11938    #[test]
11939    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
11940        let mut d = wysiwyg_doc("tbl_ret", TABLE);
11941        d.caret = TABLE.find("Name").unwrap();
11942        assert!(d.cell_return(), "acts as a table key");
11943        assert_eq!(
11944            d.selected_text(),
11945            Some("Pear"),
11946            "Return drops one cell, selecting it"
11947        );
11948        // From the last row, Return appends a row and enters it.
11949        d.caret = TABLE.rfind("Fig").unwrap();
11950        let rows_before = d.source.matches('\n').count();
11951        assert!(d.cell_return());
11952        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
11953        assert!(d.caret_in_table());
11954    }
11955
11956    #[test]
11957    fn return_and_tab_outside_a_table_decline() {
11958        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
11959        d.caret = 4;
11960        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
11961        assert!(!d.cell_tab(true), "no table: the frontend indents");
11962        assert!(
11963            !d.cell_line_break(),
11964            "no table: the frontend breaks the line"
11965        );
11966    }
11967
11968    #[test]
11969    fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
11970        // A document that ends in a table used to end *inside* it: nothing
11971        // past the last cell was a caret stop, so a click in the blank space
11972        // under the grid snapped back into the table and there was no way to
11973        // write a line after it. The bottom border's end is that stop now.
11974        let mut d = wysiwyg_doc("tbl_trail", TABLE);
11975        let rows = d.vmap.num_rows();
11976        d.click(rows + 3, 0, false);
11977        let end = TABLE.trim_end_matches('\n').len();
11978        assert_eq!(d.caret, end, "the caret stands just past the table");
11979        assert!(!d.caret_in_table(), "past the table is outside it");
11980        assert!(!d.cell_return(), "Return there is the frontend's newline");
11981        d.newline();
11982        d.insert("after");
11983        assert_eq!(
11984            d.source,
11985            format!("{TABLE}\nafter\n"),
11986            "Enter opens a paragraph under the table"
11987        );
11988    }
11989
11990    #[test]
11991    fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
11992        // The stop sits at the end of the table's last source line, and a
11993        // line glued under a table is a row of it — `| Fig | 12 |x` would be a
11994        // three-cell row. So the text gets a paragraph of its own, as it does
11995        // beside a block picture.
11996        let mut d = wysiwyg_doc("tbl_type", TABLE);
11997        d.caret = TABLE.trim_end_matches('\n').len();
11998        d.insert("x");
11999        assert_eq!(d.source, format!("{TABLE}\nx\n"));
12000        assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
12001        // And a paste, which joins the block exactly as typing would.
12002        let mut d = wysiwyg_doc("tbl_paste", TABLE);
12003        d.caret = TABLE.trim_end_matches('\n').len();
12004        d.paste("pasted");
12005        assert_eq!(d.source, format!("{TABLE}\npasted\n"));
12006    }
12007
12008    #[test]
12009    fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
12010        let mut d = wysiwyg_doc("tbl_edge", TABLE);
12011        let last_cell_end = TABLE.rfind("12").unwrap() + 2;
12012        let end = TABLE.trim_end_matches('\n').len();
12013        d.caret = last_cell_end;
12014        d.move_right(false);
12015        assert_eq!(d.caret, end, "Right from the last cell leaves the table");
12016        // Backspace there takes no byte: the one behind the caret is the row's
12017        // closing `|`, which the rich view never drew. It steps back instead.
12018        d.backspace();
12019        assert_eq!(d.source, TABLE, "nothing deleted");
12020        assert_eq!(d.caret, last_cell_end, "back into the last cell");
12021        // Down from the last row lands on the same stop, and Up returns.
12022        d.move_down(false);
12023        assert_eq!(d.caret, end, "Down from the last row leaves the table");
12024        d.move_up(false);
12025        assert_eq!(d.caret, last_cell_end);
12026    }
12027
12028    #[test]
12029    fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
12030        // With prose under the table, the stop is one hop between the last
12031        // cell and the paragraph — the shape a block picture's second stop has.
12032        let src = format!("{TABLE}\nafter\n");
12033        let mut d = wysiwyg_doc("tbl_mid", &src);
12034        d.caret = TABLE.rfind("12").unwrap() + 2;
12035        d.move_right(false);
12036        assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
12037        d.move_right(false);
12038        assert_eq!(d.caret, src.find("after").unwrap());
12039        // Typing at the stop still opens a paragraph, and the text below keeps
12040        // its own.
12041        d.move_left(false);
12042        d.insert("x");
12043        assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
12044    }
12045
12046    #[test]
12047    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
12048        let mut d = wysiwyg_doc("tbl_break", TABLE);
12049        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
12050        assert!(d.cell_line_break(), "acts as a table key");
12051        assert!(
12052            d.source.contains("Pear<br>"),
12053            "spelled as an inline <br>: {}",
12054            d.source
12055        );
12056        assert!(d.caret_in_table(), "still in the cell, past the break");
12057        // The break renders as a real line: the "Pear" cell now draws two lines,
12058        // so the table's picture is one row taller than a single-line table.
12059        d.build_visual(80);
12060        let table = &d.vmap.tables[0];
12061        let cell = &table.grid[1].cells[0]; // first body row, first column
12062        assert!(
12063            cell.glyphs.iter().any(|g| g.ch == '\n'),
12064            "the cell carries the break as a newline glyph for the frontend to split"
12065        );
12066    }
12067
12068    #[test]
12069    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
12070        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
12071        // back as structure — the whole point of routing through insert_line_break
12072        // instead of splicing raw `<br>` bytes.
12073        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
12074        d.caret = TABLE.find("Pear").unwrap() + 4;
12075        assert!(d.cell_line_break());
12076        let kinds: Vec<Kind> = d
12077            .editor
12078            .nodes()
12079            .unwrap()
12080            .iter()
12081            .map(|n| n.kind.clone())
12082            .collect();
12083        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
12084        assert!(
12085            !kinds.contains(&Kind::RawInline),
12086            "still raw HTML: {kinds:?}"
12087        );
12088    }
12089
12090    #[test]
12091    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
12092        // The `<br>` draws as one newline glyph, so Backspace over it must take
12093        // all four bytes — a one-byte delete would strand a visible `<br` in the
12094        // cell (the reported bug).
12095        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
12096        d.caret = TABLE.find("Pear").unwrap() + 4;
12097        assert!(d.cell_line_break());
12098        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
12099        d.backspace(); // caret sits just past the break
12100        assert!(
12101            !d.source.contains("<br"),
12102            "no half-deleted <br left: {}",
12103            d.source
12104        );
12105        assert!(
12106            d.source.contains("| Pear |"),
12107            "the cell is back to one line: {}",
12108            d.source
12109        );
12110    }
12111
12112    #[test]
12113    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
12114        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
12115        d.caret = TABLE.find("Pear").unwrap() + 4;
12116        assert!(d.cell_line_break());
12117        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
12118        d.delete_forward();
12119        assert!(
12120            !d.source.contains("<br"),
12121            "no half-deleted <br: {}",
12122            d.source
12123        );
12124        assert!(
12125            d.source.contains("| Pear |"),
12126            "cell back to one line: {}",
12127            d.source
12128        );
12129    }
12130
12131    #[test]
12132    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
12133        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
12134        // still consumed (a real newline would split the one-line row), but the
12135        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
12136        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
12137        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12138        d.caret = src.find("Pear").unwrap() + 4;
12139        assert!(d.caret_in_table(), "caret should be inside the djot table");
12140        assert!(
12141            d.cell_line_break(),
12142            "the key is consumed, not passed to the frontend"
12143        );
12144        assert_eq!(d.source, src, "the djot cell is left untouched");
12145        assert!(
12146            !d.source.contains("<br>"),
12147            "no non-idiomatic <br> spliced into djot"
12148        );
12149        assert!(
12150            d.status.is_some(),
12151            "the refusal is surfaced on the status line"
12152        );
12153    }
12154
12155    #[test]
12156    fn typing_in_a_cell_edits_that_cell() {
12157        // Editing comes free once offsets map correctly: the caret is a source
12158        // offset, so a normal splice lands inside the pipe table.
12159        let mut d = wysiwyg_doc("tbl_type", TABLE);
12160        d.caret = TABLE.find("Pear").unwrap() + 4;
12161        d.insert("s");
12162        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
12163    }
12164
12165    #[test]
12166    fn motion_and_delete_treat_an_emoji_as_one_character() {
12167        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
12168        // bytes, several codepoints. Right-arrow must clear it in one step, and
12169        // backspace must remove the whole cluster, not a stray joiner.
12170        let family = "👨‍👩‍👧";
12171        let mut d = doc_with("emoji", &format!("a{family}b\n"));
12172        d.caret = 1; // just after 'a', before the emoji
12173        d.move_right(false);
12174        assert_eq!(
12175            d.caret,
12176            1 + family.len(),
12177            "one step clears the whole cluster"
12178        );
12179        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
12180
12181        d.backspace(); // delete the emoji as a unit
12182        assert_eq!(d.source, "ab\n");
12183        assert_eq!(d.caret, 1);
12184    }
12185
12186    #[test]
12187    fn motion_handles_a_combining_accent_as_one_character() {
12188        // "e" + U+0301 (combining acute) renders as one é.
12189        let mut d = doc_with("combining", "e\u{0301}x\n");
12190        d.caret = 0;
12191        d.move_right(false);
12192        assert_eq!(
12193            d.caret,
12194            "e\u{0301}".len(),
12195            "steps past base + combining mark"
12196        );
12197    }
12198
12199    #[test]
12200    fn undo_then_redo_round_trips_an_edit() {
12201        let mut d = doc_with("undo", "hello\n");
12202        d.caret = 5;
12203        d.insert("!");
12204        assert_eq!(d.source, "hello!\n");
12205        d.undo();
12206        assert_eq!(d.source, "hello\n");
12207        assert_eq!(d.caret, 5, "undo restores the caret");
12208        d.redo();
12209        assert_eq!(d.source, "hello!\n");
12210    }
12211
12212    #[test]
12213    fn a_run_of_typing_undoes_as_one_step() {
12214        let mut d = doc_with("coalesce", "\n");
12215        d.caret = 0;
12216        d.insert("a");
12217        d.insert("b");
12218        d.insert("c");
12219        assert_eq!(d.source, "abc\n");
12220        d.undo(); // the whole typed run, not just "c"
12221        assert_eq!(d.source, "\n");
12222        d.undo(); // nothing left — the run was one step
12223        assert_eq!(d.source, "\n");
12224        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12225    }
12226
12227    // ── IME composition ──────────────────────────────────────────────────────
12228
12229    #[test]
12230    fn a_composition_run_undoes_as_one_step() {
12231        let mut d = doc_with("compose", "\n");
12232        d.caret = 0;
12233        // What an IME does: each step replaces the last one's provisional bytes.
12234        d.edit_composing(0, 0, "k");
12235        d.edit_composing(0, 1, "か");
12236        d.edit_composing(0, 3, "かん");
12237        d.edit_composing(0, 6, "感"); // the commit
12238        d.end_composition();
12239        assert_eq!(d.source, "感\n");
12240        d.undo(); // the whole composition, not its last keystroke
12241        assert_eq!(d.source, "\n");
12242        assert_eq!(d.status.as_deref(), None, "the run was a single step");
12243    }
12244
12245    #[test]
12246    fn two_compositions_are_two_undo_steps() {
12247        let mut d = doc_with("compose_two", "\n");
12248        d.caret = 0;
12249        d.edit_composing(0, 0, "か");
12250        d.edit_composing(0, 3, "蚊");
12251        d.end_composition();
12252        d.edit_composing(3, 3, "き");
12253        d.edit_composing(3, 6, "木");
12254        d.end_composition();
12255        assert_eq!(d.source, "蚊木\n");
12256        d.undo();
12257        assert_eq!(d.source, "蚊\n", "only the second composition");
12258        d.undo();
12259        assert_eq!(d.source, "\n");
12260    }
12261
12262    #[test]
12263    fn a_composition_does_not_fold_into_the_typing_around_it() {
12264        let mut d = doc_with("compose_typing", "\n");
12265        d.caret = 0;
12266        d.insert("a");
12267        d.insert("b");
12268        d.edit_composing(2, 2, "か");
12269        d.edit_composing(2, 5, "蚊");
12270        d.end_composition();
12271        d.insert("c");
12272        assert_eq!(d.source, "ab蚊c\n");
12273        d.undo();
12274        assert_eq!(d.source, "ab蚊\n");
12275        d.undo();
12276        assert_eq!(d.source, "ab\n");
12277        d.undo();
12278        assert_eq!(d.source, "\n");
12279    }
12280
12281    #[test]
12282    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
12283        let mut d = doc_with("compose_spurious", "\n");
12284        d.caret = 0;
12285        d.insert("a");
12286        d.end_composition(); // an IME unmarking unprompted
12287        d.insert("b");
12288        assert_eq!(d.source, "ab\n");
12289        d.undo();
12290        assert_eq!(d.source, "\n", "still one typed run");
12291    }
12292
12293    // ── the clipboard's rich flavor ──────────────────────────────────────────
12294
12295    #[test]
12296    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
12297        let mut d = doc_with("sel_inline", "a **bold** c\n");
12298        d.anchor = Some(2);
12299        d.caret = 10; // `**bold**`, inside the paragraph
12300        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
12301    }
12302
12303    #[test]
12304    fn a_whole_block_selection_keeps_its_paragraph() {
12305        let mut d = doc_with("sel_block", "a **bold** c\n");
12306        d.anchor = Some(0);
12307        d.caret = 12; // the entire paragraph
12308        assert_eq!(
12309            d.selection_html().as_deref(),
12310            Some("<p>a <strong>bold</strong> c</p>")
12311        );
12312    }
12313
12314    #[test]
12315    fn a_multi_block_selection_keeps_its_structure() {
12316        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
12317        d.select_all();
12318        let html = d.selection_html().expect("renders");
12319        assert!(html.contains("<p>para</p>"), "{html:?}");
12320        assert!(html.contains("<li>one</li>"), "{html:?}");
12321    }
12322
12323    #[test]
12324    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
12325        // The fragment `Head` is a paragraph standalone; the *document* says it
12326        // sits inside one block, so the wrapper is an artifact either way.
12327        let mut d = doc_with("sel_heading", "# Head line\n");
12328        d.anchor = Some(2);
12329        d.caret = 6;
12330        assert_eq!(d.selection_html().as_deref(), Some("Head"));
12331    }
12332
12333    #[test]
12334    fn no_selection_publishes_no_html() {
12335        let mut d = doc_with("sel_none", "a b\n");
12336        d.caret = 1;
12337        assert_eq!(d.selection_html(), None);
12338    }
12339
12340    #[test]
12341    fn pasting_html_converts_it_and_is_one_undo_step() {
12342        let mut d = doc_with("paste_html", "x\n");
12343        d.caret = 1;
12344        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
12345        assert_eq!(d.source, "xa **b** c\n");
12346        d.undo();
12347        assert_eq!(d.source, "x\n", "the whole paste, in one step");
12348    }
12349
12350    #[test]
12351    fn pasting_html_replaces_the_selection() {
12352        let mut d = doc_with("paste_html_sel", "keep drop\n");
12353        d.anchor = Some(5);
12354        d.caret = 9;
12355        assert!(d.paste_html("<em>new</em>"));
12356        assert_eq!(d.source, "keep *new*\n");
12357    }
12358
12359    #[test]
12360    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
12361        let mut d = doc_with("paste_html_bad", "x\n");
12362        d.caret = 1;
12363        // twig builds no table from HTML; raw `<table>` in prose is worse than
12364        // the plain flavor the caller still holds.
12365        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
12366        assert_eq!(d.source, "x\n", "declined edits nothing");
12367    }
12368
12369    #[test]
12370    fn copy_then_paste_round_trips_through_the_html_flavor() {
12371        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
12372        d.select_all();
12373        let html = d.selection_html().expect("renders");
12374        let mut into = doc_with("clip_round_dst", "\n");
12375        into.caret = 0;
12376        assert!(into.paste_html(&html));
12377        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
12378    }
12379
12380    #[test]
12381    fn moving_the_caret_starts_a_new_undo_group() {
12382        let mut d = doc_with("break", "\n");
12383        d.caret = 0;
12384        d.insert("a");
12385        d.insert("b"); // "ab\n", caret at 2
12386        d.move_left(false); // breaks the run
12387        d.insert("X"); // "aXb\n"
12388        assert_eq!(d.source, "aXb\n");
12389        d.undo();
12390        assert_eq!(
12391            d.source, "ab\n",
12392            "first undo removes only the post-move insert"
12393        );
12394        d.undo();
12395        assert_eq!(d.source, "\n", "second undo removes the earlier run");
12396    }
12397
12398    #[test]
12399    fn undo_reverses_a_format_toggle() {
12400        let mut d = doc_with("fmt_undo", "a word b\n");
12401        d.anchor = Some(2);
12402        d.caret = 6;
12403        d.toggle(InlineKind::Strong);
12404        assert_eq!(d.source, "a **word** b\n");
12405        d.undo();
12406        assert_eq!(d.source, "a word b\n");
12407    }
12408
12409    #[test]
12410    fn undo_back_to_the_saved_state_clears_dirty() {
12411        let mut d = doc_with("dirty_undo", "hello\n");
12412        assert!(!d.dirty);
12413        d.caret = 5;
12414        d.insert("!");
12415        assert!(d.dirty);
12416        d.undo();
12417        assert!(
12418            !d.dirty,
12419            "undoing to the saved source is not a modification"
12420        );
12421    }
12422
12423    #[test]
12424    fn a_new_edit_invalidates_redo() {
12425        let mut d = doc_with("redo_inv", "\n");
12426        d.caret = 0;
12427        d.insert("a");
12428        d.undo();
12429        d.insert("b"); // diverges — the redo of "a" is now gone
12430        d.redo();
12431        assert_eq!(d.source, "b\n");
12432    }
12433
12434    #[test]
12435    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
12436        let mut d = doc_with("can_undo", "hello\n");
12437        assert!(
12438            !d.can_undo() && !d.can_redo(),
12439            "a fresh document has no history"
12440        );
12441        d.caret = 5;
12442        d.insert("!");
12443        assert!(
12444            d.can_undo() && !d.can_redo(),
12445            "an edit is a step to take back"
12446        );
12447        d.undo();
12448        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
12449        d.redo();
12450        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
12451        d.undo();
12452        d.insert("?");
12453        assert!(
12454            d.can_undo() && !d.can_redo(),
12455            "a fresh edit ends the redo chain"
12456        );
12457        // A coalesced run over-counts steps — the bound is what a menu needs,
12458        // and it reconciles the moment twig reports the history empty.
12459        d.insert("a");
12460        d.insert("b");
12461        while d.can_undo() {
12462            d.undo();
12463        }
12464        assert_eq!(d.source, "hello\n");
12465        assert!(!d.can_undo());
12466        // A reading surface has nothing to undo, whatever the history holds.
12467        d.redo();
12468        d.set_read_only(true);
12469        assert!(!d.can_undo() && !d.can_redo());
12470    }
12471
12472    #[test]
12473    fn undo_on_empty_history_is_a_no_op() {
12474        let mut d = doc_with("undo_empty", "hi\n");
12475        d.undo();
12476        assert_eq!(d.source, "hi\n");
12477        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12478    }
12479
12480    #[test]
12481    fn a_one_character_paste_is_its_own_undo_step() {
12482        for view in [View::Source, View::Wysiwyg] {
12483            let mut d = doc_in(view, "paste_step", "ab\n");
12484            d.caret = 0;
12485            d.insert("x");
12486            d.insert("y"); // a run of typing
12487            d.paste("z"); // one character, but pasted — not part of that run
12488            assert_eq!(d.source, "xyzab\n");
12489            d.undo();
12490            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
12491            assert_eq!(d.caret, 2, "and hands back the caret it found");
12492            d.undo();
12493            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
12494        }
12495    }
12496
12497    #[test]
12498    fn the_same_character_typed_still_joins_the_run() {
12499        // The other half of the pair: `z` is a keystroke here and a paste above,
12500        // and the two undo differently. Nothing about the *string* says which —
12501        // which is why provenance has to come from the door the caller uses.
12502        for view in [View::Source, View::Wysiwyg] {
12503            let mut d = doc_in(view, "typed_run", "ab\n");
12504            d.caret = 0;
12505            d.insert("x");
12506            d.insert("y");
12507            d.insert("z");
12508            d.undo();
12509            assert_eq!(d.source, "ab\n", "one run, one step");
12510        }
12511    }
12512
12513    #[test]
12514    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
12515        for view in [View::Source, View::Wysiwyg] {
12516            let mut d = doc_in(view, "undo_caret", "hello world\n");
12517            d.caret = 11; // standing at the end of "world", away from the edit
12518            d.edit(0, 5, "goodbye");
12519            assert_eq!(d.source, "goodbye world\n");
12520            d.undo();
12521            assert_eq!(d.source, "hello world\n");
12522            // The undone edit ends at offset 5; the user was at 11.
12523            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
12524        }
12525    }
12526
12527    #[test]
12528    fn undo_restores_the_selection_the_edit_replaced() {
12529        for view in [View::Source, View::Wysiwyg] {
12530            let mut d = doc_in(view, "undo_sel", "a word b\n");
12531            d.anchor = Some(2);
12532            d.caret = 6; // "word" selected
12533            d.insert("X");
12534            assert_eq!(d.source, "a X b\n");
12535            d.undo();
12536            assert_eq!(d.source, "a word b\n");
12537            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
12538        }
12539    }
12540
12541    #[test]
12542    fn redo_restores_the_caret_the_edit_left_behind() {
12543        for view in [View::Source, View::Wysiwyg] {
12544            let mut d = doc_in(view, "redo_caret", "hello world\n");
12545            d.caret = 11;
12546            d.edit(0, 5, "goodbye");
12547            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
12548            d.undo();
12549            d.redo();
12550            assert_eq!(d.source, "goodbye world\n");
12551            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
12552        }
12553    }
12554
12555    #[test]
12556    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
12557        for view in [View::Source, View::Wysiwyg] {
12558            let mut d = doc_in(view, "run_caret", "hi\n");
12559            d.caret = 2;
12560            d.insert("a");
12561            d.insert("b");
12562            d.insert("c");
12563            assert_eq!(d.source, "hiabc\n");
12564            d.undo();
12565            assert_eq!(d.source, "hi\n");
12566            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
12567            d.redo();
12568            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
12569        }
12570    }
12571
12572    #[test]
12573    fn undo_restores_the_caret_across_a_format_toggle() {
12574        // A toggle reaches twig without going through `splice`, so it has to
12575        // record its own step — miss it and every stack depth below it is off by
12576        // one, and undo starts handing back another edit's caret.
12577        for view in [View::Source, View::Wysiwyg] {
12578            let mut d = doc_in(view, "fmt_caret", "a word b\n");
12579            d.caret = 8;
12580            d.anchor = Some(2);
12581            d.caret = 6;
12582            d.toggle(InlineKind::Strong);
12583            assert_eq!(d.source, "a **word** b\n");
12584            d.undo();
12585            assert_eq!(d.source, "a word b\n");
12586            assert_eq!(
12587                d.selection(),
12588                Some((2, 6)),
12589                "the toggled selection comes back"
12590            );
12591        }
12592    }
12593
12594    #[test]
12595    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
12596        // The drift that would never announce itself: twig drops its redo stack
12597        // on any fresh edit, so a leaf redo entry that outlives it would restore
12598        // a caret from the timeline that edit abandoned.
12599        for view in [View::Source, View::Wysiwyg] {
12600            let mut d = doc_in(view, "redo_trunc", "hello world\n");
12601            d.caret = 11;
12602            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
12603            d.undo();
12604            assert_eq!(d.caret, 11);
12605            d.caret = 0;
12606            d.insert("X"); // diverges: A's redo is gone from twig
12607            assert_eq!(d.source, "Xhello world\n");
12608
12609            d.redo();
12610            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
12611            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
12612            d.undo();
12613            assert_eq!(d.source, "hello world\n");
12614            assert_eq!(
12615                d.caret, 0,
12616                "the surviving step's caret, not the dropped one"
12617            );
12618        }
12619    }
12620
12621    #[test]
12622    fn indent_and_outdent_move_the_caret_line_with_its_text() {
12623        for view in [View::Source, View::Wysiwyg] {
12624            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
12625            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
12626            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
12627            // Indentation the caret is standing *in* collapses to the line start
12628            // rather than dragging the caret into the text.
12629            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
12630            // A line with none to give back is left exactly as it was.
12631            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
12632            // Less than a full level gives back what it has.
12633            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12634            // A tab is one level however many spaces it isn't.
12635            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
12636        }
12637    }
12638
12639    #[test]
12640    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
12641        // Why the level is two spaces and not the four both frontends type
12642        // today. Four is markdown's indented-code-block marker, so a Tab on a
12643        // paragraph would silently restyle it as code — a width that changes
12644        // what the document *means* isn't an indent. Pinned because the number
12645        // is the kind of thing a later list-aware pass would reach for.
12646        let mut d = doc_with("indent_kind", "hello\n");
12647        d.caret = 2;
12648        d.indent();
12649        assert_eq!(d.source, "  hello\n");
12650        assert!(
12651            d.nodes().iter().any(|n| n.kind == Kind::Para),
12652            "still prose after a Tab"
12653        );
12654        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
12655
12656        // The four-space level this replaces, for contrast: same text, and twig
12657        // reparses the paragraph into a code block.
12658        let mut wide = doc_with("indent_kind_4", "    hello\n");
12659        wide.build_visual(80);
12660        assert!(
12661            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
12662            "four spaces is a code block, not an indented paragraph"
12663        );
12664    }
12665
12666    #[test]
12667    fn indent_nests_a_list_item_under_its_parent() {
12668        // Tab indents a list item by its own marker width, landing its marker at
12669        // the parent's content column so twig reparses it as a nested list.
12670        for view in [View::Source, View::Wysiwyg] {
12671            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
12672            d.caret = 6; // on the second item
12673            d.indent();
12674            assert_eq!(d.source, "- a\n  - b\n");
12675            let lists = d
12676                .nodes()
12677                .iter()
12678                .filter(|n| n.kind == Kind::BulletList)
12679                .count();
12680            assert_eq!(lists, 2, "the indented item is a nested list");
12681        }
12682    }
12683
12684    #[test]
12685    fn indent_nests_an_ordered_item_at_its_marker_width() {
12686        // An ordered marker `1. ` is three columns wide, so a two-space step
12687        // (which nests a bullet) leaves it flat. Regression: Tab must use the
12688        // marker width, three, so the item actually nests — and the source
12689        // renumbers so the sub-list restarts at 1 and the outer list resumes.
12690        for view in [View::Source, View::Wysiwyg] {
12691            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
12692            d.caret = d.source.find('b').unwrap();
12693            d.indent();
12694            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
12695            let lists = d
12696                .nodes()
12697                .iter()
12698                .filter(|n| n.kind == Kind::OrderedList)
12699                .count();
12700            assert_eq!(lists, 2, "the indented item is a nested ordered list");
12701        }
12702    }
12703
12704    #[test]
12705    fn indent_leaves_a_lists_first_item_put() {
12706        // The first item of a list has no sibling above it to nest under, so Tab
12707        // is a no-op there — the marker stays at column zero rather than being
12708        // shoved into indentation twig can't read as a sub-list.
12709        for view in [View::Source, View::Wysiwyg] {
12710            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
12711            d.caret = 1; // on the FIRST item
12712            d.indent();
12713            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
12714            // The sibling below still nests, proving the guard is per-item.
12715            d.caret = d.source.find('b').unwrap();
12716            d.indent();
12717            assert_eq!(d.source, "- a\n  - b\n");
12718        }
12719    }
12720
12721    #[test]
12722    fn hidden_mode_keeps_typed_markup_literal() {
12723        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
12724        // twig escapes what would open markup, so the source is `\*hi\*` and the
12725        // AST is a plain string. Formatting is the commands' job in this mode.
12726        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
12727        d.insert("*hi*");
12728        assert_eq!(d.source, "\\*hi\\*");
12729        assert!(
12730            d.nodes()
12731                .iter()
12732                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
12733        );
12734    }
12735
12736    #[test]
12737    fn hidden_mode_escapes_a_line_start_block_marker() {
12738        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
12739        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
12740        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
12741        d.insert("# hi");
12742        assert_eq!(d.source, "\\# hi");
12743        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
12744    }
12745
12746    #[test]
12747    fn authoring_modes_keep_typed_markup_live() {
12748        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
12749        // (no escape), the same as source view — escaping is `None`'s alone, and
12750        // it's the axis, not the reveal, that decides.
12751        for (view, mode) in [
12752            (View::Wysiwyg, MarkupMode::Shortcuts),
12753            (View::Wysiwyg, MarkupMode::Full),
12754            (View::Source, MarkupMode::None),
12755        ] {
12756            let mut d = doc_in(view, "live_markup", "");
12757            d.set_markup_mode(mode);
12758            d.insert("*hi*");
12759            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
12760        }
12761    }
12762
12763    #[test]
12764    fn hidden_mode_overwrite_undoes_in_one_step() {
12765        // Typing over a selection escapes the replacement *and* stays a single
12766        // undo — the selection-delete and the literal insert fold together, so
12767        // one undo brings the whole selection back, like a plain overwrite.
12768        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
12769        d.anchor = Some(2);
12770        d.caret = 6; // "word"
12771        d.insert("*");
12772        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
12773        d.undo();
12774        assert_eq!(d.source, "a word b\n");
12775        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
12776    }
12777
12778    #[test]
12779    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
12780        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
12781        // the whole visual character, never stranding the hidden `\`.
12782        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
12783        d.insert("*");
12784        assert_eq!(d.source, "\\*");
12785        d.backspace();
12786        assert_eq!(d.source, "", "the escape backslash went with the *");
12787        // A *literal* backslash (source view, no escape) is an ordinary char.
12788        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
12789        s.caret = 3; // after `b`
12790        s.backspace();
12791        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
12792    }
12793
12794    #[test]
12795    fn hidden_mode_leaves_structural_markup_alone() {
12796        // Enter continues a bullet list by writing a real `- ` marker (an
12797        // `insert_raw`, not the typing path), so Hidden mode's escaping never
12798        // touches it — the list keeps working.
12799        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
12800        d.caret = 6;
12801        d.newline();
12802        d.insert("two");
12803        assert_eq!(d.source, "- item\n- two\n");
12804    }
12805
12806    #[test]
12807    fn markup_mode_defaults_to_none_and_round_trips() {
12808        // Diaryx's default is the clean `None` surface; a markup-fluent
12809        // frontend can climb the ladder, and the choice sticks.
12810        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
12811        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
12812        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
12813            d.set_markup_mode(mode);
12814            assert_eq!(d.markup_mode(), mode);
12815        }
12816    }
12817
12818    #[test]
12819    fn full_mode_reveals_only_the_caret_line() {
12820        // The mode's whole claim: the caret's line shows its raw delimiters and
12821        // every other line stays resolved. Two paragraphs with identical markup
12822        // so the only difference between the rows is where the caret is.
12823        let mut d = doc_in(
12824            View::Wysiwyg,
12825            "reveal_caret_line",
12826            "*one* here\n\n*two* there\n",
12827        );
12828        d.set_markup_mode(MarkupMode::Full);
12829
12830        caret_at(&mut d, "one");
12831        let rows = drawn_rows(&d);
12832        assert!(
12833            rows.iter().any(|r| r == "*one* here"),
12834            "caret's line raw: {rows:?}"
12835        );
12836        assert!(
12837            rows.iter().any(|r| r == "two there"),
12838            "other line resolved: {rows:?}"
12839        );
12840
12841        // Move to the other paragraph: the reveal follows, and the line just
12842        // left goes back to being resolved.
12843        caret_at(&mut d, "two");
12844        let rows = drawn_rows(&d);
12845        assert!(
12846            rows.iter().any(|r| r == "*two* there"),
12847            "caret's line raw: {rows:?}"
12848        );
12849        assert!(
12850            rows.iter().any(|r| r == "one here"),
12851            "left line resolved: {rows:?}"
12852        );
12853    }
12854
12855    #[test]
12856    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
12857        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
12858        // the same treatment as an emphasis's `*`: hidden while the caret is
12859        // elsewhere, shown in full where the caret lands. That falls out of
12860        // `delims` reading the bytes between the mark's span and its content
12861        // span, which is exactly `==🔴 ` and `==`, rather than from a table
12862        // of spellings — so the no-space form `==🟢green==` reveals right too.
12863        let mut d = doc_in(
12864            View::Wysiwyg,
12865            "reveal_coloured_mark",
12866            "a ==🔴 red== one\n\nb ==plain== two\n",
12867        );
12868        d.set_markup_mode(MarkupMode::Full);
12869
12870        caret_at(&mut d, "red");
12871        let rows = drawn_rows(&d);
12872        assert!(
12873            rows.iter().any(|r| r == "a ==🔴 red== one"),
12874            "the caret's line shows the colour it was written with: {rows:?}"
12875        );
12876        assert!(
12877            rows.iter().any(|r| r == "b plain two"),
12878            "and every other line stays resolved: {rows:?}"
12879        );
12880
12881        // Away from it, the emoji goes back to being markup — the reader sees
12882        // the words and the wash.
12883        caret_at(&mut d, "two");
12884        let rows = drawn_rows(&d);
12885        assert!(
12886            rows.iter().any(|r| r == "a red one"),
12887            "resolved again: {rows:?}"
12888        );
12889    }
12890
12891    #[test]
12892    fn hidden_modes_never_reveal_wherever_the_caret_is() {
12893        // The two rungs below `Full` share a rendering: delimiters stay hidden
12894        // even under the caret. `Shortcuts` differing from `None` only in what
12895        // typing does is exactly the point of splitting the axes.
12896        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
12897            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
12898            d.set_markup_mode(mode);
12899            caret_at(&mut d, "one");
12900            let rows = drawn_rows(&d);
12901            assert!(
12902                rows.iter().any(|r| r == "one here"),
12903                "{mode:?} hides: {rows:?}"
12904            );
12905            assert!(
12906                !rows.iter().any(|r| r.contains('*')),
12907                "{mode:?} shows no `*`: {rows:?}"
12908            );
12909        }
12910    }
12911
12912    #[test]
12913    fn revealed_delimiters_are_the_authors_own_spelling() {
12914        // Delimiters are re-read from the source rather than synthesized per
12915        // kind, so a line comes back spelled the way it was written: `_em_` does
12916        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
12917        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
12918        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
12919        d.set_markup_mode(MarkupMode::Full);
12920        caret_at(&mut d, "em");
12921        let rows = drawn_rows(&d);
12922        assert!(
12923            rows.iter().any(|r| r == body.trim_end()),
12924            "the revealed line is its own source: {rows:?}"
12925        );
12926    }
12927
12928    #[test]
12929    fn revealed_heading_shows_its_hashes() {
12930        // The `# ` marker is a block-level prefix, not an inline delimiter, so
12931        // it takes its own path — but it reveals on the same rule.
12932        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
12933        d.set_markup_mode(MarkupMode::Full);
12934
12935        caret_at(&mut d, "Title");
12936        assert!(
12937            drawn_rows(&d).iter().any(|r| r == "# Title"),
12938            "{:?}",
12939            drawn_rows(&d)
12940        );
12941
12942        caret_at(&mut d, "body");
12943        let rows = drawn_rows(&d);
12944        assert!(
12945            rows.iter().any(|r| r == "Title"),
12946            "hashes hidden again: {rows:?}"
12947        );
12948    }
12949
12950    #[test]
12951    fn revealed_delimiters_are_caret_stops() {
12952        // A delimiter that is drawn but can't be reached is worse than one
12953        // that's hidden: the mode exists so the markup can be *edited*. Every
12954        // revealed byte must be somewhere the caret can stand.
12955        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
12956        d.set_markup_mode(MarkupMode::Full);
12957        caret_at(&mut d, "em");
12958        let opener = d.source.find('*').unwrap();
12959        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
12960        assert!(
12961            d.vmap.is_stop(opener + 3),
12962            "the closing `*` is a caret stop"
12963        );
12964    }
12965
12966    #[test]
12967    fn setext_heading_reveals_nothing_across_its_newline() {
12968        // A setext heading's underline is on another line, so it is not the
12969        // caret line's to reveal — and emitting it would inject a `\n` glyph
12970        // that splits the row where the author wrote no break.
12971        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
12972        d.set_markup_mode(MarkupMode::Full);
12973        caret_at(&mut d, "Title");
12974        let rows = drawn_rows(&d);
12975        assert!(
12976            rows.iter().any(|r| r == "Title"),
12977            "title renders alone: {rows:?}"
12978        );
12979        assert!(
12980            !rows.iter().any(|r| r.contains('=')),
12981            "no underline leaks in: {rows:?}"
12982        );
12983    }
12984
12985    #[test]
12986    fn markup_mode_axes_split_the_ladder() {
12987        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
12988        // that authors markup but still hides it, and it's the only rung where
12989        // the two axes disagree.
12990        assert!(!MarkupMode::None.authors());
12991        assert!(!MarkupMode::None.reveals_caret_line());
12992        assert!(MarkupMode::Shortcuts.authors());
12993        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
12994        assert!(MarkupMode::Full.authors());
12995        assert!(MarkupMode::Full.reveals_caret_line());
12996    }
12997
12998    #[test]
12999    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
13000        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
13001        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
13002        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
13003        // nested bullet and `hello` stays prose: the file round-trips instead of
13004        // hiding a heading the user never asked for.
13005        for view in [View::Source, View::Wysiwyg] {
13006            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
13007            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
13008            d.indent();
13009            assert_eq!(d.source, "- hello\n  * \n");
13010            assert!(
13011                d.nodes().iter().all(|n| n.kind != Kind::Heading),
13012                "no heading"
13013            );
13014            // And it's genuinely a nested list, not a flat one.
13015            assert_eq!(
13016                d.nodes()
13017                    .iter()
13018                    .filter(|n| n.kind == Kind::BulletList)
13019                    .count(),
13020                2
13021            );
13022        }
13023    }
13024
13025    #[test]
13026    fn indenting_a_dash_item_with_content_keeps_its_dash() {
13027        // With content, `- x` can't be a setext underline, so there's nothing to
13028        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
13029        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
13030        d.caret = d.source.find('x').unwrap();
13031        d.indent();
13032        assert_eq!(d.source, "- hello\n  - x\n");
13033    }
13034
13035    #[test]
13036    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
13037        // The dash→`*` repair coalesces into the Tab, so a single undo restores
13038        // the whole pre-Tab state rather than stranding a half-collapsed doc.
13039        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
13040        d.caret = d.source.find("- \n").unwrap() + 2;
13041        d.indent();
13042        assert_eq!(d.source, "- hello\n  * \n");
13043        d.undo();
13044        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
13045    }
13046
13047    #[test]
13048    fn indent_leaves_a_nested_lists_first_item_put_too() {
13049        // The guard is about siblings, not depth: the first item of an *inner*
13050        // list (already nested under `a`) still has nothing before it at its own
13051        // level, so Tab can't take it deeper.
13052        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
13053        d.caret = d.source.find('b').unwrap();
13054        d.indent();
13055        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
13056        // But `c` (a sibling of `b`) nests under `b`.
13057        d.caret = d.source.find('c').unwrap();
13058        d.indent();
13059        assert_eq!(d.source, "- a\n  - b\n    - c\n");
13060    }
13061
13062    #[test]
13063    fn backspace_at_a_nested_item_start_outdents_it() {
13064        // Backspace with the caret right after a nested item's marker gives back
13065        // one level of nesting, the mirror of Tab — and renumbers the flattened
13066        // ordered list back to a clean run.
13067        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
13068        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
13069        d.backspace();
13070        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13071    }
13072
13073    #[test]
13074    fn backspace_at_a_top_level_item_start_strips_the_marker() {
13075        // At the outermost level there's no nesting left to give back, so the same
13076        // keystroke drops the bullet and leaves a plain paragraph.
13077        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
13078        d.caret = d.source.find('b').unwrap(); // right after `- `
13079        d.backspace();
13080        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
13081    }
13082
13083    #[test]
13084    fn backspace_mid_item_still_deletes_a_character() {
13085        // The list behaviour is armed only at the item's content start; anywhere
13086        // else Backspace is the ordinary character delete.
13087        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
13088        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
13089        d.backspace();
13090        assert_eq!(d.source, "- b\n");
13091    }
13092
13093    #[test]
13094    fn backspace_at_a_heading_start_strips_the_marker() {
13095        // The `# ` is markup the rich view hides, so Backspace over it takes the
13096        // whole marker and leaves a paragraph. Deleting a byte of it instead left
13097        // `#Title` — no longer a heading, with the hash now literal text the user
13098        // never typed and has to delete again.
13099        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
13100        d.caret = d.source.find('T').unwrap(); // right after `## `
13101        d.backspace();
13102        assert_eq!(d.source, "Title\n");
13103        assert_eq!(
13104            d.caret, 0,
13105            "the caret stays with the text it was in front of"
13106        );
13107    }
13108
13109    #[test]
13110    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
13111        // Only the heading's own marker goes — the quote (or list) it sits in is
13112        // untouched, exactly as un-heading it should be.
13113        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
13114        d.caret = d.source.find('T').unwrap();
13115        d.backspace();
13116        assert_eq!(d.source, "> Title\n");
13117    }
13118
13119    #[test]
13120    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
13121        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
13122        // behind would surface the same stray hash the marker delete just avoided.
13123        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
13124        d.caret = d.source.find('T').unwrap();
13125        d.backspace();
13126        assert_eq!(d.source, "Title\n");
13127        // And it's one edit: a single undo puts the whole heading back.
13128        d.undo();
13129        assert_eq!(d.source, "# Title #\n");
13130    }
13131
13132    #[test]
13133    fn backspace_mid_heading_still_deletes_a_character() {
13134        // The heading behaviour is armed only at the content's start; anywhere
13135        // else Backspace is the ordinary character delete.
13136        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
13137        d.caret = d.source.find('b').unwrap();
13138        d.backspace();
13139        assert_eq!(d.source, "# b\n");
13140    }
13141
13142    #[test]
13143    fn source_view_backspace_still_edits_the_heading_marker_literally() {
13144        // In source view the `# ` is text on the screen the user is deleting a
13145        // byte of, so it keeps its literal meaning — the same split the list
13146        // ladder and Enter draw between the two views.
13147        let mut d = doc_with("bsp_head_src", "# Title\n");
13148        d.caret = d.source.find('T').unwrap();
13149        d.backspace();
13150        assert_eq!(d.source, "#Title\n");
13151    }
13152
13153    #[test]
13154    fn outdent_unnests_an_ordered_item_in_one_press() {
13155        // Shift+Tab gives back exactly the marker width the indent added, so a
13156        // nested ordered item unnests in a single press, and the flattened list
13157        // renumbers back to a clean 1, 2, 3.
13158        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
13159        d.caret = d.source.find('b').unwrap();
13160        d.outdent();
13161        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13162        let lists = d
13163            .nodes()
13164            .iter()
13165            .filter(|n| n.kind == Kind::OrderedList)
13166            .count();
13167        assert_eq!(lists, 1, "back to one flat list");
13168    }
13169
13170    #[test]
13171    fn table_insert_row_adds_a_row_below_the_caret() {
13172        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13173        d.caret = d.source.find('1').unwrap(); // in the body row
13174        d.table_insert_row(true);
13175        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
13176    }
13177
13178    #[test]
13179    fn table_insert_and_delete_column_at_the_caret() {
13180        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13181        d.caret = d.source.find('a').unwrap(); // column 0
13182        d.table_insert_column(true); // add a column to the right of `a`
13183        assert_eq!(
13184            d.source,
13185            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
13186        );
13187        d.caret = d.source.find('b').unwrap(); // now the third column
13188        d.table_delete_column();
13189        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
13190    }
13191
13192    // ── ragged formats ───────────────────────────────────────────────────────
13193    // No format spells every gesture. HTML writes the inline marks as a tag pair
13194    // and no heading, list, quote or link; Markdown spells five of the eight
13195    // marks — the highlight only because leaf parses with `highlight`, which is
13196    // why the question is asked with the extensions; djot spells all eight and
13197    // no in-cell break. leaf asks twig per
13198    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
13199    // op discover the fact on its own — one of them didn't.
13200
13201    /// An HTML document in the rich view, ready for a gesture.
13202    fn html_doc(body: &str) -> Doc {
13203        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
13204        d.view = View::Wysiwyg;
13205        d.build_visual(80);
13206        d
13207    }
13208
13209    #[test]
13210    fn a_table_gesture_leaves_an_html_table_alone() {
13211        // The regression this guard exists for. twig's table editor consults no
13212        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
13213        // HTML `<table>` as a *pipe table* and reported success: the whole
13214        // element replaced by `| a | b |`, silently, on one press of a toolbar
13215        // button. Every grid op went the same way.
13216        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
13217        // A table of named operations, which is what it looks like.
13218        #[allow(clippy::type_complexity)]
13219        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
13220            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
13221            ("delete row", &|d: &mut Doc| d.table_delete_row()),
13222            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
13223            ("delete column", &|d: &mut Doc| d.table_delete_column()),
13224            ("align", &|d: &mut Doc| {
13225                d.table_set_alignment(Alignment::Right)
13226            }),
13227            ("move row", &|d: &mut Doc| d.table_move_row(true)),
13228            ("move column", &|d: &mut Doc| d.table_move_column(true)),
13229        ];
13230        for (name, op) in ops {
13231            let mut d = html_doc(src);
13232            d.caret = d.source.find('a').unwrap();
13233            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
13234            op(&mut d);
13235            assert_eq!(d.source, src, "{name} rewrote an HTML table");
13236            assert!(
13237                !d.dirty,
13238                "{name} marked the document dirty without editing it"
13239            );
13240            assert!(d.status.is_some(), "{name} refused without saying why");
13241        }
13242    }
13243
13244    #[test]
13245    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
13246        // A task box is a form control in HTML and a footnote has no native
13247        // spelling at all — the two gestures twig 3.5 still spells nothing
13248        // for, now that a quote, a list, a link and an image print through
13249        // its renderer (see the test below).
13250        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13251        // A table of named operations, which is what it looks like.
13252        #[allow(clippy::type_complexity)]
13253        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
13254            ("task item", &|d: &mut Doc| d.toggle_task_item()),
13255            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
13256            ("footnote", &|d: &mut Doc| d.insert_footnote()),
13257        ];
13258        for (name, op) in ops {
13259            let mut d = html_doc(src);
13260            let at = d.source.find("Hello").unwrap();
13261            d.caret = at;
13262            d.anchor = Some(at + 5); // a selection, for the ops that want one
13263            op(&mut d);
13264            assert_eq!(d.source, src, "{name} edited an HTML document");
13265            assert!(
13266                !d.dirty,
13267                "{name} marked the document dirty without editing it"
13268            );
13269            let status = d.status.as_deref().unwrap_or("");
13270            assert!(
13271                status.contains("html"),
13272                "{name}: the refusal should name the format, got {status:?}"
13273            );
13274        }
13275    }
13276
13277    #[test]
13278    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
13279        // twig 3.5: where HTML has no marker alphabet it prints the fresh
13280        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
13281        // the paragraph as its item, an `<a>` or `<img>` over the selection.
13282        // Until then every one of these was a refusal; now each is a real
13283        // edit, which is what the toolbar's capability flags say too.
13284        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13285        #[allow(clippy::type_complexity)]
13286        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
13287            (
13288                "quote",
13289                &|d: &mut Doc| d.toggle_blockquote(),
13290                "<blockquote>",
13291            ),
13292            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
13293            (
13294                "ordered list",
13295                &|d: &mut Doc| d.toggle_list(true),
13296                "<ol>\n<li>",
13297            ),
13298            (
13299                "link",
13300                &|d: &mut Doc| d.insert_link("https://example.dev"),
13301                "<a href=\"https://example.dev\">Hello</a>",
13302            ),
13303            (
13304                "image",
13305                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
13306                "<img alt=\"Hello\" src=\"pic.png\">",
13307            ),
13308        ];
13309        for (name, op, expect) in ops {
13310            let mut d = html_doc(src);
13311            let at = d.source.find("Hello").unwrap();
13312            d.caret = at;
13313            d.anchor = Some(at + 5);
13314            op(&mut d);
13315            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
13316            assert!(d.dirty, "{name}: a real edit");
13317            assert_eq!(
13318                d.status, None,
13319                "{name}: a supported gesture reports nothing"
13320            );
13321        }
13322    }
13323
13324    #[test]
13325    fn html_spells_a_heading_as_its_tag_pair() {
13326        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
13327        // along — the one block gesture whose HTML shape it can write. So ⌘2
13328        // in an HTML document is a real edit, and ⌘0 takes it back.
13329        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
13330        let mut d = html_doc(src);
13331        d.caret = d.source.find("Hello").unwrap();
13332        d.toggle_heading(2);
13333        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
13334        assert!(d.dirty);
13335        assert_eq!(d.status, None, "a supported gesture reports nothing");
13336        d.toggle_heading(2);
13337        assert_eq!(d.source, src, "the same level again is back to a paragraph");
13338    }
13339
13340    #[test]
13341    fn html_spells_the_inline_marks_and_the_rule() {
13342        // The other half, and why one per-document flag stopped being enough:
13343        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
13344        // already emits and the parser reads straight back as the same mark —
13345        // and the rule button writes an `<hr>`. Refusing these on the old
13346        // "HTML is parse-only" reading would now be leaf's own limitation.
13347        let mut d = html_doc("<p>Hello world</p>\n");
13348        let at = d.source.find("world").unwrap();
13349        d.caret = at;
13350        d.anchor = Some(at + 5);
13351        d.toggle(InlineKind::Strong);
13352        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
13353        assert!(d.dirty);
13354        assert_eq!(d.status, None, "a supported gesture reports nothing");
13355
13356        // And off again — the toggle reverses, which is the property that makes
13357        // authoring in HTML worth offering rather than a one-way trip.
13358        d.toggle(InlineKind::Strong);
13359        assert_eq!(d.source, "<p>Hello world</p>\n");
13360
13361        let mut d = html_doc("<p>Hello world</p>\n");
13362        d.caret = d.source.find("world").unwrap();
13363        d.insert_thematic_break();
13364        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
13365    }
13366
13367    #[test]
13368    fn a_mark_the_format_cannot_spell_arms_nothing() {
13369        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
13370        // sticky mark for the next text typed. Guarding only the twig call
13371        // leaves that path live, promising a mark the gesture will not write and
13372        // then swallowing the error inside `insert`.
13373        //
13374        // Markdown carries this, on the superscript now rather than on the
13375        // highlight: `^x^` is text there in any configuration, whereas twig
13376        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
13377        // which every leaf document does.
13378        let mut d = doc_with("mark", "Hello world\n");
13379        d.view = View::Wysiwyg;
13380        d.build_visual(80);
13381        d.caret = d.source.find("world").unwrap();
13382        d.toggle(InlineKind::Superscript);
13383        assert!(d.pending_marks.is_empty(), "no mark should be armed");
13384        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
13385        d.insert("X");
13386        assert_eq!(d.source, "Hello Xworld\n");
13387    }
13388
13389    #[test]
13390    fn markdown_authors_a_highlight_and_a_strikethrough() {
13391        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
13392        // write. `==x==` is authorable because leaf's own `parse_extensions`
13393        // turns `highlight` on — twig will only mint bytes this editor's reparse
13394        // reads back — and `~~x~~` because GFM strikethrough is parsed by
13395        // default, so the refusal there was never right for any leaf document.
13396        for (kind, marked) in [
13397            (InlineKind::Mark, "a ==word== b\n"),
13398            (InlineKind::Delete, "a ~~word~~ b\n"),
13399        ] {
13400            let mut d = doc_with("author_mark", "a word b\n");
13401            d.anchor = Some(2);
13402            d.caret = 6;
13403            d.toggle(kind);
13404            assert_eq!(d.source, marked, "{kind:?}");
13405            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
13406            assert!(d.dirty, "{kind:?}");
13407            // The region stays selected, so the second press reverses it — the
13408            // property that separates authoring from a one-way trip.
13409            d.toggle(kind);
13410            assert_eq!(d.source, "a word b\n", "{kind:?}");
13411        }
13412    }
13413
13414    #[test]
13415    fn an_authored_highlight_reads_back_as_a_mark() {
13416        // The round trip the extension gate exists to protect: what the toggle
13417        // writes, the reparse must read back as a `mark` rather than as two
13418        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
13419        // rebuilt map rather than from the source text.
13420        let mut d = doc_with("mark_roundtrip", "a word b\n");
13421        d.view = View::Wysiwyg;
13422        d.build_visual(80);
13423        d.anchor = Some(2);
13424        d.caret = 6;
13425        d.toggle(InlineKind::Mark);
13426        assert_eq!(d.source, "a ==word== b\n");
13427        d.build_visual(80);
13428        let w = d
13429            .vmap
13430            .rows
13431            .iter()
13432            .flat_map(|r| r.glyphs.iter())
13433            .find(|g| g.ch == 'w')
13434            .expect("the highlighted word");
13435        assert_eq!(w.style.role, crate::Role::Mark(None));
13436    }
13437
13438    #[test]
13439    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
13440        // The three states of one gesture, in the order a palette is pressed:
13441        // an uncoloured highlight takes the prefix, a coloured one has it
13442        // replaced, and `None` takes it away with the space that was part of the
13443        // spelling.
13444        let mut d = doc_with("mark_colour", "a ==word== b\n");
13445        d.caret = d.source.find("word").unwrap();
13446        d.set_mark_color(Some(MarkColor::Red));
13447        assert_eq!(d.source, "a ==🔴 word== b\n");
13448        assert_eq!(d.status, None);
13449        assert!(d.dirty);
13450
13451        d.set_mark_color(Some(MarkColor::Blue));
13452        assert_eq!(d.source, "a ==🔵 word== b\n");
13453
13454        d.set_mark_color(None);
13455        assert_eq!(d.source, "a ==word== b\n");
13456    }
13457
13458    #[test]
13459    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
13460        // The prefix is written *before* the word, so an offset in the word has
13461        // to ride its width — a caret that stayed put would be a caret that
13462        // walked backwards through the text it was standing in.
13463        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
13464        let word = d.source.find("word").unwrap();
13465        d.caret = word + 2; // between `wo` and `rd`
13466        d.set_mark_color(Some(MarkColor::Red));
13467        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
13468
13469        // And back the other way when the prefix goes.
13470        d.set_mark_color(None);
13471        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
13472    }
13473
13474    #[test]
13475    fn the_colour_at_the_caret_is_what_the_palette_lights() {
13476        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
13477        d.caret = d.source.find("red").unwrap();
13478        assert!(d.caret_in_mark());
13479        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
13480
13481        d.caret = d.source.find("plain").unwrap();
13482        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
13483        assert_eq!(d.mark_color_at_caret(), None);
13484
13485        d.caret = d.source.find(" and ").unwrap() + 2;
13486        assert!(!d.caret_in_mark());
13487        assert_eq!(d.mark_color_at_caret(), None);
13488    }
13489
13490    #[test]
13491    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
13492        // The gesture colours a highlight that exists; it does not make one.
13493        // Two presses is the price of a coloured highlight from bare text, and
13494        // the reason is undo — one press that spliced twice would take two
13495        // presses to take back.
13496        let mut d = doc_with("mark_colour_none", "a word b\n");
13497        d.caret = d.source.find("word").unwrap();
13498        d.set_mark_color(Some(MarkColor::Red));
13499        assert_eq!(d.source, "a word b\n");
13500        assert!(d.status.is_some(), "it should say why");
13501        assert!(!d.dirty);
13502
13503        // Clearing where there is nothing to clear is the same refusal, not a
13504        // quiet success — the caret is in no highlight either way.
13505        d.status = None;
13506        d.set_mark_color(None);
13507        assert_eq!(d.source, "a word b\n");
13508        assert!(d.status.is_some());
13509    }
13510
13511    #[test]
13512    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
13513        // twig answers this one *successfully* with a `Change` describing some
13514        // earlier edit, so a caller that trusted the change would jump the caret
13515        // to wherever that was. Core answers it before asking.
13516        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
13517        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
13518        d.caret = d.source.find("word").unwrap();
13519        let (source, caret) = (d.source.clone(), d.caret);
13520        d.set_mark_color(None);
13521        assert_eq!(d.source, source);
13522        assert_eq!(
13523            d.caret, caret,
13524            "the caret must not ride a change that isn't one"
13525        );
13526        assert_eq!(d.status, None, "and it is not an error either");
13527    }
13528
13529    #[test]
13530    fn djot_spells_the_highlight_and_not_its_colour() {
13531        // The reason the palette is its own capability rather than the Highlight
13532        // button's: `{=word=}` is a highlight djot writes happily, and there is
13533        // no djot spelling for a colour on it.
13534        assert!(Capabilities::of(Format::Djot).mark);
13535        assert!(!Capabilities::of(Format::Djot).mark_color);
13536        assert!(Capabilities::of(Format::Markdown).mark_color);
13537
13538        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
13539        d.caret = d.source.find("word").unwrap();
13540        assert!(
13541            d.caret_in_mark(),
13542            "the caret is in a highlight all the same"
13543        );
13544        d.set_mark_color(Some(MarkColor::Red));
13545        assert_eq!(d.source, "a {=word=} b\n");
13546        assert!(
13547            d.status.as_deref().unwrap_or("").contains("djot"),
13548            "and the refusal names the document's format: {:?}",
13549            d.status
13550        );
13551    }
13552
13553    #[test]
13554    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
13555        // The round trip that matters for a palette: the bytes twig writes are
13556        // bytes its own reparse reads back as a colour, so the swatch that was
13557        // pressed is the swatch that lights afterwards.
13558        let mut d = doc_with("mark_colour_undo", "a word b\n");
13559        d.anchor = Some(2);
13560        d.caret = 6;
13561        d.toggle(InlineKind::Mark);
13562        d.caret = d.source.find("word").unwrap();
13563        d.set_mark_color(Some(MarkColor::Green));
13564        assert_eq!(d.source, "a ==🟢 word== b\n");
13565        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
13566
13567        // One splice, one step: the colour comes off and the highlight stays.
13568        d.undo();
13569        assert_eq!(d.source, "a ==word== b\n");
13570        d.undo();
13571        assert_eq!(d.source, "a word b\n");
13572    }
13573
13574    #[test]
13575    fn every_colour_leaf_names_is_one_twig_writes() {
13576        // The two enums are one vocabulary, and this is what says so: each of
13577        // leaf's colours writes an emoji twig's reparse reads back as *that*
13578        // colour, so `twig_mark_color`'s table cannot quietly pair red with
13579        // orange.
13580        for color in MarkColor::ALL {
13581            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
13582            d.caret = d.source.find("word").unwrap();
13583            d.set_mark_color(Some(color));
13584            assert_eq!(d.status, None, "{color:?}");
13585            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
13586        }
13587    }
13588
13589    #[test]
13590    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
13591        // The two presses a coloured highlight is made of, in the state the
13592        // first one leaves: `toggle` selects the whole `==word==` and puts the
13593        // caret one past the closing `==`, which is *not* in the mark. Asking at
13594        // the caret alone would refuse to colour the highlight just written —
13595        // the selection's start is what answers.
13596        let mut d = doc_with("mark_colour_fresh", "a word b\n");
13597        d.anchor = Some(2);
13598        d.caret = 6;
13599        d.toggle(InlineKind::Mark);
13600        assert_eq!(d.source, "a ==word== b\n");
13601        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
13602
13603        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
13604        d.set_mark_color(Some(MarkColor::Yellow));
13605        assert_eq!(d.source, "a ==🟡 word== b\n");
13606        assert_eq!(d.status, None);
13607    }
13608
13609    #[test]
13610    fn one_press_highlights_a_selection_and_colours_it() {
13611        // What a toolbar swatch means over a plain selection, and the undo it
13612        // has to have: one press, one step. Two steps would leave an uncoloured
13613        // highlight behind on the way back, which is a state the author never
13614        // asked for and never saw.
13615        let mut d = doc_with("highlight_one", "a word b\n");
13616        d.anchor = Some(2);
13617        d.caret = 6;
13618        d.highlight(Some(MarkColor::Purple));
13619        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
13620        assert_eq!(d.status, None);
13621
13622        d.undo();
13623        assert_eq!(d.source, "a word b\n", "one press, one undo");
13624    }
13625
13626    #[test]
13627    fn one_press_on_an_existing_highlight_only_recolours_it() {
13628        // The other half: inside a highlight there is nothing to make, so the
13629        // compound is the plain gesture and the text is untouched.
13630        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
13631        d.caret = d.source.find("word").unwrap();
13632        d.highlight(Some(MarkColor::Blue));
13633        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
13634        d.undo();
13635        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
13636    }
13637
13638    #[test]
13639    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
13640        // `None` means "no colour", and over bare text that is the Highlight
13641        // button's own job. The fold must not happen here — there is no second
13642        // splice, and folding would take the *previous* edit into this one.
13643        let mut d = doc_with("highlight_none", "a word b and more\n");
13644        d.caret = d.source.find("more").unwrap() + 4; // after "more"
13645        d.insert("!"); // an earlier edit for a wrong fold to swallow
13646        d.anchor = Some(2);
13647        d.caret = 6;
13648        d.highlight(None);
13649        assert_eq!(d.source, "a ==word== b and more!\n");
13650
13651        d.undo();
13652        assert_eq!(
13653            d.source, "a word b and more!\n",
13654            "only the highlight came off"
13655        );
13656        d.undo();
13657        assert_eq!(
13658            d.source, "a word b and more\n",
13659            "and the edit before it survived"
13660        );
13661    }
13662
13663    #[test]
13664    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
13665        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
13666        // a colour cannot be armed with it — so the compound declines rather
13667        // than leaving half a promise.
13668        let mut d = doc_with("highlight_bare", "a word b\n");
13669        d.caret = 4;
13670        d.highlight(Some(MarkColor::Red));
13671        assert_eq!(d.source, "a word b\n");
13672        assert!(d.pending_marks.is_empty(), "and nothing armed");
13673        assert!(d.status.is_some());
13674    }
13675
13676    #[test]
13677    fn a_read_only_document_takes_no_colour() {
13678        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
13679        d.caret = d.source.find("word").unwrap();
13680        d.set_read_only(true);
13681        d.set_mark_color(Some(MarkColor::Red));
13682        assert_eq!(d.source, "a ==word== b\n");
13683    }
13684
13685    #[test]
13686    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
13687        // The other door into `toggle`: no selection, so nothing reaches twig
13688        // until `insert` realises the armed mark. It is armed now — the guard
13689        // above asks `Doc::supports`, which asks with the extensions — and what
13690        // it writes is the same `==…==`.
13691        let mut d = doc_with("sticky_mark", "xy\n");
13692        d.caret = 1;
13693        d.toggle(InlineKind::Mark);
13694        assert!(d.pending_marks.contains(InlineKind::Mark));
13695        d.insert("Z");
13696        assert_eq!(d.source, "x==Z==y\n");
13697    }
13698
13699    #[test]
13700    fn html_documents_still_take_typed_text() {
13701        // The guard covers *markup* gestures and must not touch plain editing:
13702        // twig's splicer is language-neutral, and typing into an HTML document
13703        // is the thing that does work today.
13704        let mut d = html_doc("<p>Hello world</p>\n");
13705        d.caret = d.source.find("world").unwrap();
13706        d.insert("big ");
13707        assert_eq!(d.source, "<p>Hello big world</p>\n");
13708        assert!(d.dirty);
13709        d.backspace();
13710        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
13711        d.undo();
13712        d.undo();
13713        assert_eq!(d.source, "<p>Hello world</p>\n");
13714    }
13715
13716    #[test]
13717    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
13718        // `authorable` only separates "there is a door in" from "there is not",
13719        // and HTML is on the near side of that line — which is exactly why a
13720        // toolbar must not be built from it.
13721        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
13722        assert!(html.authorable());
13723        assert!(
13724            !Doc::from_source("<r>x</r>".into(), Format::Xml)
13725                .unwrap()
13726                .authorable()
13727        );
13728
13729        let caps = html.capabilities();
13730        assert!(caps.bold && caps.italic && caps.code && caps.mark);
13731        assert!(caps.thematic_break && caps.cell_line_break);
13732        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
13733        // quote, a list, a code block's language, a link and an image — each
13734        // printed as a fresh node where HTML has no marker to rewrite. A task
13735        // box is a form control and a footnote has no spelling, so those two
13736        // are what keeps the record ragged.
13737        assert!(caps.heading && caps.blockquote && caps.bullet_list);
13738        assert!(caps.link && caps.image && caps.code_language);
13739        assert!(!caps.task && !caps.footnote);
13740        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
13741        // twig's table editor would happily re-emit as `| a | b |`.
13742        assert!(!caps.table);
13743
13744        // The two lightweight formats spell everything leaf offers — and still
13745        // differ from each other, which is the other half of why one boolean
13746        // can't serve.
13747        for fmt in [Format::Markdown, Format::Djot] {
13748            let caps = Capabilities::of(fmt);
13749            assert!(
13750                caps.heading && caps.blockquote && caps.ordered_list,
13751                "{fmt:?}"
13752            );
13753            assert!(
13754                caps.task && caps.link && caps.image && caps.table,
13755                "{fmt:?}"
13756            );
13757        }
13758        // Both spell the highlight and the strikethrough: djot natively, and
13759        // Markdown because `Capabilities` asks with `parse_extensions` rather
13760        // than with twig's defaults — `==x==` is text under those, and a mark
13761        // under the `highlight` leaf always parses with.
13762        for fmt in [Format::Markdown, Format::Djot] {
13763            let caps = Capabilities::of(fmt);
13764            assert!(caps.mark && caps.strike, "{fmt:?}");
13765        }
13766        // What still separates them, now that the highlight doesn't: djot has
13767        // no in-cell break, and Markdown spells neither of the scripts.
13768        assert!(Capabilities::of(Format::Djot).superscript);
13769        assert!(!Capabilities::of(Format::Markdown).superscript);
13770        assert!(Capabilities::of(Format::Markdown).cell_line_break);
13771        assert!(!Capabilities::of(Format::Djot).cell_line_break);
13772
13773        // A parse-only format answers no to every one of them, so the coarse
13774        // predicate and the record agree there.
13775        let caps = Capabilities::of(Format::Xml);
13776        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
13777    }
13778
13779    #[test]
13780    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
13781        // The guard exists to name the *document's* format rather than twig's
13782        // internals, so the message has to survive being one leaf writes itself.
13783        // Checked against a gesture twig also refuses, since that is the pair
13784        // most at risk of drifting apart — the task box, once the code
13785        // language stopped being one (twig 3.5).
13786        let mut d = html_doc("<p>Hello</p>\n");
13787        d.caret = d.source.find("Hello").unwrap();
13788        d.toggle_task_item();
13789        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
13790        assert!(!d.dirty);
13791    }
13792
13793    #[test]
13794    fn table_set_alignment_respells_the_delimiter() {
13795        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13796        d.caret = d.source.find('b').unwrap();
13797        d.table_set_alignment(Alignment::Right);
13798        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
13799    }
13800
13801    #[test]
13802    fn each_empty_table_cell_has_its_own_editable_home() {
13803        // Regression: an empty cell has no twig content_span, so both cells of a
13804        // `|  |  |` row collapsed onto the row's start (before the first `│`).
13805        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
13806        // tell the cells apart. Each empty cell must now have a distinct home
13807        // inside it.
13808        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
13809        let (c0, c1) = {
13810            let cells = &d.vmap.tables[0].grid[1].cells;
13811            (cells[0].start, cells[1].start)
13812        };
13813        assert!(
13814            c0 < c1,
13815            "the two empty cells have distinct homes: {c0} < {c1}"
13816        );
13817        d.caret = c0;
13818        d.insert("x");
13819        assert_eq!(
13820            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
13821            "typed inside the cell"
13822        );
13823    }
13824
13825    #[test]
13826    fn arrows_step_into_each_empty_table_cell() {
13827        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
13828        let (c0, c1) = {
13829            let cells = &d.vmap.tables[0].grid[1].cells;
13830            (cells[0].start, cells[1].start)
13831        };
13832        d.caret = d.source.find('b').unwrap(); // in the header's second cell
13833        let mut seen = std::collections::HashSet::new();
13834        for _ in 0..6 {
13835            d.move_right(false);
13836            seen.insert(d.caret);
13837        }
13838        assert!(
13839            seen.contains(&c0),
13840            "right arrow reaches the first empty cell"
13841        );
13842        assert!(
13843            seen.contains(&c1),
13844            "right arrow reaches the second empty cell"
13845        );
13846    }
13847
13848    #[test]
13849    fn table_op_off_a_table_is_a_no_op_with_a_status() {
13850        let mut d = doc_with("tbl_none", "just text\n");
13851        d.caret = 3;
13852        d.table_insert_row(true);
13853        assert_eq!(d.source, "just text\n", "nothing changed");
13854        assert!(d.status.is_some(), "a status explains why");
13855        assert!(!d.caret_in_table());
13856    }
13857
13858    #[test]
13859    fn enter_in_an_ordered_list_renumbers_the_following_items() {
13860        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
13861        // the renumber pass keeps them sequential, matching what the view draws.
13862        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
13863        d.caret = d.source.find('a').unwrap() + 1; // end of item a
13864        d.newline();
13865        d.insert("x");
13866        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
13867    }
13868
13869    #[test]
13870    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
13871        for view in [View::Source, View::Wysiwyg] {
13872            let mut d = doc_in(view, "outdent_noop", "hello\n");
13873            d.caret = 2;
13874            d.outdent();
13875            assert_eq!(d.source, "hello\n");
13876            assert!(!d.dirty, "a no-op is not a modification");
13877            d.undo();
13878            assert_eq!(
13879                d.status.as_deref(),
13880                Some("nothing to undo"),
13881                "spends no undo step"
13882            );
13883            assert_eq!(d.source, "hello\n");
13884        }
13885    }
13886
13887    #[test]
13888    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
13889        for view in [View::Source, View::Wysiwyg] {
13890            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
13891            d.anchor = Some(0);
13892            d.caret = 7; // through "two"
13893            d.indent();
13894            assert_eq!(
13895                d.source, "  one\n\n  two\n",
13896                "the blank line keeps no trailing pad"
13897            );
13898            // Selected, so a second Tab lands on the same lines rather than on
13899            // whatever the shifted offsets now cover.
13900            assert_eq!(d.selection(), Some((0, 12)));
13901            d.indent();
13902            assert_eq!(d.source, "    one\n\n    two\n");
13903        }
13904    }
13905
13906    #[test]
13907    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
13908        for view in [View::Source, View::Wysiwyg] {
13909            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
13910            d.anchor = Some(0);
13911            d.caret = 15;
13912            d.outdent();
13913            assert_eq!(d.source, "two\none\nnone\n");
13914        }
13915    }
13916
13917    #[test]
13918    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
13919        for view in [View::Source, View::Wysiwyg] {
13920            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
13921            d.anchor = Some(0);
13922            d.caret = 7;
13923            d.indent();
13924            assert_eq!(d.source, "  one\n\n  two\n");
13925            d.undo();
13926            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
13927            assert_eq!(
13928                d.selection(),
13929                Some((0, 7)),
13930                "with the selection it was aimed at"
13931            );
13932            d.redo();
13933            assert_eq!(d.source, "  one\n\n  two\n");
13934            assert_eq!(
13935                d.selection(),
13936                Some((0, 12)),
13937                "redo replays the caret the indent placed, not the one splice left"
13938            );
13939        }
13940    }
13941
13942    #[test]
13943    fn vertical_motion_keeps_the_column() {
13944        let mut d = doc_with("move", "abcd\nef\n");
13945        d.caret = 3; // "abc|d" on row 0, col 3
13946        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
13947        assert_eq!(d.caret, 7); // just after "ef"
13948    }
13949
13950    // ── goal column ──────────────────────────────────────────────────────────
13951
13952    #[test]
13953    fn vertical_motion_goal_column_survives_a_short_line() {
13954        // Regression: re-deriving the column from the clamped position on
13955        // every step permanently forgets it once a short line clamps it.
13956        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
13957        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
13958        assert_eq!(
13959            g("abcd|ef\nxy\nghijkl\n", |d| {
13960                d.move_down(false); // clamps to end of "xy"
13961                d.move_down(false); // restores col 4 on the long line
13962            }),
13963            "abcdef\nxy\nghij|kl\n"
13964        );
13965    }
13966
13967    #[test]
13968    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
13969        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
13970        assert_eq!(d.goal_col, None);
13971        d.caret = 4; // row 0, col 4
13972        d.move_down(false); // clamps into "xy"; goal stays the original col
13973        assert_eq!(d.goal_col, Some(4));
13974        assert_eq!(d.caret_pos(), (1, 2));
13975
13976        // A horizontal motion drops the goal column...
13977        d.move_left(false);
13978        assert_eq!(d.goal_col, None);
13979
13980        // ...so the next vertical motion picks up the *new* column (1), not
13981        // the stale one (4).
13982        d.move_down(false);
13983        assert_eq!(d.goal_col, Some(1));
13984        assert_eq!(d.caret_pos(), (2, 1));
13985    }
13986
13987    #[test]
13988    fn editing_clears_the_goal_column() {
13989        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
13990        d.caret = 4;
13991        d.move_down(false);
13992        assert_eq!(d.goal_col, Some(4));
13993        d.insert("Z");
13994        assert_eq!(d.goal_col, None);
13995    }
13996
13997    #[test]
13998    fn vertical_motion_on_an_empty_document_is_a_no_op() {
13999        let mut d = doc_with("empty_vert", "");
14000        d.move_down(false);
14001        assert_eq!(d.caret, 0);
14002        d.move_up(false);
14003        assert_eq!(d.caret, 0);
14004    }
14005
14006    // ── the document's edges ─────────────────────────────────────────────────
14007
14008    #[test]
14009    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
14010        // The reproduction, and the disagreement: Down on the last line ran to
14011        // the end of the document in the source view — by accident, an
14012        // out-of-range row clamping to the end of the string — and did nothing
14013        // whatever in the view leaf opens in. One rule now, in both.
14014        for (view, tag) in VIEWS {
14015            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
14016            d.caret = 1;
14017            d.move_down(false);
14018            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
14019            d.move_up(false);
14020            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
14021        }
14022    }
14023
14024    #[test]
14025    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
14026        // Down off the bottom is a motion like any other, so it latches a goal
14027        // column — and Up comes back to the column the caret left, not to the
14028        // one the document's end happened to be in.
14029        for (view, tag) in VIEWS {
14030            let gap = if view == View::Source { "\n" } else { "\n\n" };
14031            let src = format!("abcdef{gap}ghijkl");
14032            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
14033            d.caret = 2; // row 0, col 2
14034            d.move_down(false);
14035            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
14036            d.move_down(false);
14037            assert_eq!(
14038                d.caret,
14039                src.len(),
14040                "{tag}: Down off the bottom reaches the end"
14041            );
14042            d.move_up(false);
14043            assert_eq!(
14044                d.caret_pos().1,
14045                2,
14046                "{tag}: Up returns to the column Down left"
14047            );
14048        }
14049    }
14050
14051    #[test]
14052    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
14053        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
14054        // Up that did nothing still armed a goal column, and the next Down aimed
14055        // at a column the caret had never been in.
14056        for (view, tag) in VIEWS {
14057            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
14058            d.caret = 0;
14059            d.move_up(false);
14060            assert_eq!(d.caret, 0, "{tag}: already at the start");
14061            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
14062
14063            d.caret = d.source.len();
14064            d.move_down(false);
14065            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
14066            assert_eq!(
14067                d.goal_col, None,
14068                "{tag}: a no-op Down latched a goal column"
14069            );
14070        }
14071    }
14072
14073    // ── soft wrap ────────────────────────────────────────────────────────────
14074    // Every other test here builds the map at 80 columns, where no fixture is
14075    // long enough to fold. A wrap is where one offset belongs to two rows at
14076    // once, and it broke everything that asks the caret what row it is on.
14077
14078    /// The wrapped fixture these cases share, folded at 12 columns into
14079    /// `one two ` / `three four ` / `five six ` / `seven eight`.
14080    fn wrapped_doc(name: &str) -> Doc {
14081        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
14082        d.build_visual(12);
14083        d
14084    }
14085
14086    #[test]
14087    fn home_and_end_work_from_a_wrapped_row() {
14088        // The reproduction: offset 19 is the `f` of "five", the first character
14089        // of the third row — and also the offset the second row ends at. It
14090        // resolved to the *second* row, so End aimed at a place the caret was
14091        // already in and did nothing, while Home walked backwards onto a row the
14092        // caret had left.
14093        let mut d = wrapped_doc("wrap_home_end");
14094        d.caret = 19;
14095        assert_eq!(
14096            d.caret_pos(),
14097            (2, 0),
14098            "the wrap boundary opens the third row"
14099        );
14100        d.move_end(false);
14101        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
14102        d.move_home(false);
14103        assert_eq!(d.caret, 19, "Home left the row the caret was on");
14104    }
14105
14106    #[test]
14107    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
14108        // The row's end is the last offset that is only ever its own: the offset
14109        // past it opens the row below, and aiming there would send a second
14110        // press on to *that* row's end, and a third to the next — End walking
14111        // down the paragraph rather than sitting where it landed.
14112        let mut d = wrapped_doc("wrap_end_twice");
14113        d.caret = 12; // inside "three", on the second row
14114        d.move_end(false);
14115        assert_eq!(
14116            d.caret, 18,
14117            "the end of `three four`, before the space the wrap ate"
14118        );
14119        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
14120        d.move_end(false);
14121        assert_eq!(d.caret, 18, "a second End moved the caret");
14122        d.move_home(false);
14123        assert_eq!(d.caret, 8, "Home takes the row's own start");
14124    }
14125
14126    #[test]
14127    fn vertical_motion_crosses_a_soft_wrap() {
14128        // Down aimed at the row below's column 0, an offset that resolved *up*
14129        // to the row above's end — so it landed on the offset it already had and
14130        // the caret could never leave a paragraph's first row.
14131        let mut d = wrapped_doc("wrap_down");
14132        d.caret = 0;
14133        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
14134            d.move_down(false);
14135            assert_eq!(d.caret, want, "Down stalled");
14136            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
14137        }
14138        d.move_down(false);
14139        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
14140
14141        // ...and back up, one row per press. The goal column is the end of the
14142        // last row, past every other row's width, so each press clamps to the
14143        // row's own last offset rather than to the one that opens the next.
14144        let mut d = wrapped_doc("wrap_up");
14145        d.caret = 39;
14146        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
14147            d.move_up(false);
14148            assert_eq!(d.caret, want, "Up stalled");
14149            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
14150        }
14151    }
14152
14153    #[test]
14154    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
14155        // The kills take the same line Home and End do, so in WYSIWYG they take
14156        // the visual row — and a soft wrap has no newline in it to delete, so
14157        // nothing is joined by reaching the end of one.
14158        let mut d = wrapped_doc("wrap_kill");
14159        d.caret = 19; // the `f` of "five", opening the third row
14160        d.delete_to_line_end();
14161        // The space the wrap ate goes with the row it was drawn on: sparing it
14162        // would leave "four  seven", two spaces where the row had been.
14163        assert_eq!(d.source, "one two three four seven eight");
14164
14165        // Backwards from the row's last caret position — which is *before* that
14166        // space, so this one survives, being on the far side of the caret.
14167        let mut d = wrapped_doc("wrap_kill_back");
14168        d.caret = 27;
14169        d.delete_to_line_start();
14170        assert_eq!(d.source, "one two three four  seven eight");
14171    }
14172
14173    // ── document start / end ────────────────────────────────────────────────
14174
14175    #[test]
14176    fn move_doc_start_and_end_jump_to_the_edges() {
14177        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
14178        assert_eq!(
14179            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
14180            "|hello\nworld\n"
14181        );
14182        assert_eq!(
14183            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
14184            "hello\nworld\n|"
14185        );
14186        // Already at the edge: a no-op.
14187        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
14188        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
14189    }
14190
14191    #[test]
14192    fn move_doc_start_and_end_extend_the_selection() {
14193        assert_eq!(
14194            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
14195                .move_doc_end(true)),
14196            "hello wor[ld\n|]"
14197        );
14198        assert_eq!(
14199            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
14200                .move_doc_start(true)),
14201            "[|hello wor]ld\n"
14202        );
14203    }
14204
14205    #[test]
14206    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
14207        let mut d = doc_with("empty_edges", "");
14208        d.move_doc_end(false);
14209        assert_eq!(d.caret, 0);
14210        d.move_doc_start(false);
14211        assert_eq!(d.caret, 0);
14212    }
14213
14214    // ── arrow collapses an active selection ─────────────────────────────────
14215
14216    #[test]
14217    fn arrow_collapses_selection_to_its_near_edge() {
14218        let mut d = doc_with("collapse", "hello world\n");
14219
14220        // Forward selection (anchor before caret): Right -> end, Left -> start.
14221        d.anchor = Some(2);
14222        d.caret = 7;
14223        d.move_right(false);
14224        assert_eq!((d.caret, d.anchor), (7, None));
14225
14226        d.anchor = Some(2);
14227        d.caret = 7;
14228        d.move_left(false);
14229        assert_eq!((d.caret, d.anchor), (2, None));
14230
14231        // Backward selection (anchor after caret): edges are the same
14232        // regardless of which end the caret started on.
14233        d.anchor = Some(7);
14234        d.caret = 2;
14235        d.move_right(false);
14236        assert_eq!((d.caret, d.anchor), (7, None));
14237
14238        d.anchor = Some(7);
14239        d.caret = 2;
14240        d.move_left(false);
14241        assert_eq!((d.caret, d.anchor), (2, None));
14242    }
14243
14244    #[test]
14245    fn arrow_with_extend_keeps_growing_the_selection() {
14246        let mut d = doc_with("collapse_extend", "hello world\n");
14247        d.anchor = Some(2);
14248        d.caret = 7;
14249        d.move_right(true); // extend: no collapse, caret steps one further
14250        assert_eq!((d.caret, d.anchor), (8, Some(2)));
14251    }
14252
14253    #[test]
14254    fn arrow_without_a_selection_moves_one_character_as_before() {
14255        let mut d = doc_with("no_collapse", "hello\n");
14256        d.caret = 2;
14257        d.move_right(false);
14258        assert_eq!(d.caret, 3);
14259        d.move_left(false);
14260        assert_eq!(d.caret, 2);
14261    }
14262
14263    /// Press Right until it stops, collecting the offsets walked through. Every
14264    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
14265    /// two stops sharing one source offset can't be moved between, so the caret
14266    /// stalls on the first of them and the walk never reaches the rest.
14267    fn walk_right(d: &mut Doc) -> Vec<usize> {
14268        let mut seen = vec![d.caret];
14269        for _ in 0..2000 {
14270            let before = d.caret;
14271            d.move_right(false);
14272            if d.caret == before {
14273                break;
14274            }
14275            seen.push(d.caret);
14276        }
14277        seen
14278    }
14279
14280    #[test]
14281    fn the_caret_crosses_a_soft_break() {
14282        // A newline inside a paragraph is a `soft_break`, which twig gives no
14283        // span of its own — the space it renders as used to borrow the offset of
14284        // the character before it, and a caret can't move without changing
14285        // offset. Right must walk clean off the end of the first line.
14286        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
14287        d.caret = 0;
14288        let seen = walk_right(&mut d);
14289        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14290    }
14291
14292    #[test]
14293    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
14294        // The paragraph holds one soft break. Folded (the default) it lays out as
14295        // a single reflowed row; Preserve re-lays it as a row per source line.
14296        // The setter must invalidate the cached map for the change to show, and
14297        // again on the way back — so a round trip returns to the folded layout.
14298        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
14299        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
14300        d.build_visual(80);
14301        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
14302
14303        d.set_line_flow(LineFlow::Preserve);
14304        d.build_visual(80);
14305        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
14306
14307        d.set_line_flow(LineFlow::Fold);
14308        d.build_visual(80);
14309        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
14310    }
14311
14312    #[test]
14313    fn the_caret_still_crosses_a_preserved_soft_break() {
14314        // Preserve renders the soft break as a row boundary rather than a space,
14315        // but the caret must still reach every offset — the break's own offset is
14316        // the first row's end stop, so Right walks clean off the end of line one
14317        // onto line two, exactly as it does when the break is folded.
14318        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
14319        d.set_line_flow(LineFlow::Preserve);
14320        d.build_visual(80);
14321        d.caret = 0;
14322        let seen = walk_right(&mut d);
14323        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14324    }
14325
14326    #[test]
14327    fn the_caret_walks_a_code_block() {
14328        // Every glyph of a code block used to map to the block's start, so the
14329        // whole block was a single offset and the caret couldn't move inside it.
14330        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
14331        let mut d = wysiwyg_doc("code_walk", src);
14332        d.caret = 0;
14333        let seen = walk_right(&mut d);
14334        // The fences are markup: hidden, and no caret stop. The code between
14335        // them is reached a character at a time.
14336        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
14337        for off in code.clone() {
14338            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
14339        }
14340        assert!(seen.contains(&code.end), "no stop after the last line");
14341    }
14342
14343    #[test]
14344    fn the_caret_walks_an_indented_code_block() {
14345        // An indented block's text has the four-space indent stripped, so it
14346        // isn't a verbatim slice and its lines have to be re-found. The caret
14347        // lands on the code, never in the indent.
14348        let src = "    indented\n    code\n";
14349        let mut d = wysiwyg_doc("indent_code_walk", src);
14350        d.caret = 0;
14351        let seen = walk_right(&mut d);
14352        assert!(seen.contains(&src.find("indented").unwrap()));
14353        assert!(seen.contains(&src.find("code").unwrap()));
14354        assert!(
14355            !seen.contains(&0) || seen[0] == 0,
14356            "the caret starts where it was put"
14357        );
14358        // Nothing in the stripped indent is a stop.
14359        for off in [1, 2, 3] {
14360            assert!(!seen.contains(&off), "landed in the indent at {off}");
14361        }
14362    }
14363
14364    #[test]
14365    fn the_caret_leaves_a_tight_heading() {
14366        // "# H" with text directly under it: the heading row's end and the
14367        // separator row's end are the same offset. Right used to find the
14368        // separator's copy, set the caret to where it already was, and stop.
14369        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
14370        d.caret = 2; // the "H"
14371        let seen = walk_right(&mut d);
14372        assert!(
14373            seen.len() > 2,
14374            "Right stalled at the heading's end: {seen:?}"
14375        );
14376        assert!(
14377            seen.contains(&8),
14378            "never reached the end of \"text\": {seen:?}"
14379        );
14380    }
14381
14382    #[test]
14383    fn the_caret_skips_the_gap_between_two_paragraphs() {
14384        // The blank line between two paragraphs is the boundary itself. The
14385        // caret used to be able to sit on it, and typing there landed in the
14386        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
14387        // soft break, so the text visibly snapped back up.
14388        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
14389        d.caret = 1; // the end of "A"
14390        d.move_right(false);
14391        assert_eq!(d.caret, 3, "Right stopped in the gap");
14392        d.insert("x");
14393        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
14394    }
14395
14396    #[test]
14397    fn down_from_a_paragraph_lands_on_the_next_one() {
14398        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
14399        d.caret = 0;
14400        d.move_down(false);
14401        assert_eq!(d.caret, 3, "Down stopped in the gap");
14402    }
14403
14404    #[test]
14405    fn clicking_the_gap_lands_on_real_text() {
14406        // A click can still *reach* the gap — it's drawn, so it's clickable.
14407        // It has to resolve to somewhere the caret can be.
14408        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
14409        d.click(1, 0, false); // the gap row
14410        assert!(
14411            d.caret == 1 || d.caret == 3,
14412            "click left the caret in the gap at {}",
14413            d.caret
14414        );
14415        d.insert("x");
14416        // Either edge of the boundary is a fair place to land; inside it isn't.
14417        assert!(
14418            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
14419            "click in the gap typed into the boundary: {:?}",
14420            d.source
14421        );
14422    }
14423
14424    #[test]
14425    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
14426        // Enter inserts a paragraph break, which leaves a blank line spare on
14427        // either side of a new one. That middle line is a real empty paragraph:
14428        // the caret lands there, and typing makes a paragraph rather than
14429        // extending a neighbour.
14430        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
14431        d.caret = 1;
14432        d.newline();
14433        assert_eq!(d.source, "A\n\n\n\nB\n");
14434        d.build_visual(80);
14435        let (row, _) = d.caret_pos();
14436        assert!(
14437            d.vmap.row_is_navigable(row),
14438            "the caret landed on a gap row"
14439        );
14440        d.insert("x");
14441        assert_eq!(
14442            d.source, "A\n\nx\n\nB\n",
14443            "the new paragraph merged into a neighbour"
14444        );
14445    }
14446
14447    #[test]
14448    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
14449        let mut d = wysiwyg_doc("gap_eof", "A\n");
14450        d.caret = 1;
14451        d.newline();
14452        d.build_visual(80);
14453        let (row, _) = d.caret_pos();
14454        assert!(
14455            d.vmap.row_is_navigable(row),
14456            "the caret landed on a gap row"
14457        );
14458        d.insert("x");
14459        assert!(
14460            d.source.starts_with("A\n\n") && d.source.contains('x'),
14461            "typing at the end merged into A: {:?}",
14462            d.source
14463        );
14464    }
14465
14466    #[test]
14467    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
14468        // A paragraph broken over two source lines is one paragraph. Selecting
14469        // it must not stop at the newline inside it — that newline is markup the
14470        // rich-text view exists to hide.
14471        let src = "one two\nthree four\n\nnext\n";
14472        let mut d = wysiwyg_doc("triple_para", src);
14473        d.select_block_at(2);
14474        assert_eq!(
14475            d.selected_text(),
14476            Some("one two\nthree four"),
14477            "stopped at the soft break"
14478        );
14479    }
14480
14481    #[test]
14482    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
14483        // The reader scrolls down past the caret's row. Nothing moved the
14484        // caret, so the view must stay where it was put — the old code revealed
14485        // the caret every frame, which dragged the view straight back and made
14486        // the document unscrollable past the caret.
14487        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
14488        d.caret = 0;
14489        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
14490        d.scroll = 4; // the wheel
14491        d.follow_caret(0, 3, 9);
14492        assert_eq!(
14493            d.scroll, 4,
14494            "the wheel was overruled by a caret that never moved"
14495        );
14496    }
14497
14498    #[test]
14499    fn moving_the_caret_brings_the_view_back_to_it() {
14500        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
14501        d.caret = 0;
14502        d.follow_caret(0, 3, 9);
14503        d.scroll = 6; // scrolled away
14504        d.move_right(false); // ...and now the caret moves
14505        let (row, _) = d.caret_pos();
14506        d.follow_caret(row, 3, 9);
14507        assert!(
14508            d.scroll <= row && row < d.scroll + 3,
14509            "caret row {row} off screen at scroll {}",
14510            d.scroll
14511        );
14512    }
14513
14514    #[test]
14515    fn scrolling_stops_at_the_last_row() {
14516        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
14517        d.caret = 0;
14518        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
14519        d.scroll = 999; // the wheel, spun hard
14520        d.follow_caret(0, 3, 3);
14521        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
14522    }
14523
14524    #[test]
14525    fn every_cell_of_a_wide_table_is_reachable() {
14526        // A table whose cells are far wider than the surface: the columns are
14527        // cut to fit and the text wraps inside them, so no cell hangs off the
14528        // right edge where the caret can never go.
14529        let src = "| Ingredient | Notes |\n|---|---|\n\
14530                   | flour milled coarse | sift it twice before folding it in |\n";
14531        let mut d = wysiwyg_doc("wide_table_walk", src);
14532        d.build_visual(30);
14533        d.caret = 0;
14534        let seen = walk_right(&mut d);
14535        for word in ["Ingredient", "Notes", "coarse", "folding"] {
14536            let at = src.find(word).unwrap();
14537            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
14538        }
14539    }
14540
14541    // ── view parity ──────────────────────────────────────────────────────────
14542    // `doc_with` pins the source view, so everything above tests a view users
14543    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
14544    // deletion golden cases through *both*, plus the WYSIWYG cases the two
14545    // can't share: where the source carries markup the rendered text is a
14546    // different string, and the views agreeing would itself be the bug.
14547
14548    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
14549
14550    /// Run `action` in both views on one `|`-marked fixture and assert they
14551    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
14552    /// source verbatim, so the two views are looking at the same text and any
14553    /// disagreement is one of them having lost the plot.
14554    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
14555        let (src, caret) = parse_caret(marked);
14556        let run = |view: View, tag: &str| {
14557            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
14558            d.caret = caret;
14559            action(&mut d);
14560            render_caret(&d)
14561        };
14562        let source = run(VIEWS[0].0, VIEWS[0].1);
14563        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
14564        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
14565        source
14566    }
14567
14568    #[test]
14569    fn word_motion_agrees_across_the_views_on_plain_prose() {
14570        let g = both_views;
14571        assert_eq!(
14572            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
14573            "hello |world"
14574        );
14575        assert_eq!(
14576            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
14577            "|hello world"
14578        );
14579        assert_eq!(
14580            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
14581            "hello| world"
14582        );
14583        assert_eq!(
14584            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
14585            "hello world|"
14586        );
14587        assert_eq!(
14588            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
14589            "foo|.bar"
14590        );
14591        assert_eq!(
14592            g("par_ext", "hello |world", |d| d.move_word_right(true)),
14593            "hello [world|]"
14594        );
14595    }
14596
14597    #[test]
14598    fn word_deletion_agrees_across_the_views_on_plain_prose() {
14599        let g = both_views;
14600        assert_eq!(
14601            g("par_db", "hello world|", |d| d.delete_word_back()),
14602            "hello |"
14603        );
14604        assert_eq!(
14605            g("par_df", "hello |world", |d| d.delete_word_forward()),
14606            "hello |"
14607        );
14608        assert_eq!(
14609            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
14610            "|bar baz"
14611        );
14612        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
14613    }
14614
14615    #[test]
14616    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
14617        let g = both_views;
14618        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
14619        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
14620        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
14621        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
14622    }
14623
14624    #[test]
14625    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
14626        // The reproduction: the stop table was built one stop per `char`, so
14627        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
14628        // source view, which steps by grapheme, can't reach and backspace can't
14629        // survive. The two views must land on the same offset.
14630        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
14631        for (view, tag) in VIEWS {
14632            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
14633            d.caret = 1;
14634            d.move_right(false);
14635            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
14636
14637            // ...and the edit that used to sever a joiner off the front of it.
14638            d.backspace();
14639            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
14640            assert_eq!(d.caret, 1);
14641        }
14642    }
14643
14644    #[test]
14645    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
14646        for (view, tag) in VIEWS {
14647            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
14648            d.caret = 0;
14649            d.move_right(false);
14650            assert_eq!(
14651                d.caret,
14652                "e\u{0301}".len(),
14653                "{tag} stopped on the combining mark"
14654            );
14655        }
14656    }
14657
14658    #[test]
14659    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
14660        // The general form: whatever route the caret takes through a document
14661        // full of clusters, it never lands between the codepoints of one — so no
14662        // motion-then-backspace sequence can leave a dangling joiner behind.
14663        use unicode_segmentation::UnicodeSegmentation;
14664
14665        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
14666        let mut d = wysiwyg_doc("cluster_walk", src);
14667        d.caret = 0;
14668        let boundaries: Vec<usize> = src
14669            .grapheme_indices(true)
14670            .map(|(i, _)| i)
14671            .chain(std::iter::once(src.len()))
14672            .collect();
14673        for off in walk_right(&mut d) {
14674            assert!(
14675                boundaries.contains(&off),
14676                "Right stopped at {off}, inside a grapheme cluster"
14677            );
14678        }
14679    }
14680
14681    #[test]
14682    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
14683        // The reproduction: ⌥→ from inside the opening `**` computed its
14684        // boundary over the raw source and landed on byte 8 — inside the
14685        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
14686        // "bold". The caret drew past the bold word and sat inside it.
14687        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
14688        d.caret = 2;
14689        d.move_word_right(false);
14690        assert!(
14691            d.vmap.is_stop(d.caret),
14692            "landed at {}, not a caret stop",
14693            d.caret
14694        );
14695        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
14696        // The rendered row is "a bold c": column 6 is the space just past "bold",
14697        // and now the caret is really there rather than only drawn there.
14698        assert_eq!(d.caret_pos(), (0, 6));
14699
14700        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
14701        d.move_word_left(false);
14702        assert_eq!(d.caret, 4);
14703        assert_eq!(d.caret_pos(), (0, 2));
14704    }
14705
14706    #[test]
14707    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
14708        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
14709        // inside the closing `**`, and left "a ** c\n" — delimiters with no
14710        // opener. Glyph space covers the word alone, which would leave
14711        // "a **** c": markup wrapped around nothing. The word and the styling
14712        // that was only ever the word's go together.
14713        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
14714        d.caret = 10;
14715        d.delete_word_back();
14716        assert_eq!(d.source, "a  c\n");
14717        assert_eq!(d.caret, 2);
14718
14719        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
14720        d.caret = 4; // the "b"
14721        d.delete_word_forward();
14722        assert_eq!(d.source, "a  c\n");
14723    }
14724
14725    #[test]
14726    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
14727        let src = "a ***bold*** c\n";
14728        let mut d = wysiwyg_doc("wys_word_del_nest", src);
14729        d.caret = src.find(" c").unwrap();
14730        d.delete_word_back();
14731        assert_eq!(
14732            d.source, "a  c\n",
14733            "the emph inside the strong empties it too"
14734        );
14735
14736        let src = "a `code` c\n";
14737        let mut d = wysiwyg_doc("wys_word_del_code", src);
14738        d.caret = src.find(" c").unwrap();
14739        d.delete_word_back();
14740        assert_eq!(d.source, "a  c\n");
14741    }
14742
14743    #[test]
14744    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
14745        // Only an *emptied* node goes. Take one word of two and the `**` still
14746        // has a job to do — over the word that's left, with the space the delete
14747        // pushed against the opening delimiter moved out in front of it, or the
14748        // run would be no run at all (`** words**` is literal asterisks — see
14749        // the mark-edge rule on `splice`).
14750        let src = "a **two words** c\n";
14751        let mut d = wysiwyg_doc("wys_word_del_partial", src);
14752        d.caret = src.find(" words").unwrap();
14753        d.delete_word_back();
14754        assert_eq!(d.source, "a  **words** c\n");
14755    }
14756
14757    #[test]
14758    fn source_view_word_motion_still_walks_the_markup() {
14759        // The other half of the decision: in the source view the `**` are
14760        // characters like any other — they're on the screen, so word motion has
14761        // to stop at them and a word-delete has to leave them behind. Only
14762        // WYSIWYG hides them, so only WYSIWYG steps over them.
14763        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
14764        assert_eq!(
14765            g("src_word_motion", "a |**bold** c\n", |d| d
14766                .move_word_right(false)),
14767            "a **bold|** c\n"
14768        );
14769        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
14770        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
14771        assert_eq!(
14772            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
14773            "a **| c\n"
14774        );
14775    }
14776
14777    #[test]
14778    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
14779        // The single invariant both bugs violated: the caret draws and edits at
14780        // the same place only when it's on a stop. `debug_assert_on_a_stop`
14781        // makes the same claim in-place; this pins it from the outside, over a
14782        // document with every kind of thing the map has to be careful about.
14783        // At two widths: the wide one every other test builds at, where no
14784        // fixture folds, and one narrow enough that they all do. A soft wrap is
14785        // where an offset stops being on exactly one row, and testing only the
14786        // width that never wraps is how the caret came to be pinned at the first
14787        // one Down reached.
14788        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
14789                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
14790        // A table of named operations, which is what it looks like.
14791        #[allow(clippy::type_complexity)]
14792        let motions: [(&str, fn(&mut Doc)); 8] = [
14793            ("right", |d| d.move_right(false)),
14794            ("left", |d| d.move_left(false)),
14795            ("word_right", |d| d.move_word_right(false)),
14796            ("word_left", |d| d.move_word_left(false)),
14797            ("down", |d| d.move_down(false)),
14798            ("up", |d| d.move_up(false)),
14799            ("home", |d| d.move_home(false)),
14800            ("end", |d| d.move_end(false)),
14801        ];
14802        for width in [80, 12] {
14803            let mut d = wysiwyg_doc("stop_invariant", src);
14804            d.build_visual(width);
14805            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14806            assert!(stops.len() > 20, "fixture should have plenty of stops");
14807            for start in stops {
14808                for (name, motion) in &motions {
14809                    d.caret = start;
14810                    d.anchor = None;
14811                    motion(&mut d);
14812                    assert!(
14813                        d.vmap.is_stop(d.caret),
14814                        "{name} from {start} at width {width} landed at {} — not a caret stop",
14815                        d.caret
14816                    );
14817                }
14818            }
14819        }
14820    }
14821
14822    #[test]
14823    fn no_wysiwyg_motion_is_a_dead_end() {
14824        // Down held to the bottom of a document reaches the bottom, and Up held
14825        // to the top reaches the top — from anywhere, at a width that wraps. The
14826        // invariant above says a motion lands somewhere legal; this one says it
14827        // gets somewhere at all, which is what a caret pinned at a wrap boundary
14828        // was quietly failing to do while every assertion around it held.
14829        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
14830                   - item one two three four five\n\nlast\n";
14831        for width in [80, 12] {
14832            let mut d = wysiwyg_doc("no_dead_end", src);
14833            d.build_visual(width);
14834            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14835            let (first, last) = (stops[0], stops[stops.len() - 1]);
14836            for &start in &stops {
14837                for (name, motion, want) in [
14838                    (
14839                        "down",
14840                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
14841                        last,
14842                    ),
14843                    ("up", |d: &mut Doc| d.move_up(false), first),
14844                ] {
14845                    d.caret = start;
14846                    d.anchor = None;
14847                    d.goal_col = None;
14848                    // Every row, plus the presses the edges take, plus slack.
14849                    for _ in 0..d.vmap.num_rows() + 4 {
14850                        motion(&mut d);
14851                    }
14852                    assert_eq!(
14853                        d.caret, want,
14854                        "{name} held from {start} at width {width} never arrived"
14855                    );
14856                }
14857            }
14858        }
14859    }
14860    // ── display columns ──────────────────────────────────────────────────────
14861    // A `col` is a terminal cell, not a character. The two are the same number
14862    // for the ASCII the fixtures above are written in, which is how they came
14863    // apart in the first place: `你` is one character drawn in two cells, so a
14864    // column counted in characters names a cell the text isn't in — one earlier
14865    // for every wide character to its left.
14866
14867    #[test]
14868    fn a_wide_character_is_two_columns_wide() {
14869        // The reproduction: `你` is one char and two cells, so the caret just
14870        // past it drew at column 1 — inside the character it had already left.
14871        for (view, tag) in VIEWS {
14872            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
14873            d.caret = "你".len();
14874            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
14875            d.caret = "你好".len();
14876            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
14877        }
14878    }
14879
14880    #[test]
14881    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
14882        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
14883        // two-cell — measuring six cells one at a time, but the character they
14884        // spell is drawn in two. Width belongs to the cluster, not the glyph,
14885        // and the frontends measure it the same way.
14886        let family = "👨‍👩‍👧";
14887        for (view, tag) in VIEWS {
14888            let src = format!("a{family}b\n");
14889            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
14890            d.caret = 1 + family.len();
14891            assert_eq!(
14892                d.caret_pos(),
14893                (0, 3),
14894                "{tag}: 'a' is one cell, the family two"
14895            );
14896        }
14897    }
14898
14899    #[test]
14900    fn both_cells_of_a_wide_character_mean_the_character() {
14901        // Clicking the far half of `好` is still clicking `好`: half a character
14902        // is not a place the caret can be, so it comes to rest at the
14903        // character's start — the column it would have been drawn at anyway.
14904        for (view, tag) in VIEWS {
14905            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
14906            for col in [2, 3] {
14907                d.caret = 0;
14908                d.click(0, col, false);
14909                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
14910                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
14911            }
14912            // Past the last cell is the line's end, as it is for ASCII.
14913            d.click(0, 9, false);
14914            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
14915        }
14916    }
14917
14918    #[test]
14919    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
14920        // The mapping is only a mapping if it inverts: the cell the caret is
14921        // drawn in has to be the cell that brings it back to the same offset.
14922        // Over a fixture where a character may be one cell or two, and one
14923        // codepoint or five.
14924        use unicode_segmentation::UnicodeSegmentation;
14925
14926        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
14927
14928        let mut d = doc_in(View::Source, "roundtrip_source", src);
14929        // Every offset the source view's caret can occupy: it steps by grapheme
14930        // cluster, so those are its boundaries.
14931        for (off, _) in src
14932            .grapheme_indices(true)
14933            .chain(std::iter::once((src.len(), "")))
14934        {
14935            d.caret = off;
14936            let (row, col) = d.caret_pos();
14937            d.click(row, col, false);
14938            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
14939        }
14940
14941        // And in WYSIWYG, where the offsets the caret can occupy are the map's
14942        // stops rather than every boundary.
14943        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
14944        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14945        assert!(stops.len() > 20, "fixture should have plenty of stops");
14946        for off in stops {
14947            d.caret = off;
14948            let (row, col) = d.caret_pos();
14949            d.click(row, col, false);
14950            assert_eq!(
14951                d.caret, off,
14952                "wysiwyg: {off} → ({row}, {col}) → {}",
14953                d.caret
14954            );
14955        }
14956    }
14957
14958    #[test]
14959    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
14960        // Down from under `世` lands under the glyph in that cell, not two
14961        // characters further along the line. The goal is a column, so a line of
14962        // wide characters and a line of ASCII line up the way they're drawn.
14963        //
14964        // The gap differs by view: a bare newline inside a paragraph is a soft
14965        // break, which WYSIWYG draws as a space on a single row. The views share
14966        // a grid only where the source's lines are the renderer's rows too.
14967        for (view, tag) in VIEWS {
14968            let gap = if view == View::Source { "\n" } else { "\n\n" };
14969            let src = format!("你好世{gap}abcdef\n");
14970            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
14971            d.caret = "你好".len();
14972            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
14973            d.move_down(false);
14974            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
14975            assert!(
14976                d.source[d.caret..].starts_with('e'),
14977                "{tag}: landed on the wrong glyph"
14978            );
14979        }
14980    }
14981
14982    #[test]
14983    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
14984        // Down from column 3 onto `你好`, whose characters start at columns 0
14985        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
14986        // between the cells of one character, so the caret rests on it — and on
14987        // its start, which is the only offset there that is a caret stop.
14988        for (view, tag) in VIEWS {
14989            let gap = if view == View::Source { "\n" } else { "\n\n" };
14990            let src = format!("abcdef{gap}你好\n");
14991            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
14992            let line = src.find('你').unwrap();
14993            d.caret = 3;
14994            d.move_down(false);
14995            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
14996            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
14997        }
14998    }
14999
15000    #[test]
15001    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
15002        // The column the cell's text is laid out in is measured in cells, so the
15003        // caret walking that text has to be too — the two agreeing is the whole
15004        // point of the grid staying square.
15005        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
15006        let at = d.source.find("你").unwrap();
15007        d.caret = at;
15008        let (row, col) = d.caret_pos();
15009        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
15010        // cells further along.
15011        assert_eq!(col, 2, "the cell's first character");
15012        d.move_right(false);
15013        assert_eq!(
15014            d.caret_pos(),
15015            (row, 4),
15016            "`好` is drawn past `你`'s two cells"
15017        );
15018        assert_eq!(d.caret, at + "你".len());
15019    }
15020
15021    // ── active inline marks ───────────────────────────────────────────────────
15022
15023    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
15024    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
15025        let (src, caret) = parse_caret(marked);
15026        let mut d = doc_in(view, name, &src);
15027        d.caret = caret;
15028        d.active_inline_marks().iter().collect()
15029    }
15030
15031    /// The marks over the selection `[start, end)`.
15032    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
15033        let mut d = doc_in(view, name, src);
15034        d.anchor = Some(start);
15035        d.caret = end;
15036        d.active_inline_marks().iter().collect()
15037    }
15038
15039    #[test]
15040    fn a_caret_in_a_mark_reports_it() {
15041        for (view, tag) in VIEWS {
15042            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
15043            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
15044            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
15045            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
15046            // Plain text under no mark lights nothing — the toolbar's resting state.
15047            assert_eq!(m("a| **bold** b"), [], "{tag}");
15048            assert!(m("plain t|ext").is_empty(), "{tag}");
15049        }
15050    }
15051
15052    #[test]
15053    fn nested_marks_all_report() {
15054        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
15055        // the ancestor chain is a chain, and every mark on it is in force.
15056        for (view, tag) in VIEWS {
15057            assert_eq!(
15058                marks(
15059                    view,
15060                    &format!("marks_nested_{tag}"),
15061                    "**bold and *bo|th*** end"
15062                ),
15063                [InlineKind::Strong, InlineKind::Emph],
15064                "{tag}"
15065            );
15066        }
15067    }
15068
15069    #[test]
15070    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
15071        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
15072        // the first byte of its text and the byte after its last — both inside
15073        // the mark's span, both places typing lands inside the bold. The offset
15074        // past the closing delimiter is the next text, and reports nothing.
15075        let src = "a **bold** b";
15076        let inner_start = src.find("bold").unwrap(); // 4
15077        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
15078        for (view, tag) in VIEWS {
15079            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
15080            for off in [2, 3, inner_start, inner_end, 9] {
15081                d.caret = off;
15082                assert!(
15083                    d.active_inline_marks().contains(InlineKind::Strong),
15084                    "{tag}: offset {off} is inside the strong span"
15085                );
15086            }
15087            for off in [0, 1, 10, 11, 12] {
15088                d.caret = off;
15089                assert!(
15090                    !d.active_inline_marks().contains(InlineKind::Strong),
15091                    "{tag}: offset {off} is outside the strong run"
15092                );
15093            }
15094        }
15095    }
15096
15097    #[test]
15098    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
15099        // Regression: twig resolves an offset that is one node's end and the
15100        // next one's start to the node that *starts* there, so `**bold**|\n`
15101        // isn't bold. With nothing following there's no tie to break and the
15102        // chain still ended at the mark, which made a trailing `\n` — not the
15103        // text — decide whether the caret after a bold word reported bold. It's
15104        // the offset past the mark either way, and typing there is plain either
15105        // way. A blank document typed into is exactly this shape.
15106        for (view, tag) in VIEWS {
15107            let m = |name: String, marked| marks(view, &name, marked);
15108            assert_eq!(
15109                m(format!("marks_eob_{tag}"), "**bold**|"),
15110                [],
15111                "{tag}: no trailing newline"
15112            );
15113            assert_eq!(
15114                m(format!("marks_eol_{tag}"), "**bold**|\n"),
15115                [],
15116                "{tag}: with one"
15117            );
15118            // And the last offset that *is* in the mark still is.
15119            assert_eq!(
15120                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
15121                [InlineKind::Strong],
15122                "{tag}"
15123            );
15124        }
15125    }
15126
15127    #[test]
15128    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
15129        let src = "a **bold** b";
15130        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
15131        for (view, tag) in VIEWS {
15132            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
15133            // The whole bold word, and a slice of it.
15134            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
15135            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
15136            // Ending exactly at the closing delimiter's start is still all-bold:
15137            // an exclusive end sits *past* the last selected character, so the
15138            // question is asked of the character, not the boundary.
15139            assert_eq!(
15140                m(b, d_ + 2),
15141                [InlineKind::Strong],
15142                "{tag}: through the close"
15143            );
15144            // Half in, half out: Bold lit here would claim a press turns it off.
15145            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
15146            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
15147        }
15148    }
15149
15150    #[test]
15151    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
15152        // Both ends are bold, but the space between them isn't — two runs are two
15153        // nodes, which is exactly what the node id catches and a kind-only
15154        // comparison would not.
15155        let src = "**one** **two**";
15156        for (view, tag) in VIEWS {
15157            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
15158            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
15159        }
15160    }
15161
15162    #[test]
15163    fn marks_read_the_document_as_it_is_edited() {
15164        // The point of asking twig every frame instead of caching: the answer has
15165        // to follow the toggle that changed it.
15166        let mut d = wysiwyg_doc("marks_live", "one two\n");
15167        d.anchor = Some(0);
15168        d.caret = 3;
15169        assert!(d.active_inline_marks().is_empty(), "plain to start");
15170        d.toggle(InlineKind::Strong);
15171        assert_eq!(d.source, "**one** two\n");
15172        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
15173        assert!(d.active_inline_marks().contains(InlineKind::Strong));
15174        d.toggle(InlineKind::Strong);
15175        assert!(d.active_inline_marks().is_empty(), "and off again");
15176    }
15177
15178    #[test]
15179    fn a_link_is_not_an_inline_mark() {
15180        // `link`/`str` are inline nodes, but nothing on the inline toolbar
15181        // toggles them — a set with a "link mark" in it would have no button.
15182        for (view, tag) in VIEWS {
15183            assert_eq!(
15184                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
15185                [],
15186                "{tag}"
15187            );
15188        }
15189    }
15190
15191    // ── blank documents ───────────────────────────────────────────────────────
15192
15193    #[test]
15194    fn a_blank_document_is_untitled_empty_and_markdown() {
15195        let mut d = Doc::blank().unwrap();
15196        assert!(d.is_untitled());
15197        assert_eq!(d.path, PathBuf::new());
15198        assert_eq!(
15199            d.file_name(),
15200            "untitled",
15201            "the header has to show something"
15202        );
15203        assert_eq!(d.format_name(), "markdown");
15204        assert_eq!(d.source, "");
15205        assert!(!d.dirty, "nothing typed yet is nothing to lose");
15206        assert_eq!(d.disk_state(), DiskState::Untitled);
15207        // And it's a document you can be in: the default view renders it.
15208        d.build_visual(80);
15209        assert_eq!(d.caret, 0);
15210    }
15211
15212    #[test]
15213    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
15214        let mut d = Doc::blank().unwrap();
15215        d.insert("hello");
15216        assert!(d.dirty);
15217        d.save();
15218        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
15219        assert!(d.dirty, "it must not come away believing it saved");
15220        assert!(d.is_untitled(), "and it still has no file");
15221    }
15222
15223    #[test]
15224    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
15225        let p = temp_path("blank_save_as");
15226        let mut d = Doc::blank().unwrap();
15227        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
15228        // be kept literal (`\#`); this test is about save-as, not escaping (which
15229        // has its own test), so it types nothing that escaping would touch.
15230        d.insert("hi");
15231        d.save_as(p.clone());
15232        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
15233        assert!(!d.is_untitled());
15234        assert!(!d.dirty);
15235        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
15236        assert_eq!(
15237            d.disk_state(),
15238            DiskState::Unchanged,
15239            "the watermark is stamped"
15240        );
15241        // And ⌘S is a plain save from here on.
15242        d.insert("!");
15243        d.save();
15244        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
15245        let _ = std::fs::remove_file(&p);
15246    }
15247
15248    // ── a file that isn't there yet ───────────────────────────────────────────
15249
15250    /// A unique path in the temp dir with the given extension, guaranteed not to
15251    /// exist — what `leaf notes.md` is handed when the file has never been made.
15252    fn missing_path(name: &str, ext: &str) -> PathBuf {
15253        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15254        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15255        let mut p = std::env::temp_dir();
15256        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
15257        let _ = std::fs::remove_file(&p);
15258        p
15259    }
15260
15261    #[test]
15262    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
15263        let p = missing_path("named", "md");
15264        let mut d = Doc::open_or_create(p.clone()).unwrap();
15265
15266        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
15267        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
15268        assert!(
15269            !d.is_untitled(),
15270            "it has the name the user asked for — ^S must not detour to Save As"
15271        );
15272        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
15273        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
15274        assert!(!p.exists(), "and opening it wrote nothing");
15275        // And it's a document you can be in.
15276        d.build_visual(80);
15277        assert_eq!(d.caret, 0);
15278    }
15279
15280    #[test]
15281    fn a_new_file_is_created_by_its_first_save() {
15282        let p = missing_path("first_save", "md");
15283        let mut d = Doc::open_or_create(p.clone()).unwrap();
15284        d.insert("hello\n");
15285        assert!(d.dirty);
15286        d.save();
15287
15288        assert_eq!(
15289            std::fs::read_to_string(&p).unwrap(),
15290            "hello\n",
15291            "a plain ^S wrote it — no Save As, no name to invent"
15292        );
15293        assert!(!d.dirty);
15294        assert_eq!(d.disk_state(), DiskState::Unchanged);
15295        let _ = std::fs::remove_file(&p);
15296    }
15297
15298    #[test]
15299    fn a_new_file_takes_its_format_from_the_extension() {
15300        // The one thing `blank` can't do: with no name it has to assume Markdown,
15301        // and typing djot into a Markdown parse is the wrong buffer.
15302        let dj = missing_path("format", "dj");
15303        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
15304        let md = missing_path("format", "md");
15305        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
15306    }
15307
15308    #[test]
15309    fn a_new_file_reports_itself_missing_until_it_is_saved() {
15310        // Not `Untitled` — that's the answer for a document with no path, and it
15311        // would tell a frontend there is nothing a save could collide with. Here
15312        // there is a path, and the file simply isn't at it yet.
15313        let p = missing_path("disk_state", "md");
15314        let mut d = Doc::open_or_create(p.clone()).unwrap();
15315        assert_eq!(d.disk_state(), DiskState::Missing);
15316
15317        // Somebody else creates it while the buffer is open: that's an overwrite
15318        // the frontend has to be able to prompt about, exactly as for an opened
15319        // file. Their bytes, not ours, so `Changed`.
15320        std::fs::write(&p, "theirs\n").unwrap();
15321        assert_eq!(d.disk_state(), DiskState::Changed);
15322
15323        // Saving makes the file ours and re-stamps the watermark.
15324        d.insert("ours\n");
15325        d.save();
15326        assert_eq!(d.disk_state(), DiskState::Unchanged);
15327        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
15328        let _ = std::fs::remove_file(&p);
15329    }
15330
15331    #[test]
15332    fn open_or_create_still_opens_a_file_that_is_there() {
15333        let d = doc_with("open_or_create_existing", "body\n");
15334        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
15335        assert_eq!(reopened.source, "body\n");
15336        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
15337    }
15338
15339    #[test]
15340    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
15341        // A mistyped flag or a stray argument must not become a buffer promising
15342        // to save somewhere — the same refusal `open` gives a real file.
15343        let mut p = std::env::temp_dir();
15344        p.push("leaf_test_new_bad_ext.wat");
15345        assert!(Doc::open_or_create(p).is_err());
15346        let mut none = std::env::temp_dir();
15347        none.push("leaf_test_new_no_ext");
15348        assert!(Doc::open_or_create(none).is_err());
15349    }
15350
15351    #[test]
15352    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
15353        // Opening reads nothing, so there is nothing to fail on yet; the write is
15354        // where it fails, and it says so rather than claiming a save.
15355        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
15356        let mut d = Doc::open_or_create(p).unwrap();
15357        d.insert("x");
15358        d.save();
15359        assert!(
15360            d.status.as_deref().unwrap().starts_with("save failed:"),
15361            "got {:?}",
15362            d.status
15363        );
15364        assert!(d.dirty, "it must not come away believing it saved");
15365    }
15366
15367    // ── save as ───────────────────────────────────────────────────────────────
15368
15369    /// A unique path in the temp dir that no fixture wrote — a Save As target.
15370    fn temp_path(name: &str) -> PathBuf {
15371        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15372        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15373        let mut p = std::env::temp_dir();
15374        p.push(format!("leaf_test_target_{name}_{seq}.md"));
15375        let _ = std::fs::remove_file(&p);
15376        p
15377    }
15378
15379    #[test]
15380    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
15381        let mut d = doc_with("save_as_move", "original\n");
15382        let old = d.path.clone();
15383        let new = temp_path("save_as_move");
15384        d.insert("edited: ");
15385        d.save_as(new.clone());
15386
15387        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
15388        assert_eq!(
15389            std::fs::read_to_string(&old).unwrap(),
15390            "original\n",
15391            "Save As doesn't touch the file it came from"
15392        );
15393        assert_eq!(d.path, new, "the document moved");
15394        assert!(!d.dirty);
15395        assert_eq!(
15396            d.status.as_deref(),
15397            Some(&*format!("saved {}", d.file_name()))
15398        );
15399
15400        // Every later save follows it, which is the whole difference from a copy.
15401        d.caret = 0;
15402        d.insert("re-");
15403        d.save();
15404        assert_eq!(
15405            std::fs::read_to_string(&new).unwrap(),
15406            "re-edited: original\n"
15407        );
15408        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
15409        let _ = std::fs::remove_file(&new);
15410    }
15411
15412    #[test]
15413    fn save_as_overwrites_an_existing_target() {
15414        // The picker already asked; asking again down here is the same question
15415        // twice, and the second one has no way to be answered.
15416        let new = temp_path("save_as_over");
15417        std::fs::write(&new, "theirs\n").unwrap();
15418        let mut d = doc_with("save_as_over", "ours\n");
15419        d.save_as(new.clone());
15420        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
15421        let _ = std::fs::remove_file(&new);
15422    }
15423
15424    #[test]
15425    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
15426        let mut d = doc_with("save_as_fail", "body\n");
15427        let old = d.path.clone();
15428        d.insert("x");
15429        // A directory that doesn't exist: the write can't land.
15430        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
15431        d.save_as(bad);
15432
15433        assert_eq!(
15434            d.path, old,
15435            "the document must not move to a file that isn't there"
15436        );
15437        assert!(d.dirty, "and must not believe it saved");
15438        assert!(
15439            d.status.as_deref().unwrap().starts_with("save failed:"),
15440            "the same failure a plain save reports, got {:?}",
15441            d.status
15442        );
15443        // The original is still the document's file, and still saveable.
15444        d.save();
15445        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
15446        assert!(!d.dirty);
15447    }
15448
15449    #[test]
15450    fn save_as_renames_without_reparsing_the_format() {
15451        // `.dj` on the name doesn't make the buffer djot: it was parsed as
15452        // Markdown and still is, and saying otherwise would be a conversion the
15453        // user never asked for (and an undo history thrown away to do it).
15454        let mut d = doc_with("save_as_format", "**b**\n");
15455        let mut new = temp_path("save_as_format");
15456        new.set_extension("dj");
15457        d.save_as(new.clone());
15458        assert_eq!(d.format_name(), "markdown");
15459        let _ = std::fs::remove_file(&new);
15460    }
15461
15462    // ── external change / reload ──────────────────────────────────────────────
15463
15464    #[test]
15465    fn an_untouched_file_reports_unchanged() {
15466        let mut d = doc_with("disk_clean", "body\n");
15467        assert_eq!(d.disk_state(), DiskState::Unchanged);
15468        // Editing the buffer is not editing the file.
15469        d.insert("x");
15470        assert_eq!(d.disk_state(), DiskState::Unchanged);
15471        assert!(d.dirty);
15472        // Saving re-stamps the watermark rather than reporting our own bytes back.
15473        d.save();
15474        assert_eq!(d.disk_state(), DiskState::Unchanged);
15475    }
15476
15477    #[test]
15478    fn a_file_written_underneath_reports_changed() {
15479        let mut d = doc_with("disk_changed", "body\n");
15480        std::fs::write(&d.path, "someone else\n").unwrap();
15481        assert_eq!(d.disk_state(), DiskState::Changed);
15482        // Dirty *and* changed is the clobber: both halves are readable, and
15483        // leaf-core takes neither side.
15484        d.insert("x");
15485        assert!(d.dirty && d.disk_state() == DiskState::Changed);
15486        // Saving anyway is allowed — the frontend asked, or chose not to.
15487        d.save();
15488        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
15489        assert_eq!(d.disk_state(), DiskState::Unchanged);
15490    }
15491
15492    #[test]
15493    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
15494        // The hash is what makes this honest: the file was written (a fresh
15495        // mtime), and nothing about the document is stale.
15496        let d = doc_with("disk_same_bytes", "body\n");
15497        std::fs::write(&d.path, "body\n").unwrap();
15498        assert_eq!(d.disk_state(), DiskState::Unchanged);
15499    }
15500
15501    #[test]
15502    fn a_deleted_file_reports_missing() {
15503        let mut d = doc_with("disk_missing", "body\n");
15504        std::fs::remove_file(&d.path).unwrap();
15505        assert_eq!(d.disk_state(), DiskState::Missing);
15506        // A save recreates it, and the document is whole again.
15507        d.save();
15508        assert_eq!(d.disk_state(), DiskState::Unchanged);
15509        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
15510    }
15511
15512    #[test]
15513    fn reload_replaces_the_document_with_the_file() {
15514        for (view, tag) in VIEWS {
15515            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
15516            d.insert("edited ");
15517            assert!(d.dirty);
15518            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15519            d.reload();
15520
15521            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
15522            assert!(!d.dirty, "{tag}: the file is what we have");
15523            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
15524            assert_eq!(
15525                d.status.as_deref(),
15526                Some(&*format!("reloaded {}", d.file_name()))
15527            );
15528            // The reloaded tree is live, not the old parse.
15529            d.caret = d.source.find("three").unwrap();
15530            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
15531        }
15532    }
15533
15534    #[test]
15535    fn reload_clamps_the_caret_and_drops_the_selection() {
15536        let mut d = doc_with("reload_caret", "a long first line\n");
15537        d.caret = 12;
15538        d.anchor = Some(4);
15539        std::fs::write(&d.path, "short\n").unwrap();
15540        d.reload();
15541        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
15542        assert_eq!(
15543            d.anchor, None,
15544            "a selection over bytes that changed is a lie"
15545        );
15546        assert!(d.selection().is_none());
15547
15548        // A caret the file still has room for stays put.
15549        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
15550        d.caret = 2;
15551        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15552        d.reload();
15553        assert_eq!(d.caret, 2);
15554    }
15555
15556    /// A silent reload is something that happened *to* a reader — a formatter,
15557    /// a `git checkout` — so it has to be undoable like anything else that
15558    /// changes the document, and undoable as one step rather than as however
15559    /// many the file happens to differ by.
15560    #[test]
15561    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
15562        let mut d = doc_with("reload_undo", "body\n");
15563        d.insert("x");
15564        assert_eq!(d.source, "xbody\n");
15565        std::fs::write(&d.path, "replaced\n").unwrap();
15566        d.reload();
15567        assert_eq!(d.source, "replaced\n");
15568        assert!(!d.dirty, "a reload lands clean");
15569
15570        // One ^Z takes the whole swap off, and hands back the unsaved work it
15571        // replaced — which is unsaved again, because the file no longer says it.
15572        d.undo();
15573        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
15574        assert!(d.dirty, "and what it comes back to is unsaved");
15575        // …and the history under it is still there.
15576        d.undo();
15577        assert_eq!(
15578            d.source, "body\n",
15579            "the typing before the reload undoes too"
15580        );
15581        // Redo walks back up through the reload.
15582        d.redo();
15583        d.redo();
15584        assert_eq!(d.source, "replaced\n");
15585    }
15586
15587    /// A file rewritten with the bytes it already had is not an edit, so it
15588    /// must not leave an undo step behind for something nobody did.
15589    #[test]
15590    fn reloading_identical_bytes_pushes_no_undo_step() {
15591        let mut d = doc_with("reload_same", "body\n");
15592        d.insert("x");
15593        std::fs::write(&d.path, "xbody\n").unwrap();
15594        d.reload();
15595        assert_eq!(d.source, "xbody\n");
15596        assert!(!d.dirty, "the file now says what the buffer does");
15597        d.undo();
15598        assert_eq!(
15599            d.source, "body\n",
15600            "one step back is the typing, not a no-op"
15601        );
15602    }
15603
15604    #[test]
15605    fn a_reload_that_cant_read_leaves_the_document_alone() {
15606        let mut d = doc_with("reload_gone", "body\n");
15607        d.insert("x");
15608        std::fs::remove_file(&d.path).unwrap();
15609        d.reload();
15610        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
15611        assert!(d.dirty);
15612        assert!(
15613            d.status.as_deref().unwrap().starts_with("reload failed:"),
15614            "{:?}",
15615            d.status
15616        );
15617
15618        // And an untitled document has nothing to reload from.
15619        let mut d = Doc::blank().unwrap();
15620        d.insert("typed");
15621        d.reload();
15622        assert_eq!(d.source, "typed");
15623        assert_eq!(d.status.as_deref(), Some("no file to reload"));
15624    }
15625
15626    #[test]
15627    fn a_read_only_document_refuses_every_door() {
15628        let mut d = doc_with("readonly", "one two three\n");
15629        d.insert("x");
15630        assert!(d.dirty, "writable first, so the undo step exists");
15631        d.set_read_only(true);
15632        let before = d.source.clone();
15633        d.insert("y");
15634        d.backspace();
15635        d.undo();
15636        d.redo();
15637        assert_eq!(d.source, before, "no door moved a byte");
15638        d.set_read_only(false);
15639        d.undo();
15640        assert_ne!(d.source, before, "off again, the same doors work");
15641    }
15642
15643    /// The doors that go to twig's own verbs rather than through the splice.
15644    /// Typed text in the rendered view under the default markup mode is the
15645    /// everyday one — it is what a keystroke in leaf-web or the Apple views
15646    /// becomes — and it walked straight past the gate.
15647    #[test]
15648    fn a_read_only_document_refuses_the_doors_around_the_splice() {
15649        let mut d = wysiwyg_doc(
15650            "readonly-doors",
15651            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
15652        );
15653        d.set_markup_mode(MarkupMode::None);
15654        d.set_read_only(true);
15655        let before = d.source.clone();
15656        d.place_caret(3, false);
15657        d.insert("y");
15658        d.insert_link("https://example.com");
15659        d.insert_image("a.png", "alt");
15660        d.insert_thematic_break();
15661        d.insert_footnote();
15662        d.place_caret(0, false);
15663        d.place_caret(3, true);
15664        d.toggle(InlineKind::Strong);
15665        d.toggle_heading(2);
15666        d.set_block(BlockKind::Paragraph);
15667        d.toggle_list(false);
15668        d.toggle_blockquote();
15669        d.toggle_task_item();
15670        d.newline();
15671        d.indent();
15672        d.set_code_language("rust");
15673        let in_cell = d.source.find("| c").unwrap() + 2;
15674        d.place_caret(in_cell, false);
15675        assert!(d.caret_in_table(), "the caret is in the grid");
15676        assert!(!d.cell_line_break(), "the cell break reports the refusal");
15677        assert_eq!(d.source, before, "no door moved a byte");
15678        assert!(!d.dirty, "nothing to save");
15679        d.set_read_only(false);
15680        d.place_caret(3, false);
15681        d.insert("y");
15682        assert_ne!(d.source, before, "off again, the same doors work");
15683    }
15684
15685    #[test]
15686    fn a_selection_quote_carries_its_context_on_char_boundaries() {
15687        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
15688        let start = d.source.find("exact").unwrap();
15689        d.place_caret(start, false);
15690        d.place_caret(start + "exact".len(), true);
15691        let q = d.selection_quote(3).unwrap();
15692        assert_eq!(q.exact, "exact");
15693        assert_eq!(
15694            q.prefix, "你好 ",
15695            "chars, not bytes — the multibyte pair counts as two"
15696        );
15697        assert_eq!(q.suffix, " 世界");
15698        assert_eq!(&d.source[q.start..q.end], "exact");
15699        // At the edges the context clips rather than erring.
15700        d.place_caret(0, false);
15701        d.place_caret(6, true);
15702        let q = d.selection_quote(40).unwrap();
15703        assert_eq!(q.prefix, "");
15704        assert_eq!(q.exact, "before");
15705        // No selection is no quote.
15706        d.place_caret(0, false);
15707        assert!(d.selection_quote(3).is_none());
15708    }
15709
15710    #[test]
15711    fn highlights_are_kept_sorted_and_answer_point_queries() {
15712        let mut d = doc_with("hl", "one two three\n");
15713        d.set_highlights(vec![
15714            Highlight {
15715                start: 8,
15716                end: 13,
15717                id: "b".into(),
15718                color: None,
15719                marker: None,
15720            },
15721            Highlight {
15722                start: 0,
15723                end: 3,
15724                id: "a".into(),
15725                color: Some("#ffe066".into()),
15726                marker: None,
15727            },
15728            Highlight {
15729                start: 5,
15730                end: 5,
15731                id: "empty".into(),
15732                color: None,
15733                marker: None,
15734            },
15735        ]);
15736        assert_eq!(
15737            d.highlights()
15738                .iter()
15739                .map(|h| h.id.as_str())
15740                .collect::<Vec<_>>(),
15741            ["a", "b"],
15742            "sorted by start, the empty range dropped"
15743        );
15744        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
15745        assert_eq!(d.highlight_at(3), None, "end is exclusive");
15746        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
15747        d.set_highlights(Vec::new());
15748        assert!(d.highlights().is_empty(), "a replace is a replace");
15749    }
15750
15751    /// `Highlight::covering` and the cursor over it are what both painters ask
15752    /// per glyph, so they have to answer the same as the scan they replaced —
15753    /// including in the gaps, which is where most glyphs are.
15754    #[test]
15755    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
15756        let hl = |start: usize, end: usize, id: &str| Highlight {
15757            start,
15758            end,
15759            id: id.into(),
15760            color: None,
15761            marker: None,
15762        };
15763        // Disjoint, as search hits are: in a range, in a gap, and past the end.
15764        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
15765        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
15766        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
15767        assert_eq!(
15768            Highlight::covering(&hits, 105),
15769            None,
15770            "a gap covers nothing"
15771        );
15772        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
15773        assert_eq!(Highlight::covering(&hits, 9_999), None);
15774        assert_eq!(Highlight::covering(&[], 0), None);
15775
15776        // Nested: first by start, so a hit inside an annotation still resolves
15777        // to the annotation — and the range that stops short doesn't mask it.
15778        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
15779        assert_eq!(
15780            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
15781            Some("outer")
15782        );
15783        assert_eq!(
15784            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
15785            Some("outer")
15786        );
15787    }
15788
15789    /// The cursor is an optimisation, so the only thing worth asserting is that
15790    /// it is not also a change of answer — at every offset, over a list with a
15791    /// nest in it, walked forwards and then backwards.
15792    #[test]
15793    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
15794        let hl = |start: usize, end: usize, id: &str| Highlight {
15795            start,
15796            end,
15797            id: id.into(),
15798            color: None,
15799            marker: None,
15800        };
15801        let mut list = vec![
15802            hl(0, 20, "outer"),
15803            hl(5, 10, "inner"),
15804            hl(30, 33, "hit"),
15805            hl(40, 43, "hit"),
15806        ];
15807        list.sort_by_key(|h| (h.start, h.end));
15808
15809        let mut cursor = HighlightCursor::new(&list);
15810        for offset in 0..50 {
15811            assert_eq!(
15812                cursor.at(offset).map(|h| h.id.as_str()),
15813                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15814                "cursor disagrees at {offset}"
15815            );
15816        }
15817        // Backwards: the cursor re-seats rather than answering from where it
15818        // had got to, so a painter that revisits a row is still told the truth.
15819        for offset in (0..50).rev() {
15820            assert_eq!(
15821                cursor.at(offset).map(|h| h.id.as_str()),
15822                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15823                "cursor disagrees walking back at {offset}"
15824            );
15825        }
15826    }
15827
15828    // ── the presentation vocabulary ─────────────────────────────────────────
15829
15830    /// A document in `format`, for the gesture tests that want more than the
15831    /// Markdown `doc_with` writes.
15832    fn fmt_doc(body: &str, format: Format) -> Doc {
15833        Doc::from_source(body.to_string(), format).unwrap()
15834    }
15835
15836    /// Alignment is a block property, so the gesture is `set_block_attrs` on
15837    /// the caret's block whatever is selected — and each format spells it its
15838    /// own way: djot's `{…}` line above the block, a `<div>` around it in
15839    /// Markdown (the format has nowhere else to put it), the tag in HTML.
15840    #[test]
15841    fn set_alignment_spells_the_class_the_format_s_own_way() {
15842        let mut dj = fmt_doc("hello\n", Format::Djot);
15843        dj.caret = 1;
15844        dj.set_alignment(Some(Align::Center));
15845        assert_eq!(dj.source, "{.center}\nhello\n");
15846        assert!(dj.dirty);
15847        assert_eq!(dj.status, None);
15848
15849        let mut md = fmt_doc("hello\n", Format::Markdown);
15850        md.caret = 1;
15851        md.set_alignment(Some(Align::Right));
15852        assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
15853
15854        let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
15855        html.caret = html.source.find("hello").unwrap();
15856        html.set_alignment(Some(Align::Justify));
15857        assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
15858    }
15859
15860    /// Each gesture edits **one key and keeps the rest** — twig's contract is
15861    /// replace-not-merge, so leaf reads the node's attributes, edits its own
15862    /// key out of them, and passes the list back whole. A document from
15863    /// elsewhere passes through the editor unharmed.
15864    #[test]
15865    fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
15866        let mut d = fmt_doc(
15867            "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
15868            Format::Djot,
15869        );
15870        d.caret = d.source.find("hello").unwrap();
15871        d.set_alignment(Some(Align::Right));
15872        // `center` goes, `lead` stays, and neither the id nor the spacing is
15873        // touched.
15874        // The serializer picks the order; what matters is which keys survive.
15875        assert!(d.source.contains(".lead"), "{:?}", d.source);
15876        assert!(d.source.contains(".right"), "{:?}", d.source);
15877        assert!(!d.source.contains(".center"), "{:?}", d.source);
15878        assert!(d.source.contains("#intro"), "{:?}", d.source);
15879        assert!(
15880            d.source.contains("data-line-height=\"1.5\""),
15881            "{:?}",
15882            d.source
15883        );
15884        assert_eq!(d.alignment_at_caret(), Some(Align::Right));
15885        assert_eq!(
15886            d.line_spacing_at_caret(),
15887            Some(LineHeight::Step(LineSpacing::OneHalf))
15888        );
15889
15890        // And the other way round: the spacing gesture leaves the classes be.
15891        d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
15892        assert!(d.source.contains(".lead"), "{:?}", d.source);
15893        assert!(d.source.contains(".right"), "{:?}", d.source);
15894        assert_eq!(
15895            d.line_spacing_at_caret(),
15896            Some(LineHeight::Step(LineSpacing::Double))
15897        );
15898    }
15899
15900    /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
15901    /// owns go out of `class`, and a block left with nothing at all is spelled
15902    /// bare again — in Markdown by unwrapping the div twig wrapped it in.
15903    #[test]
15904    fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
15905        let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
15906        dj.caret = dj.source.find("hello").unwrap();
15907        dj.set_alignment(None);
15908        assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
15909        assert_eq!(dj.alignment_at_caret(), None);
15910
15911        let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
15912        bare.caret = bare.source.find("hello").unwrap();
15913        bare.set_alignment(None);
15914        assert_eq!(
15915            bare.source, "hello\n",
15916            "the last key takes the line with it"
15917        );
15918
15919        let mut md = fmt_doc("hello\n", Format::Markdown);
15920        md.caret = 1;
15921        md.set_alignment(Some(Align::Center));
15922        assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
15923        md.caret = md.source.find("hello").unwrap();
15924        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
15925        assert_eq!(
15926            md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
15927            "the second key rewrites the div rather than nesting a second"
15928        );
15929        md.caret = md.source.find("hello").unwrap();
15930        md.set_alignment(None);
15931        md.caret = md.source.find("hello").unwrap();
15932        md.set_line_spacing(None);
15933        assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
15934    }
15935
15936    /// Size, face and colour are the run's over a selection and the block's
15937    /// with none — so "make this paragraph larger" is a click with the caret in
15938    /// it rather than a select-all first.
15939    #[test]
15940    fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
15941        // With a selection: a span, in each format's own spelling.
15942        let mut dj = fmt_doc("a big b\n", Format::Djot);
15943        dj.anchor = Some(2);
15944        dj.caret = 5;
15945        dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
15946        assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
15947        assert_eq!(
15948            dj.font_size_at_caret(),
15949            Some(FontSize::Step(SizeStep::Large))
15950        );
15951
15952        let mut md = fmt_doc("a big b\n", Format::Markdown);
15953        md.anchor = Some(2);
15954        md.caret = 5;
15955        md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
15956        assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
15957        assert_eq!(
15958            md.text_color_at_caret(),
15959            Some(TextColor::Named(MarkColor::Blue))
15960        );
15961
15962        // Without one: the caret's block, through the block gesture.
15963        let mut block = fmt_doc("a big b\n", Format::Djot);
15964        block.caret = 3;
15965        block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
15966        assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
15967        assert_eq!(
15968            block.font_family_at_caret(),
15969            Some(FontFace::Generic(FontFamily::Monospace))
15970        );
15971    }
15972
15973    /// The *Other…* row of each of the four menus: a value goes into the
15974    /// document in its canonical spelling and comes back out of the query as
15975    /// the same value. One round trip per property, because the four go out
15976    /// through different doors — two block gestures, and the run three through
15977    /// the span that `wrap_range_attrs` mints.
15978    #[test]
15979    fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
15980        // Size: the run three, over a selection.
15981        let mut d = fmt_doc("a big b\n", Format::Djot);
15982        d.anchor = Some(2);
15983        d.caret = 5;
15984        d.set_font_size(FontSize::points(14.0));
15985        assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
15986        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
15987
15988        // Colour, onto the same span — the gesture keeps the size it finds.
15989        d.set_text_color(Some(TextColor::Rgb {
15990            r: 0xc0,
15991            g: 0x30,
15992            b: 0x30,
15993        }));
15994        assert_eq!(
15995            d.source,
15996            "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
15997        );
15998        assert_eq!(
15999            d.text_color_at_caret(),
16000            Some(TextColor::Rgb {
16001                r: 0xc0,
16002                g: 0x30,
16003                b: 0x30
16004            })
16005        );
16006
16007        // Face: a family name, as given.
16008        d.set_font_family(Some(FontFace::Named("Garamond".into())));
16009        assert!(
16010            d.source.contains("data-font=\"Garamond\""),
16011            "{:?}",
16012            d.source
16013        );
16014        assert_eq!(
16015            d.font_family_at_caret(),
16016            Some(FontFace::Named("Garamond".into()))
16017        );
16018
16019        // Line spacing: a block gesture, and an exact ratio.
16020        let mut block = fmt_doc("hello\n", Format::Djot);
16021        block.caret = 1;
16022        block.set_line_spacing(LineHeight::ratio(1.3));
16023        assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
16024        assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
16025
16026        // And a value spelled long is written back short, so the same press
16027        // twice writes the same bytes: `14.0pt` in, `14pt` out.
16028        let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
16029        long.caret = long.source.find("hello").unwrap();
16030        assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
16031        let in_force = long.font_size_at_caret();
16032        long.set_font_size(in_force);
16033        assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
16034    }
16035
16036    /// A value the grammar does not cover is what it was before the vocabulary
16037    /// opened: carried untouched by the document, answered `None` by the query
16038    /// so the menu ticks *Default*, and rewritten only by a gesture on its own
16039    /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
16040    #[test]
16041    fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
16042        let src =
16043            "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
16044        let mut d = fmt_doc(src, Format::Djot);
16045        d.caret = d.source.find("hello").unwrap();
16046        assert_eq!(d.font_size_at_caret(), None);
16047        assert_eq!(d.text_color_at_caret(), None);
16048        assert_eq!(d.line_spacing_at_caret(), None);
16049
16050        // The keys are still there, untouched, after a gesture on a *different*
16051        // key — "edit one key and keep the rest" holds for a value it cannot
16052        // read as readily as for one it can.
16053        d.set_alignment(Some(Align::Center));
16054        assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
16055        assert!(
16056            d.source.contains("data-color=\"rgb(1,2,3)\""),
16057            "{:?}",
16058            d.source
16059        );
16060        assert!(
16061            d.source.contains("data-line-height=\"1.3em\""),
16062            "{:?}",
16063            d.source
16064        );
16065        // And the gesture on its *own* key replaces it, which is the one way a
16066        // carried value ever changes.
16067        d.caret = d.source.find("hello").unwrap();
16068        d.set_font_size(FontSize::points(12.0));
16069        assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
16070        assert!(!d.source.contains("huge"), "{:?}", d.source);
16071    }
16072
16073    /// The nearest node wins whichever *form* either node wrote: a value inside
16074    /// a name, a name inside a value. The fold has one rule and does not learn
16075    /// a second one for exact values.
16076    #[test]
16077    fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
16078        // A value inside a name: the block says `small`, the span says `14pt`.
16079        let mut d = fmt_doc(
16080            "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
16081            Format::Djot,
16082        );
16083        d.caret = d.source.find('y').unwrap();
16084        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16085        d.caret = d.source.find('x').unwrap();
16086        assert_eq!(
16087            d.font_size_at_caret(),
16088            Some(FontSize::Step(SizeStep::Small))
16089        );
16090
16091        // And a name inside a value, which is the same rule read the other way.
16092        let mut e = fmt_doc(
16093            "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
16094            Format::Djot,
16095        );
16096        e.caret = e.source.find('y').unwrap();
16097        assert_eq!(
16098            e.font_size_at_caret(),
16099            Some(FontSize::Step(SizeStep::Small))
16100        );
16101        assert_eq!(
16102            e.text_color_at_caret(),
16103            Some(TextColor::Rgb {
16104                r: 0xc0,
16105                g: 0x30,
16106                b: 0x30
16107            }),
16108            "the block's colour still reaches the span"
16109        );
16110        e.caret = e.source.find('x').unwrap();
16111        assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
16112    }
16113
16114    /// twig re-styles the span a range already lies in rather than nesting a
16115    /// second, and an empty set unwraps it — so a second press of the menu
16116    /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
16117    /// means "the theme's own" takes the span away.
16118    #[test]
16119    fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
16120        let mut d = fmt_doc("a big b\n", Format::Djot);
16121        d.anchor = Some(2);
16122        d.caret = 5;
16123        d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16124        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16125
16126        // The selection `wrap_range_attrs` left behind covers the whole span;
16127        // colouring it now keeps the size, because the gesture reads the span's
16128        // attributes before it edits its own key.
16129        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16130        assert_eq!(
16131            d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
16132            "one span, both keys"
16133        );
16134        assert_eq!(
16135            d.font_size_at_caret(),
16136            Some(FontSize::Step(SizeStep::Large))
16137        );
16138        assert_eq!(
16139            d.text_color_at_caret(),
16140            Some(TextColor::Named(MarkColor::Red))
16141        );
16142
16143        d.set_text_color(None);
16144        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16145        d.set_font_size(None);
16146        assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
16147        assert_eq!(d.font_size_at_caret(), None);
16148    }
16149
16150    /// The queries read the nearest node that names the property: the span the
16151    /// caret is in, then its block, then the `div`s around it.
16152    #[test]
16153    fn a_presentation_query_reads_the_nearest_node_that_names_it() {
16154        let mut d = fmt_doc(
16155            "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
16156            Format::Djot,
16157        );
16158        // In the span: its own size, the block's face and alignment.
16159        d.caret = d.source.find('y').unwrap();
16160        assert_eq!(
16161            d.font_size_at_caret(),
16162            Some(FontSize::Step(SizeStep::XxLarge))
16163        );
16164        assert_eq!(
16165            d.font_family_at_caret(),
16166            Some(FontFace::Generic(FontFamily::Serif))
16167        );
16168        assert_eq!(d.alignment_at_caret(), Some(Align::Center));
16169        assert_eq!(d.line_spacing_at_caret(), None);
16170        assert_eq!(d.text_color_at_caret(), None);
16171
16172        // Outside it: the block's size.
16173        d.caret = d.source.find('x').unwrap();
16174        assert_eq!(
16175            d.font_size_at_caret(),
16176            Some(FontSize::Step(SizeStep::Small))
16177        );
16178
16179        // And through a Markdown div, which is where a Markdown block's
16180        // attributes live.
16181        let mut md = fmt_doc(
16182            "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
16183            Format::Markdown,
16184        );
16185        md.caret = md.source.find("hello").unwrap();
16186        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16187        assert_eq!(
16188            md.font_size_at_caret(),
16189            Some(FontSize::Step(SizeStep::Large))
16190        );
16191
16192        // A document that names none of it answers `None` everywhere, which is
16193        // "the theme's own" and what every toolbar draws unlit.
16194        let mut plain = doc_with("plain_presentation", "hello\n");
16195        plain.caret = 1;
16196        assert_eq!(plain.alignment_at_caret(), None);
16197        assert_eq!(plain.line_spacing_at_caret(), None);
16198        assert_eq!(plain.font_size_at_caret(), None);
16199        assert_eq!(plain.font_family_at_caret(), None);
16200        assert_eq!(plain.text_color_at_caret(), None);
16201    }
16202
16203    /// A djot fenced div is anonymous the way an attributed span is, and is a
16204    /// block all the same — the *form* is the whole of what tells them apart.
16205    /// Read as a span it poisoned both halves: the run gesture copied the div's
16206    /// entire attribute set onto the span it minted, duplicating the `id`, and
16207    /// the run and block queries answered off a node the walker draws nothing
16208    /// for.
16209    #[test]
16210    fn a_djot_fenced_div_is_not_an_attributed_span() {
16211        let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
16212        let mut d = fmt_doc(src, Format::Djot);
16213        let at = d.source.find("world").unwrap();
16214        d.anchor = Some(at);
16215        d.caret = at + "world".len();
16216        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16217        assert_eq!(
16218            d.source,
16219            "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
16220            "the span carries its own key and nothing of the div's"
16221        );
16222
16223        // And the queries stop at the block: a djot div is not a `<div>`, the
16224        // walker lends its keys to nothing inside it, and a query that said
16225        // otherwise would tick a menu entry no glyph on screen obeys.
16226        assert_eq!(
16227            d.text_color_at_caret(),
16228            Some(TextColor::Named(MarkColor::Red))
16229        );
16230        assert_eq!(d.font_size_at_caret(), None);
16231        assert_eq!(d.alignment_at_caret(), None);
16232    }
16233
16234    /// Clearing a property the block does not name and a `div` around it does
16235    /// would write nothing and change nothing — twig's `set_block_attrs`
16236    /// reaches one node, and the div is not it. The gesture says so instead of
16237    /// leaving the author pressing an entry that never ticks.
16238    #[test]
16239    fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
16240        // Markdown, two paragraphs in one div: not the sole-child shape twig
16241        // writes, so `block_attrs_at_caret` reads the paragraph and the
16242        // paragraph names none of it.
16243        let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
16244        let mut md = fmt_doc(src, Format::Markdown);
16245        md.caret = md.source.find("hello").unwrap();
16246        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16247
16248        md.set_alignment(None);
16249        assert_eq!(md.source, src, "nothing written");
16250        assert!(!md.dirty);
16251        assert_eq!(
16252            md.status.as_deref(),
16253            Some("alignment: set on the div around the block")
16254        );
16255        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16256
16257        // The same for a `data-` key, at both levels — the block pair and the
16258        // run three, the run three at a bare caret being the block gesture.
16259        md.set_line_spacing(None);
16260        assert_eq!(md.source, src);
16261        assert_eq!(
16262            md.status.as_deref(),
16263            Some("line spacing: set on the div around the block")
16264        );
16265        md.set_font_size(None);
16266        assert_eq!(md.source, src);
16267        assert_eq!(
16268            md.status.as_deref(),
16269            Some("size: set on the div around the block")
16270        );
16271
16272        // HTML has no sole-child fold at all: a block's attributes go on the
16273        // block, so the div around one is always out of reach.
16274        let html_src = "<div class=\"center\"><p>hi</p></div>\n";
16275        let mut html = fmt_doc(html_src, Format::Html);
16276        html.caret = html.source.find("hi").unwrap();
16277        assert_eq!(html.alignment_at_caret(), Some(Align::Center));
16278        html.set_alignment(None);
16279        assert_eq!(html.source, html_src);
16280        assert!(!html.dirty);
16281        assert_eq!(
16282            html.status.as_deref(),
16283            Some("alignment: set on the div around the block")
16284        );
16285
16286        // And it is a refusal, not a rule against clearing: a block that names
16287        // the property itself still loses it, div or no div.
16288        let mut own = fmt_doc(
16289            "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
16290            Format::Html,
16291        );
16292        own.caret = own.source.find("hi").unwrap();
16293        own.set_alignment(None);
16294        assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
16295        assert_eq!(own.status, None);
16296    }
16297
16298    /// An edited key is rewritten **where it stands**. The proposal's worked
16299    /// example is the test: a paragraph that came in as `id="intro"
16300    /// class="lead center" data-line-height="1.5"` and is right-aligned goes
16301    /// out as the same list with one token changed. Removing the key and
16302    /// pushing it back shuffled a document's attributes on every press.
16303    #[test]
16304    fn an_edited_key_keeps_its_place_among_the_attributes() {
16305        let mut html = fmt_doc(
16306            "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
16307            Format::Html,
16308        );
16309        html.caret = html.source.find("hello").unwrap();
16310        html.set_alignment(Some(Align::Right));
16311        assert_eq!(
16312            html.source,
16313            "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
16314        );
16315
16316        // A `data-` key the same way, and a key the block did not have still
16317        // goes on the end.
16318        html.caret = html.source.find("hello").unwrap();
16319        html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16320        assert_eq!(
16321            html.source,
16322            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
16323        );
16324        html.caret = html.source.find("hello").unwrap();
16325        html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16326        assert_eq!(
16327            html.source,
16328            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
16329        );
16330
16331        // Djot writes the same list in its own spelling, and the order is the
16332        // author's there too.
16333        let mut dj = fmt_doc(
16334            "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
16335            Format::Djot,
16336        );
16337        dj.caret = dj.source.find("hello").unwrap();
16338        dj.set_alignment(Some(Align::Right));
16339        assert_eq!(
16340            dj.source,
16341            "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
16342        );
16343    }
16344
16345    /// A page break is a block, so twig alone lands one after the caret's whole
16346    /// block; the paragraph is parted at the caret first, exactly as
16347    /// `insert_thematic_break` parts it, and each format spells the directive
16348    /// its own way.
16349    #[test]
16350    fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
16351        let mut md = doc_with("page_break_md", "hello world\n");
16352        md.caret = 5;
16353        md.insert_page_break();
16354        assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
16355        assert!(md.dirty);
16356        assert_eq!(md.status, None);
16357
16358        let mut dj = fmt_doc("hello world\n", Format::Djot);
16359        dj.caret = 5;
16360        dj.insert_page_break();
16361        assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
16362
16363        // At a block's end there is no second half to mint, so the break simply
16364        // follows the block — the rule the rule button already has.
16365        let mut end = doc_with("page_break_end", "hello\n");
16366        end.caret = 5;
16367        end.insert_page_break();
16368        assert_eq!(end.source, "hello\n\n::page-break\n");
16369
16370        // And it reaches the map as the placeholder row a frontend paginates on.
16371        end.view = View::Wysiwyg;
16372        end.build_visual(80);
16373        assert_eq!(
16374            end.vmap
16375                .rows
16376                .iter()
16377                .find_map(|r| r.leaf_directive.as_ref())
16378                .map(|m| m.name.as_str()),
16379            Some(PAGE_BREAK)
16380        );
16381    }
16382
16383    /// The vocabulary's capabilities, per format. The two block properties are
16384    /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
16385    /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
16386    /// keeps an id and a role and has no slot for a `data-` key.
16387    #[test]
16388    fn the_presentation_capabilities_are_ragged_per_format() {
16389        for fmt in [Format::Markdown, Format::Djot, Format::Html] {
16390            let c = Capabilities::of(fmt);
16391            assert!(c.alignment, "{fmt:?} alignment");
16392            assert!(c.line_spacing, "{fmt:?} line spacing");
16393            assert!(c.font_size, "{fmt:?} size");
16394            assert!(c.font_family, "{fmt:?} face");
16395            assert!(c.text_color, "{fmt:?} colour");
16396        }
16397        // Markdown spells both only under the extensions leaf parses with — a
16398        // `<div>` and a `<span>` read back as containers under `html_elements`,
16399        // and `::page-break` as a directive under `directives`. Ask twig's own
16400        // defaults and the answer is no, which is why `Capabilities` is built
16401        // with `supports_with`.
16402        assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
16403        assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
16404        assert!(!Format::Markdown.supports(Gesture::InsertDirective));
16405
16406        let adoc = Capabilities::of(Format::Asciidoc);
16407        assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
16408        assert!(
16409            !adoc.font_size && !adoc.font_family && !adoc.text_color,
16410            "AsciiDoc has no inline spelling that keeps a data- key"
16411        );
16412
16413        // XML spells none of it, and neither page break.
16414        let xml = Capabilities::of(Format::Xml);
16415        assert!(!xml.alignment && !xml.font_size && !xml.page_break);
16416        assert!(Capabilities::of(Format::Markdown).page_break);
16417        assert!(Capabilities::of(Format::Djot).page_break);
16418
16419        // And those two *only*, though twig spells the gesture in HTML and
16420        // AsciiDoc as well: it spells it differently there —
16421        // `<page-break></page-break>` and `<<<` — and the walker reads neither,
16422        // so the button would write a break that draws as nothing at all in
16423        // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
16424        // what leaf can show, not what twig can write. See
16425        // `docs/tasks/page-break-in-html-and-asciidoc.md`.
16426        let exts = parse_extensions();
16427        assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
16428        assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
16429        assert!(!Capabilities::of(Format::Html).page_break);
16430        assert!(!Capabilities::of(Format::Asciidoc).page_break);
16431    }
16432
16433    /// A format that cannot spell a property refuses in its own words and
16434    /// writes nothing — the guard every other gesture has.
16435    #[test]
16436    fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
16437        let src = "<doc><p>hello</p></doc>\n";
16438        #[allow(clippy::type_complexity)]
16439        let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
16440            ("alignment", &|d: &mut Doc| {
16441                d.set_alignment(Some(Align::Center))
16442            }),
16443            ("line spacing", &|d: &mut Doc| {
16444                d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
16445            }),
16446            ("size", &|d: &mut Doc| {
16447                d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
16448            }),
16449            ("face", &|d: &mut Doc| {
16450                d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
16451            }),
16452            ("colour", &|d: &mut Doc| {
16453                d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
16454            }),
16455            ("page break", &|d: &mut Doc| d.insert_page_break()),
16456        ];
16457        for (name, op) in ops {
16458            let mut d = fmt_doc(src, Format::Xml);
16459            let at = d.source.find("hello").unwrap();
16460            d.caret = at;
16461            d.anchor = Some(at + 5);
16462            op(&mut d);
16463            assert_eq!(d.source, src, "{name} edited an XML document");
16464            assert!(!d.dirty, "{name} marked the document dirty");
16465            let status = d.status.as_deref().unwrap_or("");
16466            assert!(
16467                status.contains("xml"),
16468                "{name}: the refusal should name the format, got {status:?}"
16469            );
16470        }
16471
16472        // AsciiDoc is the ragged one: the block gesture works where the run
16473        // gesture does not, and a *selection* is what tells the two apart.
16474        let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
16475        adoc.anchor = Some(0);
16476        adoc.caret = 5;
16477        adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16478        assert_eq!(adoc.source, "hello world\n", "no inline spelling");
16479        assert!(adoc.status.is_some());
16480    }
16481
16482    /// A read-only document takes none of it, and a caret on a blank line has
16483    /// no block to carry an attribute — both say so rather than writing.
16484    #[test]
16485    fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
16486        let mut ro = fmt_doc("hello\n", Format::Djot);
16487        ro.read_only = true;
16488        ro.caret = 1;
16489        ro.set_alignment(Some(Align::Center));
16490        assert_eq!(ro.source, "hello\n");
16491
16492        let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
16493        blank.caret = 2; // the empty line between the two paragraphs
16494        blank.set_alignment(Some(Align::Center));
16495        assert_eq!(blank.source, "a\n\n\nb\n");
16496        assert!(
16497            blank.status.as_deref().unwrap_or("").contains("no block"),
16498            "got {:?}",
16499            blank.status
16500        );
16501    }
16502
16503    /// A block attribute gesture keeps the caret on the **text** it was on, not
16504    /// on the byte offset it had. Markdown has nowhere to put a paragraph's
16505    /// attributes but a `<div>` around it, and twig splices the div and the
16506    /// block it wraps as one region — so a caret that kept its offset landed in
16507    /// the markup, and every press after the first answered "no block at the
16508    /// caret" with the toolbar's queries reading nothing.
16509    #[test]
16510    fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
16511        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16512        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16513        md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
16514
16515        // Wrapping: the div and two blank lines open above the block.
16516        md.set_alignment(Some(Align::Center));
16517        assert_eq!(
16518            md.source,
16519            "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
16520        );
16521        assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
16522        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16523
16524        // Re-styling: the attribute line changes length under the same caret,
16525        // and the second press reaches the same block rather than nothing.
16526        md.set_alignment(Some(Align::Right));
16527        assert_eq!(
16528            md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
16529            "a second press re-styles the div"
16530        );
16531        assert_eq!(md.status, None);
16532        assert_eq!(word(&md), 2);
16533
16534        // A second key on the same div — the line grows, the caret rides it.
16535        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16536        assert_eq!(
16537            md.source,
16538            "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16539        );
16540        assert_eq!(word(&md), 2);
16541        assert_eq!(
16542            md.line_spacing_at_caret(),
16543            Some(LineHeight::Step(LineSpacing::Double))
16544        );
16545
16546        // Unwrapping: the line shrinks, and then the div goes altogether.
16547        md.set_alignment(None);
16548        assert_eq!(
16549            md.source,
16550            "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16551        );
16552        assert_eq!(word(&md), 2);
16553        md.set_line_spacing(None);
16554        assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
16555        assert_eq!(word(&md), 2, "the caret came back down with the block");
16556        assert_eq!(md.alignment_at_caret(), None);
16557        assert_eq!(md.status, None);
16558    }
16559
16560    /// The same rule in djot, where the spelling is a `{…}` line *above* the
16561    /// block rather than a wrapper around it: inserting it pushes the block
16562    /// down, re-styling it changes the line's length, and clearing the last key
16563    /// takes the line away again. The caret rides all three.
16564    #[test]
16565    fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
16566        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16567        let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
16568        dj.caret = dj.source.find("brown").unwrap() + 2;
16569
16570        dj.set_alignment(Some(Align::Center));
16571        assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
16572        assert_eq!(word(&dj), 2);
16573        assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
16574
16575        dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16576        assert_eq!(
16577            dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
16578            "a second press edits the line the first wrote"
16579        );
16580        assert_eq!(word(&dj), 2);
16581
16582        dj.set_alignment(None);
16583        assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
16584        assert_eq!(word(&dj), 2);
16585
16586        dj.set_line_spacing(None);
16587        assert_eq!(dj.source, "the quick brown fox\n");
16588        assert_eq!(word(&dj), 2);
16589        assert_eq!(dj.status, None);
16590    }
16591
16592    /// The run gestures with no selection are the block gesture, so they keep
16593    /// the caret the same way — and a heading keeps it inside the heading's own
16594    /// text, past the `# ` its content span starts after. A selection rides
16595    /// along whole: a block gesture is not a run gesture, and what was selected
16596    /// before the press is still selected after it.
16597    #[test]
16598    fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
16599        // No selection: the run gesture goes through the block door.
16600        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16601        md.caret = md.source.find("brown").unwrap() + 2;
16602        md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16603        assert_eq!(
16604            md.source,
16605            "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
16606        );
16607        assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
16608        assert_eq!(
16609            md.font_size_at_caret(),
16610            Some(FontSize::Step(SizeStep::Large))
16611        );
16612        md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
16613        assert_eq!(
16614            md.font_size_at_caret(),
16615            Some(FontSize::Step(SizeStep::Small)),
16616            "the second press reached the same block"
16617        );
16618
16619        // A selection: alignment is the block's whatever is selected, and the
16620        // words stay selected.
16621        let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
16622        let at = sel.source.find("brown").unwrap();
16623        sel.anchor = Some(at);
16624        sel.caret = at + 5;
16625        sel.set_alignment(Some(Align::Center));
16626        let now = sel.source.find("brown").unwrap();
16627        assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
16628
16629        // A heading: the content span starts past the `# `.
16630        let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
16631        h.caret = h.source.find("there").unwrap() + 1;
16632        h.set_alignment(Some(Align::Right));
16633        assert_eq!(
16634            h.source,
16635            "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
16636        );
16637        assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
16638        assert_eq!(h.alignment_at_caret(), Some(Align::Right));
16639    }
16640
16641    /// A djot document open in the rich view, with its map built as
16642    /// [`wysiwyg_doc`] builds a Markdown one's.
16643    fn wysiwyg_djot(body: &str) -> Doc {
16644        let mut d = fmt_doc(body, Format::Djot);
16645        d.view = View::Wysiwyg;
16646        d.build_visual(80);
16647        d
16648    }
16649
16650    /// Backspace at the start of a block whose presentation is spelled as
16651    /// hidden markup before it strips that presentation, the way Backspace at
16652    /// a heading's start strips its `#`. The ordinary delete fused djot's
16653    /// `{.center}` line onto the text and took the blank line a Markdown div
16654    /// needs between its tag and its paragraph.
16655    #[test]
16656    fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
16657        let mut md = wysiwyg_doc(
16658            "wys_attr_bksp",
16659            "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16660        );
16661        md.caret = md.source.find("hello").unwrap();
16662        md.backspace();
16663        assert_eq!(
16664            md.source, "above\n\nhello\n\nbelow\n",
16665            "the div is unwrapped"
16666        );
16667        assert_eq!(md.caret, 7, "the caret stays at the start of its text");
16668        md.backspace();
16669        assert_eq!(
16670            md.source, "above\nhello\n\nbelow\n",
16671            "the next press joins the paragraphs, as it always did"
16672        );
16673
16674        let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
16675        dj.caret = dj.source.find("hello").unwrap();
16676        dj.backspace();
16677        assert_eq!(
16678            dj.source, "above\n\nhello\n\nbelow\n",
16679            "the attribute line goes"
16680        );
16681        assert_eq!(dj.caret, 7);
16682
16683        // A heading's own marker is the nearer hidden markup, and goes first;
16684        // the attributes are the next press's.
16685        let mut dj = wysiwyg_djot("{.center}\n# Title\n");
16686        dj.caret = dj.source.find("Title").unwrap();
16687        dj.backspace();
16688        assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
16689        dj.build_visual(80);
16690        dj.backspace();
16691        assert_eq!(dj.source, "Title\n", "then the attributes");
16692        assert_eq!(dj.caret, 0);
16693    }
16694
16695    /// A div around several blocks has no sole child for twig to unwrap, so
16696    /// at its first block the caret steps back to the stop before rather than
16697    /// taking the div apart; a later block has an ordinary paragraph above it
16698    /// and joins as any paragraph does.
16699    #[test]
16700    fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
16701        let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
16702        let mut d = wysiwyg_doc("wys_div_first", src);
16703        d.caret = d.source.find("hello").unwrap();
16704        d.backspace();
16705        assert_eq!(d.source, src, "nothing is deleted");
16706        assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
16707
16708        let mut d = wysiwyg_doc("wys_div_later", src);
16709        d.caret = d.source.find("world").unwrap();
16710        d.backspace();
16711        assert_eq!(
16712            d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
16713            "a later block joins the one above it"
16714        );
16715    }
16716
16717    /// Backspace at the start of the paragraph after a Markdown div joins it
16718    /// into the div's last paragraph — the join any two paragraphs make, with
16719    /// the hidden `</div>` carried past the joined text. The ordinary delete
16720    /// took the newline under the tag, which drew nothing different, and the
16721    /// next press took the `>` and left the div unclosed.
16722    #[test]
16723    fn backspace_after_a_div_joins_the_paragraph_into_it() {
16724        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
16725        let mut d = wysiwyg_doc("wys_div_join", src);
16726        d.caret = d.source.find("below").unwrap();
16727        d.backspace();
16728        assert_eq!(
16729            d.source,
16730            "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
16731        );
16732        assert_eq!(
16733            d.caret,
16734            d.source.find("below").unwrap(),
16735            "the caret stays at the start of the joined text"
16736        );
16737        d.build_visual(80);
16738        assert_eq!(
16739            d.alignment_at_caret(),
16740            Some(Align::Center),
16741            "and is centred now"
16742        );
16743        d.undo();
16744        assert_eq!(d.source, src, "one undo step");
16745
16746        // A list closes the div: the paragraph joins the last item's text,
16747        // under the item's continuation indent, inside the div.
16748        let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
16749        let mut d = wysiwyg_doc("wys_div_list", src);
16750        d.caret = d.source.find("below").unwrap();
16751        d.backspace();
16752        assert_eq!(
16753            d.source, "<div class=\"center\">\n\n- item\n  below\n\n</div>\n",
16754            "the paragraph joins the item"
16755        );
16756        assert_eq!(d.caret, d.source.find("below").unwrap());
16757
16758        // And where twig has nothing to join into — a code block above — the
16759        // caret steps back to the stop before, and nothing is deleted.
16760        let src = "```\ncode\n```\n\nbelow\n";
16761        let mut d = wysiwyg_doc("wys_code_then_para", src);
16762        d.caret = d.source.find("below").unwrap();
16763        d.backspace();
16764        assert_eq!(d.source, src, "nothing is deleted");
16765        assert!(
16766            d.caret < d.source.find("below").unwrap(),
16767            "the caret stepped back"
16768        );
16769    }
16770
16771    /// Backspace on a blank line collapses to the stop before it — but not
16772    /// across hidden markup, which that collapse deleted whole: a `</div>`,
16773    /// or a comment between two blocks. There the blank line goes alone, and
16774    /// the caret lands where the collapse would have put it.
16775    #[test]
16776    fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
16777        let mut d = wysiwyg_doc(
16778            "wys_div_blank",
16779            "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
16780        );
16781        d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
16782        assert!(
16783            d.vmap.is_stop(d.caret),
16784            "the empty paragraph is a caret home"
16785        );
16786        d.backspace();
16787        assert_eq!(
16788            d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16789            "the blank line goes and the div stays closed"
16790        );
16791        assert_eq!(
16792            d.caret,
16793            d.source.find("hello").unwrap() + 5,
16794            "onto the end of `hello`"
16795        );
16796
16797        let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
16798        d.caret = d.source.find("below").unwrap() - 2;
16799        assert!(d.vmap.is_stop(d.caret));
16800        d.backspace();
16801        assert_eq!(
16802            d.source, "above\n\n<!-- note -->\n\nbelow\n",
16803            "the comment stays"
16804        );
16805        assert_eq!(d.caret, 5);
16806    }
16807
16808    /// Backspace at the end of an attributed span steps inside its hidden
16809    /// closing tag the way it steps inside a `**`, and takes the span with
16810    /// its last letter. Before, the byte-step took the `>` of `</span>`,
16811    /// which left the paragraph unparseable: it vanished from the rich view,
16812    /// and the Backspace after that joined the next block into the wreck.
16813    #[test]
16814    fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
16815        let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
16816        let mut d = wysiwyg_doc("wys_span_bs", src);
16817        d.caret = d.source.find("a test").unwrap() + 6;
16818        for _ in 0..7 {
16819            d.backspace();
16820        }
16821        assert_eq!(
16822            d.source,
16823            "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
16824        );
16825        d.backspace();
16826        assert_eq!(
16827            d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
16828            "the first Backspace after the tag takes the letter, not the `>`"
16829        );
16830        d.backspace();
16831        assert_eq!(
16832            d.source, "above\n\nThis \n\nTest 2\n",
16833            "the last letter takes the span with it"
16834        );
16835        assert_eq!(d.caret, 12, "the caret is where the letter was");
16836        d.backspace();
16837        assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
16838        assert_eq!(d.caret, 11);
16839        d.build_visual(80);
16840        assert!(
16841            d.vmap
16842                .rows
16843                .iter()
16844                .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
16845            "the paragraph is still drawn"
16846        );
16847    }
16848
16849    #[test]
16850    fn backspace_walks_into_a_djot_sized_span_too() {
16851        let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
16852        d.caret = d.source.find("\n\nTest 2").unwrap();
16853        // The caret home at the paragraph's end is inside the span, before
16854        // its `]`: the map offers no stop after `]{…}`.
16855        d.build_visual(80);
16856        assert_eq!(d.vmap.stop_before(31), Some(8));
16857        d.caret = 8;
16858        d.backspace();
16859        assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
16860        d.backspace();
16861        assert_eq!(
16862            d.source, "This \n\nTest 2\n",
16863            "the attribute block outside the span goes with it"
16864        );
16865        d.backspace();
16866        assert_eq!(d.source, "This\n\nTest 2\n");
16867        assert_eq!(d.caret, 4);
16868    }
16869
16870    /// A span that is empty as the file was written has no stop of its own;
16871    /// Backspace reaching it from behind takes it with the character before
16872    /// it, the character the key looked aimed at.
16873    #[test]
16874    fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
16875        let src = "This <span data-size=\"x-large\"></span> a test\n";
16876        let mut d = wysiwyg_doc("wys_span_empty", src);
16877        d.caret = d.source.find(" a test").unwrap();
16878        d.backspace();
16879        assert_eq!(d.source, "This a test\n");
16880        assert_eq!(d.caret, 4);
16881    }
16882
16883    /// The mirror: Delete in front of a span's opening tag takes its first
16884    /// letter, and the span with its last.
16885    #[test]
16886    fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
16887        let src = "This <span data-size=\"x-large\">is</span> a test\n";
16888        let mut d = wysiwyg_doc("wys_span_del", src);
16889        d.caret = 5;
16890        d.delete_forward();
16891        assert_eq!(
16892            d.source,
16893            "This <span data-size=\"x-large\">s</span> a test\n"
16894        );
16895        d.delete_forward();
16896        assert_eq!(d.source, "This  a test\n");
16897        assert_eq!(d.caret, 5);
16898        d.delete_forward();
16899        assert_eq!(d.source, "This a test\n");
16900        assert_eq!(d.caret, 5);
16901    }
16902
16903    /// The block version of the span's emptying rule. Centre a one-letter
16904    /// paragraph — Markdown spells that as a `<div>` around it — and
16905    /// Backspace the letter: the div goes with it, leaving a plain blank line
16906    /// the caret is at home on, in the incremental map and the from-scratch
16907    /// one alike. Before, the letter went alone; the emptied div drew a
16908    /// caret home only the stale map had, and the next Backspace collapsed
16909    /// the line and left `<div class="center">\n\n</div>` standing invisibly
16910    /// in the file.
16911    #[test]
16912    fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
16913        let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
16914        d.caret = d.source.len() - 1;
16915        d.set_alignment(Some(Align::Center));
16916        assert_eq!(
16917            d.source,
16918            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
16919        );
16920        d.backspace();
16921        assert_eq!(d.source, "Try the toolbar.\n\n\n");
16922        assert_eq!(d.caret, 18, "on the blank line where the letter was");
16923        // The host rebuilds the map after every key; the spliced map and a
16924        // fresh one both give the line a caret home.
16925        d.build_visual_unwrapped();
16926        assert!(d.vmap.is_stop(18));
16927        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
16928        d.backspace();
16929        assert_eq!(
16930            d.source, "Try the toolbar.\n",
16931            "then the blank line collapses"
16932        );
16933        assert_eq!(d.caret, 16);
16934
16935        // With a block after the div the blank line keeps a gap each side.
16936        let mut d = wysiwyg_doc(
16937            "wys_div_empty_mid",
16938            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
16939        );
16940        d.caret = d.source.find("T\n").unwrap() + 1;
16941        d.backspace();
16942        assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
16943        assert_eq!(d.caret, 18);
16944        d.build_visual_unwrapped();
16945        assert!(d.vmap.is_stop(18));
16946        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
16947    }
16948
16949    /// djot spells the same paragraph as a `{.center}` line above it, and
16950    /// twig has no node at all for that line once the paragraph is gone —
16951    /// so the line goes with the letter too.
16952    #[test]
16953    fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
16954        let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
16955        d.build_visual(80);
16956        d.caret = d.source.len() - 1;
16957        d.set_alignment(Some(Align::Center));
16958        assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
16959        d.backspace();
16960        assert_eq!(d.source, "Try the toolbar.\n\n\n");
16961        assert_eq!(d.caret, 18);
16962        d.build_visual(80);
16963        assert!(d.vmap.is_stop(18));
16964    }
16965
16966    /// The rule is for a block that would be no block: a div holding more
16967    /// keeps its tags, and an emptied heading is still a heading.
16968    #[test]
16969    fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
16970        let mut d = wysiwyg_doc(
16971            "wys_div_more",
16972            "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
16973        );
16974        d.caret = d.source.find("T\n").unwrap() + 1;
16975        d.backspace();
16976        assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
16977        assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
16978
16979        let mut d = wysiwyg_doc(
16980            "wys_div_heading",
16981            "<div class=\"center\">\n\n# T\n\n</div>\n",
16982        );
16983        d.caret = d.source.find("T\n").unwrap() + 1;
16984        d.backspace();
16985        assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
16986        assert_eq!(d.caret, 24);
16987        d.build_visual(80);
16988        assert!(
16989            d.vmap.is_stop(24),
16990            "the empty heading is still a caret home"
16991        );
16992    }
16993
16994    /// The mirror: Delete in front of the letter takes the div with it.
16995    #[test]
16996    fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
16997        let mut d = wysiwyg_doc(
16998            "wys_div_empty_del",
16999            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
17000        );
17001        d.caret = d.source.find("T\n").unwrap();
17002        d.delete_forward();
17003        assert_eq!(d.source, "Try the toolbar.\n\n\n");
17004        assert_eq!(d.caret, 18);
17005        d.build_visual(80);
17006        assert!(d.vmap.is_stop(18));
17007
17008        let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
17009        d.build_visual(80);
17010        d.caret = d.source.find("T\n").unwrap();
17011        d.delete_forward();
17012        assert_eq!(d.source, "Try the toolbar.\n\n\n");
17013        assert_eq!(d.caret, 18);
17014    }
17015
17016    /// Backspace at a block's start is twig's join, spelled per format — so
17017    /// the cases the one-newline delete got wrong come out right: a
17018    /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
17019    /// and a quote's prefix is written on the joined line.
17020    #[test]
17021    fn backspace_at_a_block_start_joins_it_the_format_s_way() {
17022        let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
17023        d.caret = d.source.find("below").unwrap();
17024        d.backspace();
17025        assert_eq!(d.source, "# Title below\n", "onto the heading's line");
17026        assert_eq!(d.caret, d.source.find("below").unwrap());
17027        d.undo();
17028        assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
17029
17030        let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
17031        d.caret = d.source.find('b').unwrap();
17032        d.backspace();
17033        assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
17034        assert_eq!(d.caret, d.source.find('b').unwrap());
17035
17036        let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
17037        h.view = View::Wysiwyg;
17038        h.build_visual(80);
17039        h.caret = h.source.find("below").unwrap();
17040        h.backspace();
17041        assert_eq!(
17042            h.source, "<p>above\nbelow</p>\n",
17043            "one paragraph, the tag gone whole"
17044        );
17045        assert_eq!(h.caret, h.source.find("below").unwrap());
17046    }
17047
17048    /// Delete at the end of a block's content is the same join aimed at the
17049    /// block after it, and the caret stays where the joined text now begins.
17050    #[test]
17051    fn delete_at_a_block_end_joins_the_next_block_into_it() {
17052        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
17053        let mut d = wysiwyg_doc("wys_del_join", src);
17054        d.caret = d.source.find("hello").unwrap() + 5;
17055        d.delete_forward();
17056        assert_eq!(
17057            d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
17058            "below joins hello inside the div"
17059        );
17060        assert_eq!(
17061            d.caret,
17062            d.source.find("hello").unwrap() + 5,
17063            "the caret stays"
17064        );
17065
17066        let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
17067        d.caret = 5;
17068        d.delete_forward();
17069        assert_eq!(
17070            d.source, "above\nTitle\n",
17071            "the heading's marker goes with the join"
17072        );
17073        assert_eq!(d.caret, 5);
17074
17075        // A code block after the paragraph: nothing to join, the caret steps
17076        // forward onto the next stop and nothing is deleted.
17077        let src = "above\n\n```\ncode\n```\n";
17078        let mut d = wysiwyg_doc("wys_del_code", src);
17079        d.caret = 5;
17080        d.delete_forward();
17081        assert_eq!(d.source, src);
17082        assert!(d.caret > 5, "the caret stepped forward");
17083    }
17084}