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::counts::{self, TextCounts};
42use crate::html;
43use crate::source::{self, SourceMap};
44use crate::style::{Align, FontFace, FontSize, LineHeight, MarkColor, TextColor};
45use crate::wysiwyg::{self, MediaKind, MediaStop, Reveal, VisualMap};
46
47/// Which view the body shows.
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum View {
50    /// The raw document with a caret in source bytes.
51    Source,
52    /// Markup resolved to real styles, caret riding the rendered glyphs.
53    Wysiwyg,
54}
55
56/// How much of the source markup the WYSIWYG view exposes — a per-editor
57/// preference, orthogonal to [`View`]. Named for markup rather than for Markdown
58/// because leaf is grammar-agnostic: twig hands it Djot, HTML and XML on the same
59/// terms, and every rung below is about *delimiters*, whatever grammar spells
60/// them. The examples are Markdown only because that is what most documents are.
61///
62/// A single ladder over two underlying axes, because only three of their four
63/// combinations are coherent:
64///
65/// | | authoring off | authoring on |
66/// |---|---|---|
67/// | delimiters hidden | [`None`](Self::None) | [`Shortcuts`](Self::Shortcuts) |
68/// | caret line revealed | *incoherent* | [`Full`](Self::Full) |
69///
70/// The empty quadrant would show delimiters on the caret's line and then escape
71/// the ones you type — a surface that displays a syntax it refuses to accept.
72/// Someone who wants to read raw markup without authoring it has
73/// [`View::Source`], which is the better tool for it.
74///
75/// The two axes are read separately by the code that cares — see
76/// [`reveals_caret_line`](Self::reveals_caret_line) and
77/// [`authors`](Self::authors) — so neither behaviour has to know it's spelled
78/// as a ladder.
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
80pub enum MarkupMode {
81    /// Delimiters stay hidden even on the caret's line, and typed syntax stays
82    /// literal — twig escapes anything that would open markup, so formatting
83    /// comes from commands (⌘b, the toolbar) instead of from spelling. The clean
84    /// reading surface for people who don't write markup by hand; the default,
85    /// and what Diaryx ships.
86    #[default]
87    None,
88    /// Delimiters stay hidden, but typing them authors real markup: `*x*`
89    /// becomes italic and the asterisks disappear into the styling
90    /// (Typora/Bear-shaped). For someone who knows the syntax but wants the
91    /// clean surface back once it has been applied.
92    Shortcuts,
93    /// The caret's line shows its raw markup while every other line renders
94    /// resolved (Obsidian live-preview-shaped), and typed syntax authors markup
95    /// — for people fluent in the document's grammar who want to see and edit
96    /// the delimiters they type.
97    Full,
98}
99
100impl MarkupMode {
101    /// Whether the rich view shows raw delimiters on the line holding the caret.
102    /// The rendering axis — read by [`Doc::reveal_line`] and threaded into the
103    /// WYSIWYG builder.
104    pub fn reveals_caret_line(self) -> bool {
105        matches!(self, MarkupMode::Full)
106    }
107
108    /// Whether typed markup characters author real formatting. The editing axis
109    /// — read by [`Doc::insert`], which escapes typed syntax when this is false.
110    pub fn authors(self) -> bool {
111        !matches!(self, MarkupMode::None)
112    }
113}
114
115/// How the WYSIWYG view treats a *soft break* — a bare newline inside a
116/// paragraph. An axis of its own, orthogonal to [`MarkupMode`] (which governs
117/// inline-markup delimiters) and to [`View`]: any reveal preference pairs with
118/// either flow. The renderer consults it when it lays a block's inline content
119/// into visual rows.
120#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
121pub enum LineFlow {
122    /// A soft break folds into a space and the paragraph reflows to the
123    /// viewport width — flowing prose, where the source's line wrapping is
124    /// insignificant. The default, and what Diaryx ships.
125    #[default]
126    Fold,
127    /// A soft break renders as a line break exactly where it was written, so
128    /// the author's source line structure shows on screen unchanged — the mode
129    /// for people who lay out their prose deliberately (one sentence or clause
130    /// per line, semantic line breaks). The break is still a soft break in the
131    /// source; only its rendering changes.
132    Preserve,
133}
134
135/// What the file behind a document looks like right now, against the bytes leaf
136/// last read from it or wrote to it — the question a frontend asks before it
137/// saves (a `Changed` file plus a `dirty` document is an overwrite about to
138/// happen) or when its window regains focus. See [`Doc::disk_state`].
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum DiskState {
141    /// The file holds exactly the bytes leaf last read or wrote.
142    Unchanged,
143    /// Someone else wrote the file since. Saving overwrites their work; see
144    /// [`Doc::reload`] for the other direction.
145    Changed,
146    /// The file is gone — deleted or renamed away. A save recreates it.
147    Missing,
148    /// There is a path, but the file couldn't be read (permissions, a directory
149    /// in the way): leaf can't tell, and won't guess.
150    Unreadable,
151    /// No file behind this document yet — see [`Doc::blank`]. Nothing can have
152    /// changed under a document that was never on disk.
153    Untitled,
154}
155
156/// The inline marks in force at a point in the document — what a toolbar
157/// lights up. A `Copy` bitset rather than a `HashSet`, because
158/// [`Doc::active_inline_marks`] is called on every frame that draws a toolbar
159/// and a set that allocates to answer "is Bold on?" is a set that shouldn't.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub struct InlineMarks(u8);
162
163impl InlineMarks {
164    /// Every kind, in the order [`InlineMarks::iter`] yields them.
165    const ALL: [InlineKind; 8] = [
166        InlineKind::Strong,
167        InlineKind::Emph,
168        InlineKind::Verbatim,
169        InlineKind::Mark,
170        InlineKind::Superscript,
171        InlineKind::Subscript,
172        InlineKind::Insert,
173        InlineKind::Delete,
174    ];
175
176    pub const fn empty() -> Self {
177        InlineMarks(0)
178    }
179
180    /// Private: the set is an *answer*, and adding a mark to it doesn't mark
181    /// anything ([`Doc::toggle`] does that). `FromIterator` is the way in.
182    fn insert(&mut self, kind: InlineKind) {
183        self.0 |= Self::bit(kind);
184    }
185
186    /// Flip `kind` in the set — the sticky-marks toggle at a collapsed caret.
187    fn flip(&mut self, kind: InlineKind) {
188        self.0 ^= Self::bit(kind);
189    }
190
191    /// The symmetric difference: which marks differ between the two sets. Used
192    /// to resolve the marks already in force at the caret against the pending
193    /// delta — a bit set in the delta flips the base mark for the next keystroke.
194    fn xor(self, other: InlineMarks) -> InlineMarks {
195        InlineMarks(self.0 ^ other.0)
196    }
197
198    /// Whether `kind` is in force — the toolbar's "is Bold active?".
199    pub fn contains(self, kind: InlineKind) -> bool {
200        self.0 & Self::bit(kind) != 0
201    }
202
203    pub fn is_empty(self) -> bool {
204        self.0 == 0
205    }
206
207    /// The marks in force, for a frontend that renders whatever is on rather
208    /// than asking after a fixed list.
209    pub fn iter(self) -> impl Iterator<Item = InlineKind> {
210        Self::ALL.into_iter().filter(move |&k| self.contains(k))
211    }
212
213    fn bit(kind: InlineKind) -> u8 {
214        1 << match kind {
215            InlineKind::Strong => 0,
216            InlineKind::Emph => 1,
217            InlineKind::Verbatim => 2,
218            InlineKind::Mark => 3,
219            InlineKind::Superscript => 4,
220            InlineKind::Subscript => 5,
221            InlineKind::Insert => 6,
222            InlineKind::Delete => 7,
223        }
224    }
225}
226
227impl FromIterator<InlineKind> for InlineMarks {
228    fn from_iter<I: IntoIterator<Item = InlineKind>>(iter: I) -> Self {
229        let mut m = InlineMarks::empty();
230        for k in iter {
231            m.insert(k);
232        }
233        m
234    }
235}
236
237/// What kind of edit produced an undo group. Same-kind edits in a row coalesce
238/// into one undo step (a run of typed characters undoes together); `Other` never
239/// coalesces, so a paste, format toggle, or block change is always its own step.
240#[derive(Clone, Copy, PartialEq, Eq)]
241enum EditKind {
242    Insert,
243    Delete,
244    /// One step of an IME composition — see [`Doc::edit_composing`]. Its own kind
245    /// rather than `Insert`'s because a composition is not typing: each step
246    /// *replaces* the last (`か` → `かん` → `感`), so the run has to coalesce even
247    /// though no two steps insert the same bytes, and it must not fold into the
248    /// typed characters on either side of it.
249    Compose,
250    Other,
251}
252
253/// Which side of the caret a delete looks for an in-cell `<br>` break to swallow
254/// whole — see [`Doc::cell_break_at`]. `Backward` is Backspace (a break ending at
255/// the caret), `Forward` is Delete (one starting at it).
256#[derive(Clone, Copy)]
257enum BreakEdge {
258    Backward,
259    Forward,
260}
261
262/// A re-spelling of one inline mark run, held ready in case the edit about to
263/// happen breaks it — see [`Doc::mark_edge_fix`] and [`Doc::repair_mark_edges`].
264/// Every offset in it is in the coordinates the document will have *after* the
265/// plain edit, since that is when it may be applied.
266struct MarkEdgeFix {
267    /// The run's kind, and an offset inside what was its content: together they
268    /// answer "did the plain edit actually break this mark?" — the question that
269    /// decides whether any of this is applied at all.
270    kind: InlineKind,
271    probe: usize,
272    /// The byte range to re-spell (the run's delimiters included) and its new
273    /// spelling, with the edge whitespace moved outside the delimiters.
274    start: usize,
275    end: usize,
276    text: String,
277    /// Where the caret belongs afterwards — the same place on screen it would
278    /// have had, which is now on the other side of a delimiter.
279    caret: usize,
280    /// The marks in force for text typed at that caret. The caret can land
281    /// outside a run it was inside, and the marks have to survive the move or
282    /// the toolbar goes dark mid-word.
283    want: InlineMarks,
284}
285
286/// The caret and selection at one moment — the part of a history step twig's
287/// `Change` cannot carry, because the caret is leaf's state and twig only knows
288/// about bytes. leaf serializes it into the opaque per-state blob twig now
289/// stores in its own undo history (see `record_caret`), so undo and redo hand
290/// back the caret that matches the source they restore.
291#[derive(Clone, Copy)]
292struct CaretState {
293    caret: usize,
294    anchor: Option<usize>,
295}
296
297impl CaretState {
298    /// Pack into the fixed 17-byte blob leaf hands twig: the caret as a u64,
299    /// then an anchor-present flag and the anchor. twig copies these bytes and
300    /// never reads them.
301    fn to_blob(self) -> [u8; 17] {
302        let mut b = [0u8; 17];
303        b[..8].copy_from_slice(&(self.caret as u64).to_le_bytes());
304        if let Some(a) = self.anchor {
305            b[8] = 1;
306            b[9..].copy_from_slice(&(a as u64).to_le_bytes());
307        }
308        b
309    }
310
311    /// Recover a state from twig's blob, or `None` when it is empty or the wrong
312    /// length — a state twig restored that never had a caret set on it, which
313    /// leaves the caller to fall back to the edit site.
314    fn from_blob(b: &[u8]) -> Option<Self> {
315        let b: &[u8; 17] = b.try_into().ok()?;
316        let caret = u64::from_le_bytes(b[..8].try_into().unwrap()) as usize;
317        let anchor = (b[8] != 0).then(|| u64::from_le_bytes(b[9..].try_into().unwrap()) as usize);
318        Some(CaretState { caret, anchor })
319    }
320}
321
322/// A footnote reference and the note it names — the answer to
323/// [`Doc::footnote_at`].
324///
325/// The two `Option`s move together: a reference whose definition is missing has
326/// neither a body to show nor a place to jump to, and one that resolved has
327/// both.
328#[derive(Clone, PartialEq, Eq, Debug)]
329pub struct FootnoteRef {
330    /// The reference's label — the `1` of `[^1]`, with neither the `^` that
331    /// spells it a footnote nor the brackets around it.
332    pub label: String,
333    /// The note's body as source bytes (see
334    /// [`wysiwyg::footnote_body_span`](crate::wysiwyg)), or `None` when the
335    /// document defines no `[^label]:` to read one from.
336    pub text: Option<String>,
337    /// Where the note's *body* starts, for a "go to note" that moves the caret
338    /// there. `None` alongside a `None` `text`.
339    ///
340    /// The body rather than the definition, because this is an offset to put a
341    /// caret on and the `[^1]:` marker is decoration the caret can't occupy —
342    /// aiming at the definition's first byte snaps to the nearest real stop,
343    /// which is up in the paragraph above the note. It is also simply where a
344    /// reader following a reference wants to land: at the note's first word,
345    /// ready to read or amend it.
346    pub offset: Option<usize>,
347    /// Where the note's body ends, exclusive — so a frontend can ask which
348    /// *rendered rows* the note occupies and draw those instead of [`text`](Self::text).
349    ///
350    /// The rows are the note with its markup resolved: `see *later*` reaches a
351    /// frontend as an italic run, not as asterisks. `text` is the source bytes
352    /// and stays the honest answer for anything that wants the note as written
353    /// (a search index, a copy); this pair of offsets is for anything that wants
354    /// it as *read*. `None` alongside a `None` `offset`.
355    pub end: Option<usize>,
356}
357
358/// A footnote definition and the reference that sends a reader to it — the
359/// answer to [`Doc::footnote_definition_at`], and the other half of the round
360/// trip [`FootnoteRef`] starts.
361///
362/// A note is a place a reader *arrives*, so the useful thing to know while
363/// standing in one is the way back. Without this the jump to a note is a
364/// one-way door: the definitions sit at the foot of the document, so returning
365/// by hand means scrolling back up and finding the sentence again.
366#[derive(Clone, PartialEq, Eq, Debug)]
367pub struct FootnoteDef {
368    /// The definition's label — the `1` of `[^1]: …`, marker and colon stripped,
369    /// spelled exactly as [`FootnoteRef::label`] spells the same footnote's.
370    pub label: String,
371    /// Where the reference's *label* is, for a "back to reference" that moves
372    /// the caret there. `None` for a note nothing refers to — an orphan, which
373    /// is worth being able to say rather than silently doing nothing.
374    ///
375    /// The label rather than the reference's first byte, for
376    /// [`FootnoteRef::offset`]'s reason: a reference's brackets are decoration
377    /// and its label is the only part of it the caret can rest on.
378    ///
379    /// The *first* reference, when a label is cited more than once: a repeated
380    /// citation has no one true home, and the first is both the one a reader
381    /// most likely came from and the only choice that doesn't depend on how
382    /// they got here.
383    pub offset: Option<usize>,
384}
385
386/// Where a locator lands — the answer to [`Doc::locate`].
387///
388/// A locator (the `v2` of a `chapter.dj#v2`) names a *place* rather than a
389/// document, and a place is a span rather than a point: a reader following one
390/// wants the caret at its first byte, and a reader merely *peeking* at one wants
391/// the block it covers drawn. Both are served by carrying the whole span, and
392/// only one of the two can be recovered from an offset alone.
393#[derive(Clone, PartialEq, Eq, Debug)]
394pub struct Landing {
395    /// The first byte of the block the locator names — where a caret goes.
396    pub start: usize,
397    /// One past its last byte, so a frontend can map the pair through
398    /// [`VisualMap::row_range_for`](crate::wysiwyg::VisualMap::row_range_for) to the rendered rows the block occupies and draw
399    /// those, the way a footnote peek draws a note ([`FootnoteRef::end`]).
400    pub end: usize,
401}
402
403/// A selection cited out of the source: the text itself, up to a requested
404/// number of characters either side, and the byte range it came from. See
405/// [`Doc::selection_quote`].
406///
407/// The prefix and suffix are what make the quote *re-findable*: the same text
408/// can occur twice, and a little of what surrounded it is how a later reader —
409/// or the same document after an edit — tells the occurrences apart. The Web
410/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
411/// the name.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct Quote {
414    /// The selected source, verbatim.
415    pub exact: String,
416    /// What immediately preceded it — possibly empty, at the document's start.
417    pub prefix: String,
418    /// What immediately followed it — possibly empty, at the document's end.
419    pub suffix: String,
420    /// Byte offset in the source where the selection begins.
421    pub start: usize,
422    /// Byte offset where it ends (exclusive).
423    pub end: usize,
424}
425
426/// A host-painted range of the source — an annotation's footprint, a search
427/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
428/// glyphs whose source falls inside it) and hands back the `id` when the
429/// reader activates it; what the range *means* is entirely the host's.
430///
431/// Ranges are source bytes, like the caret and the selection, so a host that
432/// anchors quotes against the source ([`Doc::selection_quote`] is the other
433/// half of that loop) can paint what it found without any coordinate
434/// conversion. A range that drifts off the text it meant is the host's to
435/// re-anchor; leaf draws what it is told.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct Highlight {
438    /// Byte offset in the source where the wash begins.
439    pub start: usize,
440    /// Byte offset where it ends (exclusive).
441    pub end: usize,
442    /// The host's name for it, handed back on activation. Opaque to leaf.
443    pub id: String,
444    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
445    /// nothing for the theme's default wash.
446    pub color: Option<String>,
447    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
448    /// a marker gets a small glyph in the margin beside its first line, and
449    /// the glyph — not the wash — is what activates it: the wash is ink, the
450    /// marker is the control, which is what lets a reader put a caret in (or
451    /// copy from) annotated text without a card leaping at them. The name is
452    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
453    /// as a class.
454    pub marker: Option<String>,
455}
456
457impl Highlight {
458    /// The range covering source `offset` in a list [`Doc::set_highlights`]
459    /// sorted, first by start where several overlap — the one place that
460    /// question is answered, for the frontends that paint by asking it as well
461    /// as for [`Doc::highlight_at`].
462    ///
463    /// The list is sorted by `(start, end)`, so the scan can stop at the first
464    /// range starting past `offset` rather than running to the end. A painter
465    /// asking once per glyph wants [`HighlightCursor`] instead; this is the
466    /// one-shot form, for the host asking what the reader just activated.
467    pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
468        highlights
469            .iter()
470            .take_while(|h| h.start <= offset)
471            .find(|h| offset < h.end)
472    }
473}
474
475/// [`Highlight::covering`] for a caller walking the document in order — which
476/// is every painter, since a frontend draws rows top to bottom and glyphs left
477/// to right.
478///
479/// The one-shot form is a scan from the front of the list per glyph, and a
480/// document with two hundred search hits pays that two hundred times a row. A
481/// range that ends at or before an offset can never cover that offset *or any
482/// later one*, so the cursor retires those permanently and each glyph costs the
483/// ranges that actually reach it. The answer is identical to
484/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
485/// the part that was being redone dropped, not a cheaper approximation.
486///
487/// Offsets are expected to arrive non-decreasing. One that goes backwards is
488/// still answered correctly: the cursor re-seats to the front, since a painter
489/// that revisits a row is asking a question the retired ranges may own again.
490pub struct HighlightCursor<'a> {
491    highlights: &'a [Highlight],
492    /// The first range not yet retired.
493    at: usize,
494    /// The last offset asked about, to notice a caller going backwards.
495    last: usize,
496}
497
498impl<'a> HighlightCursor<'a> {
499    pub fn new(highlights: &'a [Highlight]) -> Self {
500        HighlightCursor {
501            highlights,
502            at: 0,
503            last: 0,
504        }
505    }
506
507    /// The range covering `offset`, advancing the cursor past every range that
508    /// can no longer cover anything.
509    pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
510        if offset < self.last {
511            self.at = 0;
512        }
513        self.last = offset;
514        while self
515            .highlights
516            .get(self.at)
517            .is_some_and(|h| h.end <= offset)
518        {
519            self.at += 1;
520        }
521        Highlight::covering(&self.highlights[self.at..], offset)
522    }
523}
524
525/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
526/// purpose: the only useful question is whether two of them are the same map,
527/// and what is behind it — which `Doc` built it, and the (revision, wrap,
528/// reveal line) it was built from — is core's business.
529///
530/// The document is part of it because the rest is not unique to one: two
531/// documents opened at the same width are both at revision zero with no reveal
532/// line, and a frontend holding one copy of a map across the two would take
533/// the second's key for the first's and paint the wrong document.
534#[derive(Clone, PartialEq, Eq, Debug)]
535pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
536
537pub struct Doc {
538    editor: Editor,
539    pub format: Format,
540    pub path: PathBuf,
541    /// Current source, refreshed from the editor after every successful edit.
542    pub source: String,
543    /// The caret, as a byte offset into `source` (always on a char boundary).
544    pub caret: usize,
545    /// The selection's fixed end, if a selection is active; the moving end is
546    /// the caret. `None` means no selection.
547    pub anchor: Option<usize>,
548    pub dirty: bool,
549    pub status: Option<String>,
550    pub view: View,
551    /// Whether the document refuses to change — a *reading* surface over the
552    /// same rendering, selection, and navigation the editor has.
553    ///
554    /// Enforced here rather than by each frontend hiding its input paths,
555    /// because every mutation funnels through a few doors —
556    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
557    /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
558    /// verbs directly rather than through the splice (a typed literal, a link,
559    /// an image, a rule, a footnote, a cell's line break) — and guarded doors
560    /// are a guarantee where a frontend's suppressed keyboard is a hope. A
561    /// gated door reports exactly like a rolled-back splice, a path every
562    /// caller already handles. `a_read_only_document_refuses_every_door` is
563    /// the list; a new `self.editor.insert_*` call belongs on it.
564    read_only: bool,
565    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
566    /// State like the selection rather than like the text: no edit history,
567    /// no dirty bit, redrawn from whatever the host last set.
568    highlights: Vec<Highlight>,
569    /// How much of the source markup the rich view exposes — a frontend preference (see
570    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
571    /// [`reveal_line`](Self::reveal_line), the editing one by
572    /// [`insert`](Self::insert).
573    markup_mode: MarkupMode,
574    /// Whether soft breaks fold into the reflowed paragraph or render where
575    /// they were written (see [`LineFlow`]) — an independent frontend
576    /// preference the WYSIWYG builder consults when it lays out a block.
577    line_flow: LineFlow,
578    /// The kind of the last edit, for coalescing: twig owns the undo *history*
579    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
580    /// call, so leaf decides when a run continues and tells twig to coalesce.
581    last_edit_kind: Option<EditKind>,
582    /// The inline marks the user has toggled *at a collapsed caret* with no
583    /// selection — "start typing bold here". Held as the XOR delta from the marks
584    /// already in force at [`pending_at`](Self::pending_at): a set bit means
585    /// "flip this kind for the next typed text", so it both turns a mark on where
586    /// none is (type into bold) and off where one already covers the caret (type
587    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
588    /// freshly typed text and then clears it — a mark once realised is carried by
589    /// the caret sitting inside the run, not by this delta.
590    pending_marks: InlineMarks,
591    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
592    /// delta is live only while the caret still stands here with no selection;
593    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
594    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
595    pending_at: Option<usize>,
596    /// The source as of the last open/save — `dirty` is `source != clean_source`,
597    /// so undoing back to the saved state correctly clears the modified flag.
598    clean_source: String,
599    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
600    /// while the document has no file behind it. [`Doc::disk_state`] compares
601    /// the file against this to catch an edit made *outside* leaf before a save
602    /// silently overwrites it — `clean_source` only knows what leaf itself did.
603    ///
604    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
605    /// writes inside one filesystem timestamp tick are indistinguishable, a
606    /// clock that steps backwards (or a writer that restores an mtime) hides a
607    /// real change, and a `touch` invents one. The whole point of the watermark
608    /// is to not clobber someone's work, so it reads the bytes and compares what
609    /// is actually there. That costs a file read per question, which is why the
610    /// question is asked on a user event (focus, save) and not every frame.
611    disk_hash: Option<u64>,
612    /// The "sticky" display column vertical motion aims for, in the active
613    /// view's grid. Set on the first `move_up`/`move_down` of a run and
614    /// reused by every subsequent one in that run, so passing through a
615    /// shorter line doesn't permanently forget the original column. Any
616    /// horizontal motion or edit clears it.
617    ///
618    /// A column, not a character index: dropping down a line of `你好` onto one
619    /// of ASCII has to land under the glyph the caret was drawn beneath, which
620    /// is the only thing the user can see to aim by. Where the goal falls inside
621    /// a wide character on the target line, the mapping resolves it to that
622    /// character — the caret lands on it rather than between its cells.
623    goal_col: Option<usize>,
624    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
625    /// and clicks read it to stay in visible space.
626    pub vmap: VisualMap,
627    /// The syntax map for the source view; empty in the WYSIWYG view, which
628    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
629    /// frontend that never calls it paints raw source unstyled, which is what
630    /// every frontend did before this map existed.
631    pub smap: SourceMap,
632    /// The revision `smap` was built from, or `None` before the first build.
633    /// The map is a pure function of the text alone — no width, no caret, no
634    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
635    /// whole key.
636    smap_key: Option<u64>,
637    /// Everything the map is built from, as one number: bumped whenever the
638    /// document's text changes, and never by a motion, a selection, or a save.
639    /// A frontend can hold work against it — see [`Doc::revision`].
640    revision: u64,
641    /// How many history steps stand behind the caret, and how many ahead of
642    /// it — the answer to a native Edit menu's "may Undo be enabled?", which
643    /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
644    /// the funnel every edit comes through, and moved back and forth by
645    /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
646    /// an exact depth: a coalesced run of typing is one of twig's steps but
647    /// several of these, and twig's own cap on history is not mirrored here.
648    /// Neither error can make `can_undo` false while a step remains, which is
649    /// the only property a menu needs; the one place the bound can be wrong the
650    /// other way — the cap has retired every step — is reconciled the moment
651    /// twig reports nothing to undo.
652    undo_steps: usize,
653    redo_steps: usize,
654    /// What `vmap` was built from, or `None` before the first build. The map is
655    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
656    /// moved, rebuilding it produces the identical map — see
657    /// [`Doc::build_visual`].
658    ///
659    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
660    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
661    /// text and width alone, and a caret motion still rebuilds nothing.
662    vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
663    /// Which `Doc` this is, distinct from every other one built in this
664    /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
665    /// can never be mistaken for another document's — see
666    /// [`Doc::visual_key`]. Nothing else reads it.
667    identity: u64,
668    /// Per-block row cache backing the incremental rebuild: when the text
669    /// changes, only the top-level blocks whose bytes moved are re-rendered and
670    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
671    /// builds; a pure accelerator, so it's never read for correctness.
672    block_cache: wysiwyg::BlockCache,
673    /// What the frontend has said about itself — how tall its pictures came
674    /// out, keyed by destination or by TeX, and whether it paints a picture in
675    /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`] and
676    /// [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
677    /// so this is the only way it learns a height or a capability. Threaded
678    /// into every build; a change drops both caches, since none of it is in a
679    /// block's bytes.
680    surface: wysiwyg::Surface,
681
682    // View geometry the renderer stamps each frame, so mouse events can map a
683    // screen cell back to a byte offset.
684    pub scroll: usize,
685    pub body_origin: (u16, u16),
686    /// Width of the body rectangle last painted by the frontend. Zero means
687    /// unknown (used by tests or a frontend that has not drawn yet).
688    pub body_width: u16,
689    pub body_height: u16,
690    /// The caret as of the last frame drawn, or `None` before the first.
691    ///
692    /// Scrolling is the viewport's business, not the caret's: the view follows
693    /// the caret when the caret *moves*, but a wheel that doesn't touch the
694    /// caret has to be free to scroll away from it — otherwise the view is
695    /// pinned to the caret and stops dead at the edge of the document you can
696    /// see. Comparing against this is what tells the two apart, and it catches a
697    /// caret set by any route, including a frontend assigning the field itself.
698    pub drawn_caret: Option<usize>,
699}
700
701/// The Markdown extensions every leaf document is parsed with — five of them,
702/// each departing from twig's defaults for a reason leaf can state.
703///
704/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
705/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
706/// node the frontends can frame and rasterize instead of opaque `raw_block`
707/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
708/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
709/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
710/// plain tinted container, agnostic of `name`.
711///
712/// `highlight` and `highlight_colors` are the pair that makes Markdown read
713/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
714/// `data-color`. leaf already had somewhere to put both: the
715/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
716/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
717/// from a Djot one it was converted from — the button wrote `==…==` and the
718/// reparse read it straight back as text.
719/// They are on together because a colour is inert without the highlight itself,
720/// and a document that writes `==🔴 x==` means the colour by it.
721///
722/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
723/// a `display_math` one — what djot reads natively and what leaf has
724/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
725/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
726/// twig has already read as an escaped comma, and an author who types a
727/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
728/// twig's own rule that a dollar followed by whitespace never opens math, so
729/// `$5 and $6` stays prose.
730///
731/// Every flag is inert for non-Markdown formats, so it's safe to pass them
732/// unconditionally. Threading this through every constructor (not just `open`)
733/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
734/// way — twig reparses with these same flags after each edit.
735pub(crate) fn parse_extensions() -> MarkdownExtensions {
736    MarkdownExtensions {
737        html_elements: true,
738        directives: true,
739        highlight: true,
740        highlight_colors: true,
741        math: true,
742    }
743}
744
745/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
746/// mapping twig's error into the `anyhow` context every constructor shares.
747fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
748    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
749}
750
751/// Does `format` spell a table as a **pipe table** — the one grid twig's table
752/// editor knows how to emit?
753///
754/// This is the single capability leaf still has to answer for itself, and the
755/// only hand-maintained format list left in this file. Every other gesture is
756/// [`Format::supports`], which is twig's own answer read across the C ABI — but
757/// twig deliberately leaves the table ops out of that query, because they read
758/// no `Syntax` table at all. They rewrite a grid that is already in the source
759/// and refuse on *position*, never on format. Handed a caret inside an HTML
760/// `<table>`, `table_insert_row` therefore re-emits the whole element as
761/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
762/// `dirty` flag, and nothing downstream able to tell it from a good edit.
763///
764/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
765/// wildcard answers "no" for a format leaf has never heard of: a new twig
766/// language that *does* spell pipe tables loses its grid controls until this
767/// line is updated, which shows up as a missing button. The other default hands
768/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
769fn spells_pipe_tables(format: Format) -> bool {
770    matches!(format, Format::Markdown | Format::Djot)
771}
772
773/// Which of leaf's authoring controls this document's format can actually
774/// spell — one flag per toolbar button, resolved once so a frontend can build
775/// its chrome instead of discovering each refusal on a click.
776///
777/// Every field but [`table`](Self::table) is `Format::supports_with` on the
778/// gesture the matching [`Doc`] method calls, so this record cannot drift from
779/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
780/// doesn't export.
781///
782/// `supports_with` rather than `supports` because two of these are facts about
783/// the *parse options* as much as about the format. `Format::supports` answers
784/// for twig's defaults, and leaf never parses with those — it parses with
785/// [`parse_extensions`], and a document's toolbar has to describe the document
786/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
787/// without it would mint bytes its own reparse hands back as plain text, which
788/// is why twig asks before it writes.
789///
790/// **The formats are ragged, and that is the point.** A single per-document
791/// boolean was enough while the two authorable formats were Markdown and djot
792/// and everything else spelled nothing. HTML is neither: it writes seven of the
793/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
794/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
795/// pair, and since 3.5 a quote, a list, a code block, a link and an image
796/// printed as fresh nodes; it spells no task box (a form control there) and
797/// no footnote, and its `<table>` is one twig reads but will not write. So
798/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
799/// table buttons do not, and no one flag can say that. Markdown and djot
800/// differ from each other too:
801/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
802#[derive(Clone, Copy, Debug, Eq, PartialEq)]
803pub struct Capabilities {
804    /// ⌘B — `InlineKind::Strong`.
805    pub bold: bool,
806    /// ⌘I — `InlineKind::Emph`.
807    pub italic: bool,
808    /// Inline code — `InlineKind::Verbatim`.
809    pub code: bool,
810    /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
811    /// under the `highlight` extension [`parse_extensions`] turns on: the
812    /// button writes `==text==`, which is what the reparse reads back.
813    pub mark: bool,
814    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
815    pub underline: bool,
816    /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
817    /// out of the box, since twig parses it out of the box.
818    pub strike: bool,
819    /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
820    /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
821    /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
822    /// so a toolbar offering the swatches wherever the button lights would offer
823    /// them in a document that cannot write one. Pair with
824    /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
825    /// a highlight to colour as much as a format that spells one.
826    pub mark_color: bool,
827    pub superscript: bool,
828    pub subscript: bool,
829    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
830    pub heading: bool,
831    pub blockquote: bool,
832    pub bullet_list: bool,
833    pub ordered_list: bool,
834    /// The checkbox controls: giving an item a box, and ticking one.
835    pub task: bool,
836    pub link: bool,
837    /// Covers [`Doc::insert_media`] too — see the note there on why the three
838    /// media kinds stand or fall together.
839    pub image: bool,
840    /// The horizontal-rule button. HTML spells this one (`<hr>`).
841    pub thematic_break: bool,
842    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
843    /// the pair; HTML has no footnote of its own, so the button goes away rather
844    /// than writing brackets that would render as brackets.
845    pub footnote: bool,
846    /// Setting a fenced block's language — a control only ever offered with the
847    /// caret already in a fence.
848    pub code_language: bool,
849    /// The grid controls: insert/delete/move a row or column, set a column's
850    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
851    /// question — an HTML `<table>` holds the caret and still can't be edited.
852    pub table: bool,
853    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
854    /// idiomatic in-cell break.
855    pub cell_line_break: bool,
856    /// The alignment control — [`Doc::set_alignment`], twig's
857    /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
858    /// block's attributes, Markdown under the `html_elements`
859    /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
860    /// through its `[…]` line.
861    pub alignment: bool,
862    /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
863    /// [`alignment`](Self::alignment) and so the same answer, and its own flag
864    /// because a toolbar dims controls one at a time and the pair may yet
865    /// diverge.
866    pub line_spacing: bool,
867    /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
868    /// over a selection. **Narrower than the block pair**: AsciiDoc's
869    /// `[#id.role]#text#` keeps an id and a role and has no slot for a
870    /// `data-` key, so twig refuses the span there and this is `false` while
871    /// [`alignment`](Self::alignment) is `true`. The block-level form of the
872    /// same property — the caret in a paragraph, no selection — goes through
873    /// `SetBlockAttrs` and still works, which is why the flag describes the
874    /// control rather than the caret.
875    pub font_size: bool,
876    /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
877    /// [`font_size`](Self::font_size) is.
878    pub font_family: bool,
879    /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
880    /// and not to be confused with [`mark_color`](Self::mark_color): that is a
881    /// highlight's background and rides the `mark` node twig already owns,
882    /// this is a run's foreground and rides an attributed span.
883    pub text_color: bool,
884    /// The page-break button — [`Doc::insert_page_break`], twig's
885    /// `Gesture::InsertDirective`. Markdown under the `directives` extension
886    /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
887    /// it as an empty `::: page-break` fence.
888    ///
889    /// **Those two and no others**, though twig spells the gesture in HTML and
890    /// AsciiDoc as well — see [`Capabilities::of`].
891    pub page_break: bool,
892}
893
894impl Capabilities {
895    /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
896    /// twig computes each from a static table — but a frontend that wants to
897    /// hold them can.
898    ///
899    /// The extensions are not a parameter because they are not a choice a
900    /// caller makes: every leaf document is parsed with [`parse_extensions`],
901    /// so the format is the whole of what varies.
902    pub fn of(format: Format) -> Self {
903        let exts = parse_extensions();
904        let supports = |g| format.supports_with(exts, g);
905        let inline = |k| supports(Gesture::ToggleInline(k));
906        let container = |k| supports(Gesture::ToggleBlockContainer(k));
907        Self {
908            bold: inline(InlineKind::Strong),
909            italic: inline(InlineKind::Emph),
910            code: inline(InlineKind::Verbatim),
911            mark: inline(InlineKind::Mark),
912            underline: inline(InlineKind::Insert),
913            strike: inline(InlineKind::Delete),
914            mark_color: supports(Gesture::SetMarkColor),
915            superscript: inline(InlineKind::Superscript),
916            subscript: inline(InlineKind::Subscript),
917            heading: supports(Gesture::SetBlock),
918            blockquote: container(BlockContainerKind::BlockQuote),
919            bullet_list: container(BlockContainerKind::BulletList),
920            ordered_list: container(BlockContainerKind::OrderedList),
921            // Both halves of the checkbox story, and leaf offers no control that
922            // needs only one: the item gesture mints the box, the checked one
923            // ticks it, and a format spelling a `task_marker` spells both.
924            task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
925            link: supports(Gesture::InsertLink),
926            image: supports(Gesture::InsertImage),
927            thematic_break: supports(Gesture::InsertThematicBreak),
928            footnote: supports(Gesture::InsertFootnote),
929            code_language: supports(Gesture::SetCodeLanguage),
930            table: spells_pipe_tables(format),
931            cell_line_break: supports(Gesture::InsertLineBreak),
932            // The presentation vocabulary, one gesture per level: the two
933            // line-level properties are a block's attributes and the three
934            // run-level ones a span's. They are asked separately because the
935            // formats answer differently — AsciiDoc spells the block and not
936            // the span — and a toolbar that dimmed all five together would dim
937            // three controls that work.
938            alignment: supports(Gesture::SetBlockAttrs),
939            line_spacing: supports(Gesture::SetBlockAttrs),
940            font_size: supports(Gesture::WrapRangeAttrs),
941            font_family: supports(Gesture::WrapRangeAttrs),
942            text_color: supports(Gesture::WrapRangeAttrs),
943            // Narrower than the gesture, on purpose. Twig spells
944            // `InsertDirective` in HTML and AsciiDoc too, and spells it
945            // *differently* there — `<page-break></page-break>` and `<<<` —
946            // and the walker reads only the two spellings above. An HTML page
947            // break draws as nothing at all (no row, no caret home) and an
948            // AsciiDoc one as an empty unlabelled row, so the button would
949            // write a break the author cannot see and cannot get back to.
950            // The proposal claims Markdown and djot, and this is that claim.
951            // Widening it is the walker's work, not this line's — see
952            // `docs/tasks/page-break-in-html-and-asciidoc.md`.
953            page_break: supports(Gesture::InsertDirective)
954                && matches!(format, Format::Markdown | Format::Djot),
955        }
956    }
957}
958
959/// The source of [`Doc::identity`], one per document ever built.
960static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
961
962impl Doc {
963    #[cfg(feature = "fs")]
964    pub fn open(path: PathBuf) -> Result<Self> {
965        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
966        Self::from_disk_bytes(path, bytes)
967    }
968
969    /// An empty document *named* `path`, for a file that isn't there yet — what
970    /// every other terminal editor gives you when you name a file that doesn't
971    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
972    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
973    /// the header shows the name the user asked for.
974    ///
975    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
976    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
977    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
978    /// parse is still an error: a mistyped flag or a stray argument should say
979    /// so, not open a buffer promising to save somewhere.
980    ///
981    /// The watermark is the hash of *no bytes*, not `None`, and that is the
982    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
983    /// answering [`DiskState::Untitled`] for a document that has a path and
984    /// intends to write to it. Hashing `""` instead makes the answers the true
985    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
986    /// recreates it, which is exactly what this is for), and
987    /// [`DiskState::Changed`] if somebody creates it underneath us between
988    /// launch and save, so the frontend's overwrite prompt guards a new file as
989    /// it guards an opened one.
990    ///
991    /// Nothing is written here. A buffer that is never typed into never touches
992    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
993    /// open — the write is where that fails, and it says so then.
994    #[cfg(feature = "fs")]
995    pub fn create(path: PathBuf) -> Result<Self> {
996        Self::from_disk_bytes(path, Vec::new())
997    }
998
999    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1000    /// the call a CLI frontend wants for its path argument.
1001    ///
1002    /// The decision is made from the failed read itself rather than a `exists()`
1003    /// check first, so there is no window between the two for the file to appear
1004    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1005    /// a directory in the way is still an error, because pretending those are
1006    /// "no file yet" would offer to save over something leaf couldn't read.
1007    #[cfg(feature = "fs")]
1008    pub fn open_or_create(path: PathBuf) -> Result<Self> {
1009        match std::fs::read(&path) {
1010            Ok(bytes) => Self::from_disk_bytes(path, bytes),
1011            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1012            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1013        }
1014    }
1015
1016    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1017    /// stand in for) the file at `path`, parsed as the format its extension
1018    /// names. Keeping the two on one path is what makes a new file's document
1019    /// identical in every respect to an opened one but its contents.
1020    #[cfg(feature = "fs")]
1021    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1022        let format = detect_format(&path)?;
1023        let editor = new_editor(&bytes, format)?;
1024        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1025        let disk_hash = Some(hash_bytes(source.as_bytes()));
1026        // Store the document's *absolute* path. A relative one (`leaf README.md`)
1027        // has an empty parent, so a frontend can't resolve a relative image
1028        // destination (`![](pic.png)`) against the document's directory and the
1029        // picture silently falls back to its text placeholder. `absolute` is
1030        // purely lexical — it prefixes the current directory and normalizes, but
1031        // reads nothing and resolves no symlinks — so `file_name` and save are
1032        // unchanged; it only gives `path.parent()` something to join against.
1033        let path = std::path::absolute(&path).unwrap_or(path);
1034        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1035    }
1036
1037    /// Build a document from an in-memory string, the format named explicitly —
1038    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1039    /// path and sniffs the format from its extension). A wasm or FFI host, which
1040    /// has no path to read, uses this: it hands over bytes it fetched however it
1041    /// could, and later persists [`Doc::source`] however it can (a browser
1042    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1043    ///
1044    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1045    /// true) exactly like a [`Doc::blank`] that has been given content.
1046    pub fn from_source(source: String, format: Format) -> Result<Self> {
1047        let editor = new_editor(source.as_bytes(), format)?;
1048        Ok(Doc::from_parts(
1049            editor,
1050            format,
1051            PathBuf::new(),
1052            source,
1053            None,
1054        ))
1055    }
1056
1057    /// An untitled, empty document — the `+` button and a `leaf` launched with
1058    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1059    ///
1060    /// It is Markdown, because a format has to be chosen before a name exists to
1061    /// read one from: `detect_format` reads the extension and an untitled
1062    /// document has neither. Markdown is what leaf's own files are, what its
1063    /// block markers are already written for (`insert_block_prefix`), and the
1064    /// extension a Save As will overwhelmingly pick — a wrong guess here would
1065    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1066    /// *doesn't* revisit this: see [`Doc::save_as`].
1067    pub fn blank() -> Result<Self> {
1068        let format = Format::Markdown;
1069        let editor = new_editor(b"", format)?;
1070        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1071        // field two frontends already read; making it an `Option` to say this
1072        // would break both). `is_untitled` is the question to ask, not the
1073        // representation to copy.
1074        Ok(Doc::from_parts(
1075            editor,
1076            format,
1077            PathBuf::new(),
1078            String::new(),
1079            None,
1080        ))
1081    }
1082
1083    /// The fields every constructor agrees on, so `open` and `blank` can't drift
1084    /// apart in the ones neither of them has an opinion about.
1085    // `identity` is taken from a counter rather than from the `Doc`'s address,
1086    // which moves — a session that holds one is moved into and out of
1087    // containers freely, and an identity that changed with it would defeat the
1088    // one comparison it exists for.
1089    fn from_parts(
1090        editor: Editor,
1091        format: Format,
1092        path: PathBuf,
1093        source: String,
1094        disk_hash: Option<u64>,
1095    ) -> Self {
1096        Doc {
1097            editor,
1098            format,
1099            path,
1100            disk_hash,
1101            clean_source: source.clone(),
1102            source,
1103            caret: 0,
1104            anchor: None,
1105            dirty: false,
1106            status: None,
1107            read_only: false,
1108            highlights: Vec::new(),
1109            // leaf opens in the rich-text (WYSIWYG) view by default — the
1110            // markup-resolved surface is leaf's differentiator. Frontends can
1111            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1112            // toggles at runtime.
1113            view: View::Wysiwyg,
1114            // `None` by default — the clean surface Diaryx ships, with typed
1115            // syntax kept literal; a markup-fluent frontend can climb the
1116            // ladder to `Shortcuts` or `Full`.
1117            markup_mode: MarkupMode::default(),
1118            // Fold by default — flowing prose that reflows to the viewport, the
1119            // behaviour every frontend had before this preference existed.
1120            line_flow: LineFlow::default(),
1121            last_edit_kind: None,
1122            pending_marks: InlineMarks::empty(),
1123            pending_at: None,
1124            goal_col: None,
1125            vmap: VisualMap::default(),
1126            smap: SourceMap::default(),
1127            // No map yet — the first `build_source` always builds.
1128            smap_key: None,
1129            revision: 0,
1130            undo_steps: 0,
1131            redo_steps: 0,
1132            // No map yet — the first `build_visual` always builds.
1133            vmap_key: None,
1134            identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1135            block_cache: wysiwyg::BlockCache::default(),
1136            surface: wysiwyg::Surface::default(),
1137            scroll: 0,
1138            body_origin: (0, 0),
1139            body_width: 0,
1140            body_height: 0,
1141            drawn_caret: None,
1142        }
1143    }
1144
1145    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1146    /// has never been saved. The question a ⌘S handler asks to know it should
1147    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1148    /// header asks to know the name it shows is a placeholder.
1149    pub fn is_untitled(&self) -> bool {
1150        self.path.as_os_str().is_empty()
1151    }
1152
1153    pub fn toggle_view(&mut self) {
1154        self.view = match self.view {
1155            View::Source => View::Wysiwyg,
1156            View::Wysiwyg => View::Source,
1157        };
1158        self.scroll = 0;
1159        self.status = None;
1160        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1161        // lift it to the first rendered offset.
1162        self.clamp_caret();
1163    }
1164
1165    /// The current markup-exposure preference (see [`MarkupMode`]).
1166    pub fn markup_mode(&self) -> MarkupMode {
1167        self.markup_mode
1168    }
1169
1170    /// Set the markup-exposure preference. Both of its axes take effect at
1171    /// once: the editing one on the next [`insert`](Self::insert), and the
1172    /// rendering one on the next build — which is why this drops the cached
1173    /// visual map and the per-block render cache, exactly as
1174    /// [`set_line_flow`](Self::set_line_flow) does.
1175    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1176        if self.markup_mode == mode {
1177            return;
1178        }
1179        self.markup_mode = mode;
1180        // Neither cache is keyed on the mode, and moving between `Full` and the
1181        // hidden modes changes every row the caret's line renders to — so
1182        // invalidate both explicitly.
1183        self.vmap_key = None;
1184        self.block_cache = wysiwyg::BlockCache::default();
1185    }
1186
1187    /// The line the caret sits on, when that line should render something
1188    /// raw — `None` when nothing on it would, which is what the builder reads
1189    /// as "reveal nothing" and what keeps caret motion from costing a build.
1190    ///
1191    /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1192    /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1193    /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1194    /// formula's content is not its picture and hiding the `$` alone would
1195    /// leave nothing to edit; there the line is threaded through only when it
1196    /// meets a block that holds one, which the last build's layout knows
1197    /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1198    /// keeps the `None` it always had, and one with math pays a rebuild only
1199    /// while the caret is in the formula's block.
1200    ///
1201    /// A *source* line (newline to newline), not a visual row: a wrapped
1202    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1203    /// line across several rows, and revealing half a delimiter pair because the
1204    /// other half wrapped would be worse than revealing neither. The range
1205    /// excludes the terminating newline and is empty-but-present on a blank
1206    /// line, which reveals nothing but still keys the caches correctly.
1207    ///
1208    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1209    /// there is nothing there to reveal.
1210    pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1211        if self.view != View::Wysiwyg {
1212            return None;
1213        }
1214        let line = source_line_range(&self.source, self.caret);
1215        if self.markup_mode.reveals_caret_line() {
1216            return Some(Reveal::full(line));
1217        }
1218        self.block_cache
1219            .math_meets(&line)
1220            .then_some(Reveal::math(line))
1221    }
1222
1223    /// The current soft-break flow preference (see [`LineFlow`]).
1224    pub fn line_flow(&self) -> LineFlow {
1225        self.line_flow
1226    }
1227
1228    /// Set the soft-break flow preference. The mode changes how every block lays
1229    /// out, so a change drops the cached visual map and the per-block render
1230    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1231    ///
1232    /// [`build_visual`]: Self::build_visual
1233    pub fn set_line_flow(&mut self, mode: LineFlow) {
1234        if self.line_flow == mode {
1235            return;
1236        }
1237        self.line_flow = mode;
1238        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1239        // so invalidate them explicitly, or the next build would reuse rows laid
1240        // out under the old flow.
1241        self.vmap_key = None;
1242        self.block_cache = wysiwyg::BlockCache::default();
1243    }
1244
1245    pub fn view_name(&self) -> &'static str {
1246        match self.view {
1247            View::Source => "source",
1248            View::Wysiwyg => "wysiwyg",
1249        }
1250    }
1251
1252    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1253    /// (called by the renderer each frame it's in the WYSIWYG view).
1254    /// Build the WYSIWYG map, wrapped at `width` display columns.
1255    ///
1256    /// Cheap to call every frame, which is what both frontends do: the map is a
1257    /// pure function of the document and the wrap width, so a call that would
1258    /// rebuild the same map returns the one already built. Only an edit (or a
1259    /// resize) pays.
1260    ///
1261    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1262    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1263    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1264    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1265    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1266    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1267    pub fn build_visual(&mut self, width: usize) {
1268        self.build_map(Some(width));
1269    }
1270
1271    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1272    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1273    /// than a fixed character column.
1274    pub fn build_visual_unwrapped(&mut self) {
1275        self.build_map(None);
1276    }
1277
1278    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1279    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1280    /// styling for [`View::Wysiwyg`].
1281    ///
1282    /// A frontend calls this before painting raw source. One that doesn't gets
1283    /// an empty map and paints unstyled text, so this is additive: nothing
1284    /// breaks by not calling it.
1285    ///
1286    /// Built at most once per revision, and the revision is the whole key — the
1287    /// map has no width and no caret in it, so it survives every resize, every
1288    /// motion, and every selection change.
1289    ///
1290    /// The builds it does do cost a whole-arena marshal, which is precisely what
1291    /// the WYSIWYG path works to avoid, so this has no incremental path where
1292    /// that one has two. From `cargo run --release -p leaf-core --example
1293    /// bench`, per keystroke, against the WYSIWYG build the source view is
1294    /// *not* doing:
1295    ///
1296    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1297    /// |------:|-------:|--------:|----------------:|-------------------:|
1298    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1299    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1300    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1301    ///
1302    /// Linear, two thirds of it the marshal, and the build itself five to seven
1303    /// times cheaper than the one it stands in for at every size. Comfortable
1304    /// well past any document a person edits in a terminal — a megabyte is where
1305    /// it would want [`Editor::dirty_range`] and the same splice treatment
1306    /// `build_spliced` gives the other map. The door is open; nothing has needed
1307    /// it yet.
1308    pub fn build_source(&mut self) {
1309        if self.smap_key == Some(self.revision) {
1310            return;
1311        }
1312        let nodes = self.nodes();
1313        self.smap = source::build(&nodes, &self.source);
1314        self.smap_key = Some(self.revision);
1315    }
1316
1317    /// Tell the model how many visual rows each block image should reserve, keyed
1318    /// by the image's destination. A terminal frontend calls this once it has
1319    /// decoded and measured its pictures — core does no image I/O, so this is the
1320    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1321    /// placeholder out that tall (the label row plus blank filler rows the
1322    /// frontend paints the raster over). A destination left out of the map falls
1323    /// back to the bare one-row placeholder, which is also what a frontend that
1324    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1325    /// by never calling this.
1326    ///
1327    /// Cheap to call every frame with the same map: only a *change* invalidates
1328    /// the built map (and the block-row cache, since a height isn't part of a
1329    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1330    /// no-op, so a frontend can just hand over its current measurements each frame.
1331    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1332        if self.surface.media_rows == rows {
1333            return;
1334        }
1335        self.surface.media_rows = rows;
1336        self.surface_changed();
1337    }
1338
1339    /// Tell the model how many visual rows each display formula should
1340    /// reserve, keyed by the formula's TeX exactly as the map's
1341    /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1342    /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1343    /// typesets the picture, measures it in cells, and reports back; a
1344    /// frontend that lays formulas out in pixels never calls this and gets
1345    /// the one-row placeholder to paint over.
1346    pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1347        if self.surface.math_rows == rows {
1348            return;
1349        }
1350        self.surface.math_rows = rows;
1351        self.surface_changed();
1352    }
1353
1354    /// Tell the model whether the frontend can paint a picture *inside* a line
1355    /// of text. When it can, an inline formula renders to one atom glyph the
1356    /// frontend draws its typeset picture over — see
1357    /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1358    /// the code-styled TeX it always showed. Off until a frontend says
1359    /// otherwise, so a host that has not caught up sees what it saw.
1360    pub fn set_inline_pictures(&mut self, on: bool) {
1361        if self.surface.inline_pictures == on {
1362            return;
1363        }
1364        self.surface.inline_pictures = on;
1365        self.surface_changed();
1366    }
1367
1368    /// A height or a capability lives outside a block's source bytes, so the
1369    /// content-keyed block cache would hand back the old rows on a hit. Drop
1370    /// it (and the splice layout it carries) so the next build re-renders
1371    /// every block against the new surface, and force that build by clearing
1372    /// the map key.
1373    fn surface_changed(&mut self) {
1374        self.block_cache = wysiwyg::BlockCache::default();
1375        self.vmap_key = None;
1376    }
1377
1378    /// The revision the document's text is at — bumped by every edit, undo,
1379    /// redo, and reload, and by nothing else. A frontend caches against this to
1380    /// tell a repaint that needs new work from one that doesn't.
1381    ///
1382    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1383    /// lands on the same text two revisions later. Work is only ever rebuilt
1384    /// needlessly, never wrongly reused.
1385    pub fn revision(&self) -> u64 {
1386        self.revision
1387    }
1388
1389    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1390    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1391    /// unbuilt map before the first one.
1392    ///
1393    /// This is *not* [`revision`](Self::revision). The revision says where the
1394    /// text is; this says where the map is, and the two part company the moment
1395    /// an edit lands, until something rebuilds. A frontend that keeps its own
1396    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1397    /// under an oversized heading — compares this against the value it held when
1398    /// it took the copy, and learns whether `vmap` is still the map it stashed
1399    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1400    /// map would paint a stale document; restoring nothing hands core's
1401    /// incremental rebuild a map it never built.
1402    ///
1403    /// "Somebody else" includes another document. The key names the `Doc`
1404    /// as well as the build, so a frontend that draws two documents through
1405    /// one stash — a host with several buffers, or one that opens the next
1406    /// document where the last one stood — never has the copy it took of one
1407    /// accepted by the other, however alike their builds are.
1408    pub fn visual_key(&self) -> VisualKey {
1409        VisualKey(self.identity, self.vmap_key.clone())
1410    }
1411
1412    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1413    /// runs on every call: the caret moves without the document changing, and
1414    /// keeping it on a legal stop is this function's job either way.
1415    fn build_map(&mut self, wrap: Option<usize>) {
1416        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1417        // as well as the text, so the line joins the key: moving within a line
1418        // still reuses the map, and crossing into another one rebuilds it. In
1419        // every other mode `reveal_line` is `None` and the key is what it was,
1420        // so caret motion goes on costing nothing.
1421        let reveal = self.reveal_line();
1422        let key = (self.revision, wrap, reveal.clone());
1423        if self.vmap_key.as_ref() != Some(&key) {
1424            self.build_map_with(wrap, reveal);
1425            self.vmap_key = Some(key);
1426            // In a hidden mode the reveal line was decided from the *previous*
1427            // build's layout, whose spans are stale across an edit: the
1428            // keystroke that closes a new `$…$` on the caret's line asked "is
1429            // there math here?" of a layout that had none, and the formula
1430            // would snap to its picture under the caret until the next
1431            // motion. Ask again of the layout just built, and go once more if
1432            // the answer moved. Between edits the first answer is exact and
1433            // this is one comparison.
1434            let again = self.reveal_line();
1435            if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1436                self.build_map_with(wrap, again.clone());
1437                self.vmap_key = Some((self.revision, wrap, again));
1438            }
1439        }
1440        self.clamp_caret();
1441    }
1442
1443    /// One build of the map at `wrap` under `reveal`, incremental where it can
1444    /// be — the body of [`build_map`](Self::build_map), which decides whether
1445    /// to call it.
1446    fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1447        {
1448            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1449            // A subtree is pulled only for the block(s) that actually changed, so
1450            // the FFI marshal shrinks from O(document) to O(edited block).
1451            let top = self.top_blocks();
1452
1453            // Fast path: when twig reports a dirty byte range, try to patch the
1454            // previous map in place — a single-block edit moves the prefix,
1455            // shifts the suffix, and re-renders only one block. `build_spliced`
1456            // returns `None` (and we fall back to the always-correct full rebuild)
1457            // whenever the edit reshaped the block structure, hit a table, or
1458            // there's no previous map to patch.
1459            // Preserve soft breaks as written when the flow preference asks for
1460            // it — the builder renders each as its own visual row instead of
1461            // folding it into the reflowed paragraph.
1462            let preserve_soft = self.line_flow == LineFlow::Preserve;
1463            let spliced = match self.editor.dirty_range() {
1464                Some(dirty) => {
1465                    let prev = std::mem::take(&mut self.vmap);
1466                    let source = &self.source;
1467                    let cache = &mut self.block_cache;
1468                    let surface = &self.surface;
1469                    let editor = &mut self.editor;
1470                    wysiwyg::build_spliced(
1471                        prev,
1472                        source,
1473                        wrap,
1474                        preserve_soft,
1475                        &top,
1476                        dirty,
1477                        surface,
1478                        reveal.clone(),
1479                        cache,
1480                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1481                    )
1482                }
1483                None => None,
1484            };
1485            self.vmap = spliced.unwrap_or_else(|| {
1486                let source = &self.source;
1487                let cache = &mut self.block_cache;
1488                let surface = &self.surface;
1489                let editor = &mut self.editor;
1490                wysiwyg::build_cached(
1491                    &top,
1492                    source,
1493                    wrap,
1494                    preserve_soft,
1495                    surface,
1496                    reveal,
1497                    cache,
1498                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1499                )
1500            });
1501            // Acknowledge the dirty range so the next edit's range starts fresh.
1502            self.editor.clear_dirty();
1503        }
1504    }
1505
1506    fn nodes(&mut self) -> Vec<FlatNode> {
1507        self.editor.nodes().unwrap_or_default()
1508    }
1509
1510    /// The document's top-level blocks for the incremental render. See
1511    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1512    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1513        wysiwyg::top_blocks(&mut self.editor)
1514    }
1515
1516    pub fn format_name(&self) -> &'static str {
1517        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1518        // required. It also covers `Asciidoc`, which twig parses but cannot
1519        // serialize — leaf never opens a document in it (see `Doc::open`).
1520        match self.format {
1521            Format::Djot => "djot",
1522            Format::Markdown => "markdown",
1523            Format::Xml => "xml",
1524            Format::Html => "html",
1525            _ => "unknown",
1526        }
1527    }
1528
1529    /// Whether this document's format offers *any* door in — `false` only for a
1530    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1531    /// a frontend may as well open the file read-only.
1532    ///
1533    /// This is a much weaker claim than the name suggests, and driving per-button
1534    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1535    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1536    /// while a heading, a quote, a list, a task box, a link and a code fence all
1537    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1538    /// [`supports`](Self::supports) — per control.
1539    pub fn authorable(&self) -> bool {
1540        self.format.is_authorable()
1541    }
1542
1543    /// Whether this document can spell `gesture`, which is twig's own answer
1544    /// rather than a copy of it: `Format::supports_with` reads the same
1545    /// `Syntax` table the `Editor` method consults before refusing, chosen by
1546    /// the very [`parse_extensions`] this document's editor reparses with — so
1547    /// what the toolbar offers and what the splice will accept are one table.
1548    ///
1549    /// It is a fact about the *document*, not about the caret. `true` does not
1550    /// promise the gesture succeeds where it is standing — a link over a table
1551    /// border still fails — only that it will not fail with
1552    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1553    /// will work here".
1554    pub fn supports(&self, gesture: Gesture) -> bool {
1555        self.format.supports_with(parse_extensions(), gesture)
1556    }
1557
1558    /// Every control's enabled state in one read — what a toolbar builds itself
1559    /// from when a document opens or its format changes. See [`Capabilities`].
1560    pub fn capabilities(&self) -> Capabilities {
1561        Capabilities::of(self.format)
1562    }
1563
1564    /// Refuse a gesture this document's format cannot spell, saying so in the
1565    /// status line. `true` means the caller must return without calling twig.
1566    ///
1567    /// Most of these refusals duplicate one twig would make anyway, and they are
1568    /// made here regardless because a message naming the *document's* format
1569    /// reads better than one naming twig's internals. Two of them are not
1570    /// duplicates and are the reason this is a guard rather than an error
1571    /// translation:
1572    ///
1573    /// - The table family (see [`table_op`](Self::table_op)) consults no
1574    ///   `Syntax` table, so twig does not refuse it at all.
1575    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1576    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1577    ///   could not keep.
1578    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1579        self.refuse_unless(what, self.supports(gesture))
1580    }
1581
1582    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1583    /// answers itself — today only [`spells_pipe_tables`].
1584    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1585        if supported {
1586            return false;
1587        }
1588        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1589        true
1590    }
1591
1592    /// The name to show for this document. An untitled one has no file to name
1593    /// it, and both frontends put this straight on screen — an empty path
1594    /// renders as an empty header, so it says so instead.
1595    pub fn file_name(&self) -> String {
1596        if self.is_untitled() {
1597            return "untitled".into();
1598        }
1599        self.path
1600            .file_name()
1601            .map(|s| s.to_string_lossy().into_owned())
1602            .unwrap_or_else(|| self.path.display().to_string())
1603    }
1604
1605    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1606    /// caret and anchor coincide (an empty selection is no selection).
1607    pub fn selection(&self) -> Option<(usize, usize)> {
1608        self.anchor
1609            .map(|a| (a.min(self.caret), a.max(self.caret)))
1610            .filter(|(s, e)| s != e)
1611    }
1612
1613    /// The selected text, or `None` when there's no selection — the source
1614    /// slice a copy/cut hands to the system clipboard.
1615    pub fn selected_text(&self) -> Option<&str> {
1616        self.selection().map(|(s, e)| &self.source[s..e])
1617    }
1618
1619    /// The selection as a quote with a little of what surrounds it — the shape
1620    /// a host that cites, annotates, or searches for a passage wants, cut from
1621    /// the **source** rather than from anything rendered, so the quote is
1622    /// findable in the document again by plain string search.
1623    ///
1624    /// `context` is a count of characters (not bytes) on each side, clipped at
1625    /// the document's edges; the slices land on char boundaries by
1626    /// construction. `None` when nothing is selected.
1627    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1628        let (start, end) = self.selection()?;
1629        let mut before = start;
1630        for _ in 0..context {
1631            match self.source[..before].chars().next_back() {
1632                Some(c) => before -= c.len_utf8(),
1633                None => break,
1634            }
1635        }
1636        let mut after = end;
1637        for _ in 0..context {
1638            match self.source[after..].chars().next() {
1639                Some(c) => after += c.len_utf8(),
1640                None => break,
1641            }
1642        }
1643        Some(Quote {
1644            exact: self.source[start..end].to_string(),
1645            prefix: self.source[before..start].to_string(),
1646            suffix: self.source[end..after].to_string(),
1647            start,
1648            end,
1649        })
1650    }
1651
1652    /// Words, characters, and paragraphs over the whole document — the numbers
1653    /// a status bar or an inspector puts next to a piece of writing.
1654    ///
1655    /// Counted over the text a **reader** sees, not the markup that spells it:
1656    /// `**bold**` is one word and four characters, a link is its label and not
1657    /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1658    /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1659    /// not render at all — is not writing. [`crate::counts`] states the rules
1660    /// in full; [`TextCounts`] states them per field.
1661    ///
1662    /// The same numbers in both views. They have to be: a word count that fell
1663    /// when you pressed ⌘E would be telling you the view had changed, which
1664    /// you knew already. So this reads neither [`Doc::view`] nor the map the
1665    /// frontend last built — it renders the source afresh, unwrapped, with
1666    /// soft breaks folded and no line revealed, and counts that. A narrower
1667    /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1668    /// source view all give the identical answer, because none of them is an
1669    /// input.
1670    ///
1671    /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1672    /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1673    /// settle and wrong in a paint loop, so a frontend should ask when the
1674    /// typing stops rather than once a keystroke. Caching it against
1675    /// [`revision`](Self::revision) is the obvious next move if that is ever
1676    /// not enough; nothing has needed it yet.
1677    pub fn counts(&self) -> TextCounts {
1678        self.count_over(None)
1679    }
1680
1681    /// The same statistics over the selection alone — `None` when nothing is
1682    /// selected, since an empty selection is no selection.
1683    ///
1684    /// Same rules, over the same rendering, narrowed to the glyphs whose
1685    /// source byte falls inside [`selection`](Self::selection)'s range. A
1686    /// block the selection only clips still counts as one paragraph, and one
1687    /// it enters without catching a visible character counts as none — a
1688    /// selection that starts on a hidden `**` gains no paragraph from it.
1689    pub fn selection_counts(&self) -> Option<TextCounts> {
1690        let (start, end) = self.selection()?;
1691        Some(self.count_over(Some(start..end)))
1692    }
1693
1694    /// The rendering both counters tally, and the tally itself.
1695    ///
1696    /// A fresh parse rather than `self.editor`, because these take `&self` and
1697    /// twig's arena is reached through `&mut`. A document that will not
1698    /// reparse is a "cannot happen" — the source came out of an editor that
1699    /// had already accepted it — and answers zero rather than panicking in
1700    /// what is very likely a paint path.
1701    fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1702        let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1703            return TextCounts::default();
1704        };
1705        let Ok(nodes) = editor.nodes() else {
1706            return TextCounts::default();
1707        };
1708        // A surface that paints pictures in a line, so an inline formula is
1709        // an atom here and never its TeX: a formula is a picture to a reader
1710        // whichever way it is written, and the count says so consistently.
1711        let surface = wysiwyg::Surface {
1712            inline_pictures: true,
1713            ..Default::default()
1714        };
1715        let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1716        counts::tally(&map, range)
1717    }
1718
1719    /// Whether the document refuses to change — see the field.
1720    pub fn read_only(&self) -> bool {
1721        self.read_only
1722    }
1723
1724    /// Turn the read-only gate on or off. A frontend preference like
1725    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1726    /// itself changes, only what may be done to it from here on.
1727    pub fn set_read_only(&mut self, on: bool) {
1728        self.read_only = on;
1729    }
1730
1731    /// The host-painted ranges, sorted by start — see [`Highlight`].
1732    pub fn highlights(&self) -> &[Highlight] {
1733        &self.highlights
1734    }
1735
1736    /// Replace the host-painted ranges wholesale. The whole set each time,
1737    /// rather than add/remove verbs: the host owns the list (it derives it
1738    /// from its own state — annotations, search hits), and a replace can
1739    /// never leave the two disagreeing about what should be on screen.
1740    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1741        highlights.retain(|h| h.start < h.end);
1742        highlights.sort_by_key(|h| (h.start, h.end));
1743        self.highlights = highlights;
1744    }
1745
1746    /// The highlight covering source `offset`, if one does — first by start
1747    /// when several overlap, which makes overlapping washes resolvable rather
1748    /// than undefined. What a frontend asks when the reader activates a spot.
1749    ///
1750    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1751    /// asking the same question per glyph, against a slice they were handed
1752    /// rather than against a `Doc`, and one answer for both is what keeps a
1753    /// wash and an activation agreeing about which range a spot is in.
1754    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1755        Highlight::covering(&self.highlights, offset)
1756    }
1757
1758    /// The AST breadcrumb at the caret (root → deepest), e.g.
1759    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1760    pub fn breadcrumb(&mut self) -> String {
1761        match self.editor.ancestors_at(self.caret) {
1762            Ok(chain) => chain
1763                .iter()
1764                .map(|m| m.kind.as_str())
1765                .collect::<Vec<_>>()
1766                .join(" › "),
1767            Err(_) => String::new(),
1768        }
1769    }
1770
1771    // ── editing ──────────────────────────────────────────────────────────────
1772
1773    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1774    /// after it. The public form of the internal splice — a pixel frontend that
1775    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1776    /// edits through this, the same twig `edit_range` the caret ops use.
1777    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1778        self.splice(start, end, text, EditKind::Other);
1779    }
1780
1781    /// Insert typed `text` at the caret, replacing the selection if there is one.
1782    /// A single typed character coalesces with the run of typing before it; a
1783    /// newline or a multi-character insert is its own undo step.
1784    ///
1785    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1786    pub fn insert(&mut self, text: &str) {
1787        // The read-only gate, up front: the paths below reach twig by several
1788        // verbs, not all of them through the splice — see the field.
1789        if self.read_only {
1790            return;
1791        }
1792        // Typing against a block picture would dissolve it, and typing past a
1793        // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1794        // the text a paragraph first, so what the caret was standing beside
1795        // stays what it was.
1796        self.open_paragraph_at_block_edge(text);
1797        // Armed sticky marks (⌘b with no selection) turn the next typed text
1798        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1799        // the exception: it takes no mark of its own and keeps the delta armed
1800        // for the character behind it — see `insert_space_with_marks`.
1801        let pending = self.pending_here();
1802        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1803            if text.trim().is_empty() {
1804                self.insert_space_with_marks(self.caret, text, pending);
1805            } else {
1806                self.insert_with_marks(self.caret, text, pending);
1807            }
1808            return;
1809        }
1810        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1811        // anything that would open markup, so a Diaryx user never mints
1812        // formatting by keyboard (it comes from commands instead). The other two
1813        // rungs of the ladder author markup from what you type, which is the
1814        // whole difference between them and this one. Only in the rendered view
1815        // (source view is for typing raw markup) and only where the format has a
1816        // literal spelling at all: escaping is a backslash before a byte from the
1817        // format's own alphabet, and a format with no such alphabet (HTML escapes
1818        // with entities, XML spells nothing) would have `\&` written into it,
1819        // which is two literal characters and not an escape. Marks (⌘b) still
1820        // format — that path returned above; and leaf's own structural inserts go
1821        // through `insert_raw`, never here, so a list marker or quote gutter is
1822        // written as the markup it is.
1823        if !self.markup_mode.authors()
1824            && self.view == View::Wysiwyg
1825            && !text.is_empty()
1826            && self.supports(Gesture::InsertLiteral)
1827        {
1828            self.insert_literal_typed(text);
1829            return;
1830        }
1831        self.insert_raw(text);
1832    }
1833
1834    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1835    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1836    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1837    /// markup by design and must not be escaped.
1838    fn insert_raw(&mut self, text: &str) {
1839        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1840        self.splice(s, e, text, typed_edit_kind(text));
1841    }
1842
1843    /// Open a paragraph for text about to be inserted at one of a block media's
1844    /// two caret stops, or at a table's trailing stop, and leave the caret
1845    /// standing in it.
1846    ///
1847    /// A block image is a paragraph whose entire content is the picture, and the
1848    /// caret's only homes on it are in front of it and just past it (see
1849    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1850    /// *that* paragraph — and a paragraph holding anything besides the image is
1851    /// no longer a block image but a line of text with an inline one in it. The
1852    /// frontend that was painting a photo there paints a text run instead; the
1853    /// picture is still in the file, and nothing said a word. Those two offsets
1854    /// are also exactly where a click on the picture lands, so the whole accident
1855    /// is one tap and one keystroke.
1856    ///
1857    /// So the break goes in first and the text lands in the new empty paragraph —
1858    /// what pressing Return before typing would have done, which is a habit no
1859    /// one should have to learn from losing a photo. A no-op everywhere else, and
1860    /// over a selection (which is replaced, not joined into).
1861    ///
1862    /// A picture inside a quote or a list leaves its container, because `\n\n`
1863    /// ends the block. The alternative is worse: the `\n> ` / next-item
1864    /// continuation [`newline`](Self::newline) writes stays in the same
1865    /// *paragraph*, which is the thing being prevented.
1866    ///
1867    /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1868    /// accident from the other side of a different block: the stop sits at the
1869    /// end of the table's last source line, and a line glued under a table is
1870    /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1871    /// break goes in there too, and the text lands under the table.
1872    ///
1873    /// Only in the rendered view. Source view is for typing raw markup, where
1874    /// putting a character against an image is exactly what it looks like.
1875    fn open_paragraph_at_block_edge(&mut self, text: &str) {
1876        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1877            return;
1878        }
1879        if self.selection().is_some() {
1880            return;
1881        }
1882        // The map may be a revision behind (nothing has drawn since the last
1883        // edit), and this asks it about offsets — a stale answer would splice a
1884        // break into the wrong place. Free when it is already current, which it
1885        // is whenever a frontend drew a frame between keystrokes.
1886        self.rebuild_map();
1887        let at = self.caret;
1888        let side = match self.vmap.block_media_stop(at) {
1889            Some((side, _)) => side,
1890            None if self.vmap.table_end_stop(at) => MediaStop::After,
1891            None => return,
1892        };
1893        if !self.splice(at, at, "\n\n", EditKind::Other) {
1894            return;
1895        }
1896        // The break is part of the keystroke, not an edit of its own: leave the
1897        // run marked as typing so the character about to arrive folds into it and
1898        // one undo puts the document back the way it was found. (A paste, or a
1899        // multi-character insert, is `EditKind::Other` and stays its own step —
1900        // as it would have been anywhere else in the document.)
1901        self.last_edit_kind = Some(EditKind::Insert);
1902        if side == MediaStop::Before {
1903            // The break went in above the picture and the caret rode to the end
1904            // of it — which is still hard against the picture. Step back onto the
1905            // blank line it opened, so the text lands above rather than in front.
1906            self.caret = at;
1907        }
1908    }
1909
1910    /// A delete key pressed at one of a block picture's two caret stops, handled
1911    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1912    /// the key was consumed.
1913    ///
1914    /// The caret rests in front of a block image and just past it, never inside
1915    /// its markup — which the rendered view doesn't show. So the byte a delete
1916    /// key nominally takes there is one the writer cannot see, and taking it
1917    /// leaves the picture as broken markup rather than as anything anyone asked
1918    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1919    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1920    /// missing from a document with nobody having touched it — the same
1921    /// dissolution [`open_paragraph_at_block_edge`](Self::open_paragraph_at_block_edge)
1922    /// prevents from the typing side, and it cost this repository's own test vault
1923    /// a photo before it was found.
1924    ///
1925    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1926    /// when it is behind the caret, Delete when it is in front — which is what
1927    /// every editor does with an embed, and one undo away. The key aimed *away*
1928    /// from it would otherwise delete the paragraph break and merge a neighbour
1929    /// into the picture's own paragraph, which dissolves it just as surely; it
1930    /// steps the caret over the boundary instead and leaves the
1931    /// next press to delete in the block it has reached — the same "first press
1932    /// steps out of the atom, second press deletes" every delete key here gets,
1933    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1934    /// above, and reaches it on the second press rather than taking the break and
1935    /// the picture with it on the first).
1936    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1937        // The map answers about offsets, so it has to be this revision's — see
1938        // the same call in `open_paragraph_at_block_edge`.
1939        self.rebuild_map();
1940        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1941            return false;
1942        };
1943        let aimed_at_it = side
1944            == if forward {
1945                MediaStop::Before
1946            } else {
1947                MediaStop::After
1948            };
1949        if !aimed_at_it {
1950            let over = if forward {
1951                self.vmap.stop_after(self.caret)
1952            } else {
1953                self.vmap.stop_before(self.caret)
1954            };
1955            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1956                self.caret = off;
1957                self.anchor = None;
1958                self.goal_col = None;
1959            }
1960            return true;
1961        }
1962        // Take the break that held the picture apart from its neighbour with it,
1963        // so the delete doesn't leave a blank paragraph standing where the
1964        // picture was. The last arm is a picture that is the whole document.
1965        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1966            (span.start - 2, span.end)
1967        } else if self.source[span.end..].starts_with("\n\n") {
1968            (span.start, span.end + 2)
1969        } else {
1970            (span.start, span.end)
1971        };
1972        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1973        true
1974    }
1975
1976    /// The Hidden-mode typing path: replace any selection, then insert `text`
1977    /// escaped so it stays literal. When it replaces a selection the two edits
1978    /// fold into one undo step, so an overwrite undoes atomically (and restores
1979    /// the selection) exactly as a plain one does.
1980    fn insert_literal_typed(&mut self, text: &str) {
1981        let kind = typed_edit_kind(text);
1982        match self.selection() {
1983            Some((s, e)) => {
1984                if !self.splice(s, e, "", EditKind::Other) {
1985                    return;
1986                }
1987                // Typing over a whole marked run takes its delimiters with it
1988                // (the empty content couldn't hold them — see
1989                // `repair_mark_edges`) and leaves its marks armed at the caret.
1990                // The text taking the run's place inherits them, exactly as it
1991                // would have by landing inside a run that survived.
1992                let pending = self.pending_here();
1993                if !pending.is_empty() && !text.trim().is_empty() {
1994                    self.insert_with_marks(self.caret, text, pending);
1995                    return;
1996                }
1997                self.insert_literal_at(self.caret, text, kind, true);
1998            }
1999            None => {
2000                self.insert_literal_at(self.caret, text, kind, false);
2001            }
2002        }
2003    }
2004
2005    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2006    /// at a collapsed caret, but only while the caret still stands where they
2007    /// were armed and nothing is selected. Empty otherwise, so a stale delta
2008    /// never styles text it wasn't meant for.
2009    fn pending_here(&self) -> InlineMarks {
2010        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2011            self.pending_marks
2012        } else {
2013            InlineMarks::empty()
2014        }
2015    }
2016
2017    /// Drop the armed sticky marks — any caret motion, selection, or edit does
2018    /// this, so "start bold here" only ever applies at the exact spot it was
2019    /// asked for.
2020    fn clear_pending(&mut self) {
2021        self.pending_marks = InlineMarks::empty();
2022        self.pending_at = None;
2023    }
2024
2025    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2026    /// force is wrapped around the freshly typed text; a mark the caret already
2027    /// stands inside is *shed* — the text is inserted past the run's end so it
2028    /// lands unmarked ("type normally again"). The caret comes to rest inside any
2029    /// added runs, so continued typing inherits the marks with no re-wrapping,
2030    /// and the delta is cleared: the marks now live in the document, not here.
2031    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2032        let base = self.mark_spans_at(at);
2033        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2034        // Nothing to shed, and a run of exactly these marks standing just behind
2035        // the caret: carry on writing *that* run rather than opening a second
2036        // one beside it.
2037        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2038            return;
2039        }
2040        // Shed the marks we're turning off: step the insertion point past the
2041        // end of each run the caret sits in, so the new text falls outside it.
2042        let mut ins_at = at;
2043        for (kind, span) in &base {
2044            if marks.contains(*kind) {
2045                ins_at = ins_at.max(span.end);
2046            }
2047        }
2048        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2049            return;
2050        }
2051        // The plain splice inserted exactly `text` at `ins_at`; that byte range
2052        // is the content every added mark wraps.
2053        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2054        for kind in marks.iter() {
2055            if !base_set.contains(kind) {
2056                let (ncs, nce) = self.wrap_span(cs, ce, kind);
2057                cs = ncs;
2058                ce = nce;
2059            }
2060        }
2061        self.caret = ce.min(self.source.len());
2062        self.anchor = None;
2063        self.last_edit_kind = None;
2064        // Realised: the marks are in the document now, and the caret sits inside
2065        // them, so there is no delta left to carry. Arm nothing, but remember the
2066        // spot so a *further* toggle before typing starts a clean delta here.
2067        self.pending_marks = InlineMarks::empty();
2068        self.pending_at = Some(self.caret);
2069        self.clamp_caret();
2070        self.record_caret();
2071    }
2072
2073    /// Carry on the marked run just behind `at` — moving its closing delimiters
2074    /// out past the new text — instead of opening a second run of the same marks
2075    /// beside it. Returns whether it did.
2076    ///
2077    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2078    /// A space typed after a bold word steps the caret out of the run, because
2079    /// `**bold **` is not bold; the next character has to step back *in*, or the
2080    /// writer who typed one bold phrase is left with `**bold** **and**` — two
2081    /// runs that read the same to a reader but spell the file in a way nobody
2082    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2083    /// words it isn't marking), and the marks behind it must be exactly the ones
2084    /// armed — a run of *some* other kind is a neighbour, not this phrase.
2085    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2086        if text.is_empty() || text.trim() != text {
2087            return false;
2088        }
2089        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2090        // Walk in through the delimiters stacked at that point, innermost last:
2091        // `***both*** ` closes two runs with one `***`, and rejoining means
2092        // getting behind all of them.
2093        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2094        while let Some((kind, content_end)) = self
2095            .editor
2096            .ancestors_at(prev_boundary(&self.source, cut))
2097            .unwrap_or_default()
2098            .into_iter()
2099            .filter(|m| m.span.end == cut)
2100            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2101        {
2102            if content_end >= cut {
2103                break; // a mark with no closing delimiter to step behind
2104            }
2105            kinds.insert(kind);
2106            cut = content_end;
2107        }
2108        if cut == gap_at || kinds != marks {
2109            return false;
2110        }
2111        // Re-spell the tail: the gap, then the new text, then the delimiters that
2112        // used to close in front of them — read out of the document rather than
2113        // written from a table, so whatever twig spells them with is what moves.
2114        let tail = format!(
2115            "{}{text}{}",
2116            &self.source[gap_at..at],
2117            &self.source[cut..gap_at]
2118        );
2119        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2120            return false;
2121        }
2122        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2123        self.anchor = None;
2124        self.last_edit_kind = None;
2125        self.pending_marks = InlineMarks::empty();
2126        self.pending_at = Some(self.caret);
2127        self.clamp_caret();
2128        self.record_caret();
2129        true
2130    }
2131
2132    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2133    /// never itself wrapped: a mark around a space draws nothing a reader can
2134    /// see, and in Markdown and Djot it draws its own delimiters instead
2135    /// (`** **`). So the space goes in unmarked — outside any run the armed
2136    /// marks are shedding — and the marks stay armed for the character after it,
2137    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2138    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2139        let base = self.mark_spans_at(at);
2140        // What the *next* character carries: the armed delta resolved against the
2141        // marks in force here, which the space must not quietly drop.
2142        let want = base
2143            .iter()
2144            .map(|(k, _)| *k)
2145            .collect::<InlineMarks>()
2146            .xor(marks);
2147        let mut ins_at = at;
2148        for (kind, span) in &base {
2149            if marks.contains(*kind) {
2150                ins_at = ins_at.max(span.end);
2151            }
2152        }
2153        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2154            return;
2155        }
2156        self.rearm(want);
2157        self.record_caret();
2158    }
2159
2160    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2161    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2162    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2163    /// added split evenly around the content — half the growth on each side.
2164    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2165        // The read-only gate — this door reaches twig without the splice.
2166        if self.read_only {
2167            return (s, e);
2168        }
2169        match self.editor.toggle_inline(s, e, kind) {
2170            Ok(change) => {
2171                self.last_edit_kind = None;
2172                self.refresh();
2173                self.dirty = self.source != self.clean_source;
2174                let added = (change.new.end - change.new.start).saturating_sub(e - s);
2175                let half = added / 2;
2176                (change.new.start + half, change.new.end - half)
2177            }
2178            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2179            // rather than lose the keystroke.
2180            Err(e2) => {
2181                self.status = Some(format!("{kind:?}: {e2}"));
2182                (s, e)
2183            }
2184        }
2185    }
2186
2187    /// The safe offset to splice a block-level break at, given a caret that may
2188    /// sit exactly between an inline mark's content and its own closing
2189    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2190    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2191    /// with nothing following it on the line: the closing `**` renders no
2192    /// glyph of its own, so the caret's "end of line" offset lands right
2193    /// before it). Splicing a paragraph/list/quote break at `off` itself would
2194    /// sever the delimiter from its content, stranding it alone on the new
2195    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2196    /// nested marks closing at the same point (`**_x_**`) all clear together.
2197    /// A no-op everywhere else — mid-run, or past real trailing content, no
2198    /// mark's `content_span` ends exactly at `off`.
2199    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2200        let off = off.min(self.source.len());
2201        let runs = self.run_span_ids();
2202        self.editor
2203            .ancestors_at(off)
2204            .unwrap_or_default()
2205            .into_iter()
2206            .filter(|m| hides_delims(m, &runs))
2207            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2208            .map(|m| m.span.end)
2209            .max()
2210            .unwrap_or(off)
2211    }
2212
2213    /// The offset a *delete* aimed at the character before `off` should stop at,
2214    /// when `off` is the start of a run's text and the bytes behind it are that
2215    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2216    /// byte behind the caret at the start of a bold word is not a character the
2217    /// writer can see, let alone one they aimed Backspace at: taking it leaves
2218    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2219    /// delete steps over the whole delimiter to the visible character in front of
2220    /// it instead. Walks out to the *outermost* mark opening there, so
2221    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2222    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2223        let off = off.min(self.source.len());
2224        let runs = self.run_span_ids();
2225        self.editor
2226            .ancestors_at(off)
2227            .unwrap_or_default()
2228            .into_iter()
2229            .filter(|m| hides_delims(m, &runs))
2230            .filter(|m| {
2231                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2232            })
2233            .map(|m| m.span.start)
2234            .min()
2235            .unwrap_or(off)
2236    }
2237
2238    /// `off` moved *inside* the run whose closing delimiters end there — the
2239    /// other offset the rich view draws in the same place, since a `**` renders
2240    /// no glyph of its own. `**bold**` has a caret home on each side of its
2241    /// closing delimiter, one column apart on screen and eight bytes and a whole
2242    /// run apart in the file, and a plain ← lands on the outer one whenever a
2243    /// space follows the phrase. The inner one is what the writer is pointing at
2244    /// there: the end of their bold word. Walks in through every mark closing at
2245    /// that point, innermost last, so `***both***` lands inside both. A no-op
2246    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2247    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2248        let mut off = off.min(self.source.len());
2249        let runs = self.run_span_ids();
2250        loop {
2251            let inner = self
2252                .editor
2253                .ancestors_at(prev_boundary(&self.source, off))
2254                .unwrap_or_default()
2255                .into_iter()
2256                .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2257                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2258                .filter(|&end| end < off)
2259                .max();
2260            match inner {
2261                Some(end) => off = end,
2262                None => return off,
2263            }
2264        }
2265    }
2266
2267    /// The mirror at the opening edge: `off` moved inside the run whose
2268    /// delimiters *start* there, onto the first character of its text. See
2269    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2270    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2271        let mut off = off.min(self.source.len());
2272        let runs = self.run_span_ids();
2273        loop {
2274            let inner = self
2275                .editor
2276                .ancestors_at(off)
2277                .unwrap_or_default()
2278                .into_iter()
2279                .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2280                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2281                .filter(|&start| start > off)
2282                .min();
2283            match inner {
2284                Some(start) => off = start,
2285                None => return off,
2286            }
2287        }
2288    }
2289
2290    /// The ids of the document's attributed run spans — the inline
2291    /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2292    /// which sees an ancestor chain and so only a kind. Read once per gesture,
2293    /// not once per step of a walk.
2294    fn run_span_ids(&mut self) -> Vec<NodeId> {
2295        self.nodes()
2296            .iter()
2297            .filter(|n| wysiwyg::is_run_span(n))
2298            .map(|n| n.id)
2299            .collect()
2300    }
2301
2302    /// The attributed span whose text is exactly `content` — the whole of
2303    /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2304    /// and sits between the tags of `<span …></span>` — as the whole range
2305    /// spelling the span: the node's span, widened to its attribute block
2306    /// where the format writes that outside the node, as djot's
2307    /// `[i]{data-size="large"}` does. `None` for any other range, including
2308    /// part of a span's text.
2309    ///
2310    /// An empty span has an interior of no bytes, or no known interior at
2311    /// all: twig gives Markdown's `<span …></span>` the first and djot's
2312    /// `[]{…}` the second, and the chain already says the offset is inside.
2313    fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2314        let runs = self.run_span_ids();
2315        let m = self
2316            .editor
2317            .ancestors_at(content.start)
2318            .unwrap_or_default()
2319            .into_iter()
2320            .filter(|m| runs.contains(&NodeId(m.node_id)))
2321            .find(|m| match &m.content_span {
2322                Some(c) => *c == content,
2323                None => content.is_empty(),
2324            })?;
2325        let mut range = m.span;
2326        if let Some(attrs) = self
2327            .editor
2328            .document()
2329            .ok()
2330            .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2331        {
2332            range.start = range.start.min(attrs.start);
2333            range.end = range.end.max(attrs.end);
2334        }
2335        Some(range)
2336    }
2337
2338    /// The attributed block whose whole text is exactly `content` — the `T`
2339    /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2340    /// `{.center}\nT` — as the range a delete that takes that text takes with
2341    /// it: the whole `<div>` when the block is all the div holds, or the
2342    /// `{…}` line down to the end of the text. The block version of
2343    /// [`run_span_of_content`](Self::run_span_of_content), for the same
2344    /// reason: a paragraph with no text is no block, so the div would stand
2345    /// around nothing and the `{…}` line above nothing, and a from-scratch
2346    /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2347    /// `None` for a block with more text, a div holding more, a heading (an
2348    /// empty `# ` is still a heading), and a format whose attributes are the
2349    /// block's own tag (HTML's `<p class="center"></p>` is still a
2350    /// paragraph).
2351    fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2352        if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2353            return None;
2354        }
2355        let nodes = self.nodes();
2356        let block = nodes
2357            .iter()
2358            .filter(|n| n.kind == Kind::Para)
2359            .find(|n| n.content_span.as_ref() == Some(&content))?;
2360        match self.format {
2361            Format::Djot => {
2362                let attrs = self
2363                    .editor
2364                    .document()
2365                    .ok()
2366                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2367                (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2368            }
2369            _ => {
2370                let div = block
2371                    .parent
2372                    .and_then(|p| nodes.iter().find(|n| n.id == p))
2373                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2374                let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2375                alone.then(|| div.span.clone())
2376            }
2377        }
2378    }
2379
2380    /// The inline mark kinds whose span covers `off`, each with that span — the
2381    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2382    /// ids instead. Used to shed a mark by stepping past the end of its run.
2383    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2384        let off = off.min(self.source.len());
2385        self.editor
2386            .ancestors_at(off)
2387            .unwrap_or_default()
2388            .into_iter()
2389            .filter(|m| off < m.span.end)
2390            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2391            .collect()
2392    }
2393
2394    /// Insert clipboard `text` at the caret, replacing the selection if there is
2395    /// one — always its own undo step, whatever its length.
2396    ///
2397    /// Provenance is the whole point, and only the caller has it. `insert` reads
2398    /// a lone character as a keystroke and folds it into the run around it,
2399    /// which is right for typing and wrong for a one-character paste: that paste
2400    /// would vanish mid-run on an undo it was never part of, and the characters
2401    /// the user actually typed would go with it. Length can't tell the two
2402    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2403    /// caller comes through is what says which happened.
2404    pub fn paste(&mut self, text: &str) {
2405        // Pasting against a block picture or a table's end joins the block
2406        // exactly as typing does, and for the same reason — see
2407        // `open_paragraph_at_block_edge`.
2408        self.open_paragraph_at_block_edge(text);
2409        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2410        self.splice(s, e, text, EditKind::Other);
2411    }
2412
2413    /// Replace `[start, end)` with `text` as one step of an IME composition —
2414    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2415    /// folds into a single undo.
2416    ///
2417    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2418    /// dozen calls here, each replacing the last one's provisional bytes, and an
2419    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2420    /// unspools backwards through kana — the intermediate states were never text
2421    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2422    /// look like any other edit), so the door the caller comes through is what
2423    /// says so, exactly as it is for [`paste`](Self::paste) versus
2424    /// [`insert`](Self::insert).
2425    ///
2426    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2427    /// composition folds into this one.
2428    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2429        self.splice(start, end, text, EditKind::Compose);
2430    }
2431
2432    /// Close the open composition run, so the next one is its own undo step.
2433    /// Call when the IME commits or withdraws a composition.
2434    ///
2435    /// Only clears a *composition* run: a frontend that reports an end it never
2436    /// began (some IMEs unmark unprompted) would otherwise split the run of
2437    /// typing around it into two undo steps for no reason the user can see.
2438    pub fn end_composition(&mut self) {
2439        if self.last_edit_kind == Some(EditKind::Compose) {
2440            self.last_edit_kind = None;
2441        }
2442    }
2443
2444    // ── the clipboard's rich flavor ──────────────────────────────────────────
2445
2446    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2447    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2448    /// nothing is selected, or when the selection doesn't render (the caller
2449    /// still has [`selected_text`](Self::selected_text), which is what to publish
2450    /// as `text/plain` either way).
2451    ///
2452    /// **The fragment is a source substring, and that is the honest limit here.**
2453    /// It's parsed standalone, so a selection whose meaning depends on its
2454    /// surroundings converts as what it literally says rather than what it looks
2455    /// like on screen: half a list item is a paragraph, a row torn out of a table
2456    /// is the text of a row, the `**` of a bold run selected without its closing
2457    /// `**` is two asterisks. Every one of those still *renders* — there's no
2458    /// error to report — it just renders as the fragment and not as the document.
2459    /// Widening the range to whole blocks would publish text the user didn't
2460    /// select, which is a worse lie than a fragment being a fragment; the plain
2461    /// flavor has the same substring, so the two flavors at least agree.
2462    pub fn selection_html(&mut self) -> Option<String> {
2463        let (start, end) = self.selection()?;
2464        let inline = self.selection_is_inline(start, end);
2465        let html = html::render_fragment(&self.source[start..end], self.format)?;
2466        Some(match inline {
2467            true => html::strip_sole_paragraph(html),
2468            false => html,
2469        })
2470    }
2471
2472    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2473    /// format first. Its own undo step, like any [`paste`](Self::paste).
2474    ///
2475    /// Returns whether it landed. `false` means the HTML didn't convert to
2476    /// anything worth pasting — the caller should fall back to the plain flavor
2477    /// rather than treat it as an error. The `html` module has the full list of
2478    /// what that covers: a table twig won't build, markup it doesn't recognise,
2479    /// an empty result.
2480    pub fn paste_html(&mut self, html: &str) -> bool {
2481        match html::parse_fragment(html, self.format) {
2482            Some(source) => {
2483                self.paste(&source);
2484                true
2485            }
2486            None => false,
2487        }
2488    }
2489
2490    /// Does the selection live *inside* a single top-level block?
2491    ///
2492    /// The question [`selection_html`](Self::selection_html) needs and the
2493    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2494    /// whether the user selected one word of a sentence or a whole paragraph, and
2495    /// only the document knows which. Selecting a word and pasting into Docs
2496    /// should extend the line you paste into; selecting the paragraph should make
2497    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2498    /// is an artifact of standalone parsing), and one that covers a whole block —
2499    /// or spans two — keeps its structure.
2500    ///
2501    /// Reads the block from twig rather than guessing from the bytes:
2502    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2503    /// block containing an offset, and two ends inside the same one cannot have
2504    /// crossed a block boundary.
2505    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2506        // The last *character*, not `end - 1`: the selection's end is exclusive
2507        // and may sit mid-codepoint's-worth of bytes past the last char.
2508        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2509            return false;
2510        };
2511        let (Some(head), Some(tail)) =
2512            (self.top_block_span(start), self.top_block_span(start + off))
2513        else {
2514            return false;
2515        };
2516        head == tail && !(start <= head.start && end >= head.end)
2517    }
2518
2519    /// The byte span of the top-level block containing `offset`, or `None` at an
2520    /// offset that belongs to no block (the blank line between two of them).
2521    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2522        self.editor
2523            .ancestors_at(offset)
2524            .ok()?
2525            .get(1)
2526            .map(|m| m.span.clone())
2527    }
2528
2529    // ── indentation ──────────────────────────────────────────────────────────
2530
2531    /// One indent level.
2532    ///
2533    /// Two spaces, not the four both frontends type for Tab today, because in a
2534    /// markdown document four columns isn't a width — it's a *meaning*. Four
2535    /// spaces at the head of a line is markdown's indented-code-block marker, so
2536    /// one Tab on a paragraph would reparse it into code and style it as such;
2537    /// two cannot, and the line stays the prose it was. Two is also exactly
2538    /// where a `- ` bullet's content starts, so an indented line lands under its
2539    /// parent item's text instead of beside it — the column a list-aware indent
2540    /// has to hit anyway, which keeps this width from being relitigated later.
2541    const INDENT: &'static str = "  ";
2542
2543    /// Indent the selected lines — or the caret's line, with no selection — by
2544    /// one level (Tab).
2545    pub fn indent(&mut self) {
2546        self.reindent(true);
2547        // Nesting changes an ordered list's numbering (the nested item restarts,
2548        // its old siblings resume) — keep the source markers in step.
2549        self.renumber_here();
2550        // Nesting an empty `-` item under a text line reparses that text as a
2551        // setext heading; swap the dash for a `*` before it can (a no-op unless
2552        // the collapse actually happened).
2553        self.avoid_setext_collapse();
2554    }
2555
2556    /// Take one indent level back off the selected lines, or the caret's line
2557    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2558    ///
2559    /// A line with *less* than a full level gives back what it has rather than
2560    /// refusing: outdent's job is to walk a line left, and real documents — hand
2561    /// written, or reflowed by some other editor — are full of indentation that
2562    /// was never a clean multiple of anything. Refusing there would strand the
2563    /// line at a depth Shift+Tab couldn't undo.
2564    pub fn outdent(&mut self) {
2565        self.reindent(false);
2566        self.renumber_here();
2567    }
2568
2569    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2570    ///
2571    /// One splice across the whole line range, never one per line: a Tab is one
2572    /// thing the user did, so it has to be one undo step and one reparse. Per
2573    /// line, twig would reparse the document once per line and leave a stack of
2574    /// steps that Shift+⌘Z walks back one line at a time.
2575    fn reindent(&mut self, add: bool) {
2576        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2577        let start = source_line_range(&self.source, sel_start).start;
2578        let end = source_line_range(&self.source, sel_end).end;
2579        let region = self.source[start..end].to_string();
2580        let lines: Vec<&str> = region.split('\n').collect();
2581        // A blank line has no text to move, and padding it would leave nothing
2582        // but trailing whitespace — but Tab on a blank line *is* a request for
2583        // indentation to type into, so the skip only applies where the op has
2584        // other lines to do real work on.
2585        let skip_blank = add && lines.len() > 1;
2586
2587        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2588        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2589        let mut line_off = start;
2590        for (i, full) in lines.iter().enumerate() {
2591            if i > 0 {
2592                out.push('\n');
2593            }
2594            // A list item moves by having its whole leading prefix *replaced*,
2595            // never by having spaces pushed in front of the line. twig spells
2596            // both prefixes, so the quote markers, the parent's indent and an
2597            // ordered marker's extra column all come out right without leaf
2598            // measuring any of them — and a line that only looks like an item
2599            // (a Djot continuation) reports no marker and is left to the plain
2600            // path, where a Tab is just a Tab.
2601            let marker = self.list_marker_on_line(line_off);
2602            let own = marker
2603                .as_ref()
2604                .map(|m| m.marker_start - m.line_start)
2605                .unwrap_or(0);
2606            let delta = if add {
2607                if skip_blank && full.trim().is_empty() {
2608                    out.push_str(full);
2609                    0
2610                } else if marker.is_some() && self.first_item_of_list(line_off) {
2611                    // The first item of a list has no preceding sibling to nest
2612                    // under, so a Tab here can't spell a sub-list — twig would
2613                    // reparse the shoved-over marker as the same list, only
2614                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2615                    // item where it is, the way every list editor refuses to
2616                    // over-indent a list's first line.
2617                    out.push_str(full);
2618                    0
2619                } else if marker.is_some() {
2620                    // Nesting means standing where a *continuation* of this line
2621                    // would stand: past the parent's marker, inside its content
2622                    // column. That is `continuation_prefix`, less a checkbox.
2623                    let new = self.nesting_prefix_at(line_off);
2624                    let delta = new.len() as isize - own as isize;
2625                    out.push_str(&new);
2626                    out.push_str(&full[own..]);
2627                    delta
2628                } else {
2629                    out.push_str(Self::INDENT);
2630                    out.push_str(full);
2631                    Self::INDENT.len() as isize
2632                }
2633            } else if marker.is_some() {
2634                // Unnesting is the mirror: stand where the parent item's own
2635                // line starts, which drops exactly the level it contributed.
2636                let new = self.outdent_prefix_at(line_off);
2637                let delta = new.len() as isize - own as isize;
2638                out.push_str(&new);
2639                out.push_str(&full[own..]);
2640                delta
2641            } else {
2642                // A plain line gives back the ordinary step.
2643                let strip = outdent_width(full, Self::INDENT.len());
2644                out.push_str(&full[strip..]);
2645                -(strip as isize)
2646            };
2647            deltas.push(delta);
2648            line_off += full.len() + 1;
2649        }
2650        // Nothing to give back. Returning before the splice keeps an outdent at
2651        // column zero from spending an undo step on a document it never changed.
2652        if deltas.iter().all(|d| *d == 0) {
2653            return;
2654        }
2655
2656        // Every line's text keeps its offset *within the line*, so the caret is
2657        // remapped by its column, not by its byte offset — which the prefixes on
2658        // the lines above it have already invalidated.
2659        let remap = |off: usize| -> usize {
2660            let (mut old_ls, mut new_ls) = (start, start);
2661            for (line, delta) in lines.iter().zip(&deltas) {
2662                let old_le = old_ls + line.len();
2663                let new_len = (line.len() as isize + delta) as usize;
2664                if off <= old_le {
2665                    let col = (off - old_ls) as isize;
2666                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2667                }
2668                old_ls = old_le + 1;
2669                new_ls += new_len + 1;
2670            }
2671            start + out.len()
2672        };
2673        let placed = match self.selection() {
2674            // Keep the rewritten region selected, the way a container toggle
2675            // keeps its own: it leaves a second Tab aimed at the same lines
2676            // rather than at whatever the shifted offsets now happen to cover.
2677            Some(_) => (start + out.len(), Some(start)),
2678            None => (remap(self.caret), None),
2679        };
2680
2681        // A rolled-back splice leaves the old source in place, where every offset
2682        // computed above addresses text that was never written.
2683        if !self.splice(start, end, &out, EditKind::Other) {
2684            return;
2685        }
2686        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2687        // rewrite is the last line's end — nowhere the caret was. Place it, then
2688        // re-record the caret so this is the state redo restores, not the one
2689        // `splice` left behind from the `Change`.
2690        self.caret = placed.0.min(self.source.len());
2691        self.anchor = placed.1;
2692        self.clamp_caret();
2693        self.record_caret();
2694    }
2695
2696    /// The Enter key.
2697    ///
2698    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2699    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2700    /// caret is in decides what actually gets written.
2701    ///
2702    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2703    ///                            block at the caret and reopens its container
2704    ///   - list item            → likewise: the next item, its indent, quote
2705    ///                            prefix and `[ ]` box all reproduced by twig —
2706    ///                            except an *empty* item, which exits the list
2707    ///   - block quote          → likewise: a new paragraph inside the quote
2708    ///   - heading              → a new *paragraph*, not another heading
2709    ///   - code block           → a literal newline (stay in the block)
2710    ///   - blank line           → a literal newline (one Backspace undoes it)
2711    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2712    ///                            visible line
2713    ///
2714    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2715    /// and it is better at it: it drops the whitespace the caret was sitting in
2716    /// front of instead of stranding it at the head of the second half, and it
2717    /// knows continuations leaf's marker scan never covered — a checklist item
2718    /// continues as an *unchecked* checklist item rather than a plain bullet.
2719    ///
2720    /// The exceptions above are exceptions because `split_block` is either wrong
2721    /// there or refuses: parting a fence yields two fences with the code split
2722    /// between them, parting a heading yields a second heading where every editor
2723    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2724    /// table all report an error rather than a split.
2725    pub fn newline(&mut self) {
2726        if self.view == View::Source {
2727            self.insert_raw("\n");
2728            return;
2729        }
2730        // Enter over a selection replaces it with a paragraph break.
2731        if let Some((s, e)) = self.selection() {
2732            self.splice(s, e, "\n\n", EditKind::Other);
2733            return;
2734        }
2735        // A caret resting exactly between an inline mark's content and its own
2736        // closing delimiter (`**bold**` with nothing after it on the line —
2737        // the WYSIWYG caret's natural end-of-line position) must not splice a
2738        // block break there: every path below eventually does via
2739        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2740        // delimiter would strand it alone on the new line.
2741        self.caret = self.skip_trailing_close_delims(self.caret);
2742        // The block the caret is in. `block_offset_for_caret` nudges off a line
2743        // end (where the caret sits at the doc level); on a bare line (e.g. an
2744        // empty list item) fall back to the caret so the enclosing list/quote is
2745        // still visible in the ancestors.
2746        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2747        let kinds: Vec<Kind> = self
2748            .editor
2749            .ancestors_at(off)
2750            .map(|c| c.into_iter().map(|m| m.kind).collect())
2751            .unwrap_or_default();
2752        let has = |k: Kind| kinds.contains(&k);
2753
2754        if has(Kind::CodeBlock) {
2755            self.insert_raw("\n");
2756            return;
2757        }
2758        // An *empty* list item exits the list — the standard double-Enter — which
2759        // `split_block` reports as an error rather than a split (there is no
2760        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2761        // the AST gate — it answers from the tree, so a `- ` that reads as a
2762        // marker byte-for-byte but opens no item (a setext underline, a Djot
2763        // continuation line) never reaches here.
2764        if let Some(marker) = self.list_marker_on_line(self.caret)
2765            && self.item_is_empty(&marker)
2766        {
2767            self.exit_list(&marker);
2768            return;
2769        }
2770        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2771        // not another full paragraph break — so it moves down one line and one
2772        // Backspace undoes it, not two. (`split_block` errors here too.)
2773        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2774        let line_end = self.source[self.caret..]
2775            .find('\n')
2776            .map_or(self.source.len(), |i| self.caret + i);
2777        if self.source[line_start..line_end].trim().is_empty() {
2778            self.insert_raw("\n");
2779            return;
2780        }
2781        // In `Preserve` flow a soft break is a *visible* line the author means to
2782        // make, so Enter writes a single `\n` and typing continues the same
2783        // paragraph on the next line — the behaviour of an ordinary text editor.
2784        // A second Enter then lands on the blank line above and takes the
2785        // empty-line branch, so double-Enter still promotes to a full paragraph
2786        // break; and Backspace, which deletes a lone `\n` over a soft break,
2787        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2788        // render as an invisible space, so Enter keeps making the paragraph break
2789        // that actually shows.
2790        //
2791        // Only in running prose. A list or a quote has a continuation of its own
2792        // to write, and a `\n` there is not a soft line but a lost container.
2793        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2794        if self.line_flow == LineFlow::Preserve && !in_container {
2795            self.insert_raw("\n");
2796            return;
2797        }
2798        // A heading gets a *paragraph*, never a second heading: Enter at the end
2799        // of a title is how every editor is asked for the body under it, and
2800        // `split_block` would repeat the `#` instead. Whitespace at the split
2801        // point goes with the break rather than opening the new paragraph, which
2802        // is what `split_block` does everywhere else.
2803        if has(Kind::Heading) {
2804            let mut end = self.caret;
2805            while self.source.as_bytes().get(end) == Some(&b' ') {
2806                end += 1;
2807            }
2808            self.splice(self.caret, end, "\n\n", EditKind::Other);
2809            return;
2810        }
2811        self.split_block_here();
2812    }
2813
2814    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2815    /// the caret in the second half.
2816    ///
2817    /// twig reopens whatever the first half was inside of — the bullet with its
2818    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2819    /// reason this replaced the markup leaf used to spell from the line's bytes.
2820    /// It renumbers nothing, though: a new item mid-list is written with its
2821    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2822    /// behind it, folded into the same undo step.
2823    ///
2824    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2825    /// shape still moves the caret down rather than swallowing the keystroke.
2826    fn split_block_here(&mut self) {
2827        // The read-only gate — this door reaches twig without the splice.
2828        if self.read_only {
2829            return;
2830        }
2831        match self.editor.split_block(self.caret) {
2832            Ok(change) => {
2833                self.last_edit_kind = None;
2834                self.refresh();
2835                self.anchor = None;
2836                self.caret = change.new.end;
2837                self.dirty = self.source != self.clean_source;
2838                self.status = None;
2839                self.clamp_caret();
2840                self.record_caret();
2841                // Aimed at the new block's *start*: the caret twig leaves is one
2842                // past the marker it wrote, where there is no list in reach.
2843                self.renumber_at(change.new.start);
2844            }
2845            Err(_) => self.insert_raw("\n\n"),
2846        }
2847    }
2848
2849    /// Whether the item on the marker's line carries no content — the shape
2850    /// double-Enter reads as "I'm done with this list."
2851    fn item_is_empty(&self, line: &ListMarker) -> bool {
2852        let content_start = line.content_start().min(self.source.len());
2853        let line_end = self.source[self.caret..]
2854            .find('\n')
2855            .map(|i| self.caret + i)
2856            .unwrap_or(self.source.len());
2857        self.source[content_start..line_end.max(content_start)]
2858            .trim()
2859            .is_empty()
2860    }
2861
2862    /// Leave the list: replace the empty item's marker with a blank line, so the
2863    /// caret lands in a fresh paragraph below it.
2864    ///
2865    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2866    /// quote), and the caret's new line keeps the `> ` it was already behind —
2867    /// leaving the list without also leaving the quote.
2868    fn exit_list(&mut self, line: &ListMarker) {
2869        let prefix = self.quote_prefix_at(line.marker_start);
2870        let blank = prefix.trim_end();
2871        self.splice(
2872            line.line_start,
2873            self.caret,
2874            &format!("{blank}\n{prefix}"),
2875            EditKind::Other,
2876        );
2877    }
2878
2879    /// What a line continuing the containers at `off` has to open with — the
2880    /// quote markers reproduced, each enclosing item's marker as its width in
2881    /// spaces. Also the column a nested item's marker stands in, which is what
2882    /// makes it Tab's answer.
2883    fn continuation_prefix_at(&mut self, off: usize) -> String {
2884        self.editor
2885            .document()
2886            .and_then(|mut d| d.continuation_prefix(off))
2887            .map(|p| p.text)
2888            .unwrap_or_default()
2889    }
2890
2891    /// The column a *nested list* may open at inside the item at `off` — which
2892    /// is not always where the item's own text continues.
2893    ///
2894    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2895    /// it is markup a rich view hides, and the item's own wrapped text does
2896    /// stand past it. But a nested list may only open at the *list* marker's
2897    /// column, and four columns further in is an indented continuation of the
2898    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2899    /// So the box's own width goes back.
2900    ///
2901    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2902    /// reports the list marker's column apart from the box; `checked` is what
2903    /// says a box is there at all, so only its width is being measured here.
2904    fn nesting_prefix_at(&mut self, off: usize) -> String {
2905        let cont = self.continuation_prefix_at(off);
2906        let Some(item) = self.innermost_list_item(off) else {
2907            return cont;
2908        };
2909        if item.checked.is_none() {
2910            return cont;
2911        }
2912        let box_width = item
2913            .marker_span
2914            .and_then(|m| self.source.get(m))
2915            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2916            .unwrap_or(0);
2917        // The trailing columns are the ones the item's own marker contributed,
2918        // so trimming from the end leaves any quote prefix standing.
2919        cont[..cont.len().saturating_sub(box_width)].to_string()
2920    }
2921
2922    /// Where the line of the item *containing* the item at `off` begins — the
2923    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2924    /// parent contributed. The quote prefix alone for a top-level item, which
2925    /// has no level left to give.
2926    fn outdent_prefix_at(&mut self, off: usize) -> String {
2927        let items: Vec<usize> = self
2928            .editor
2929            .document()
2930            .and_then(|mut d| d.ancestors_at_caret(off))
2931            .map(|c| {
2932                c.into_iter()
2933                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2934                    .map(|m| m.span.start)
2935                    .collect()
2936            })
2937            .unwrap_or_default();
2938        // The second-innermost item is the parent; its own line's indent is the
2939        // target. `list_marker_on_line` gives that line's prefix directly.
2940        let parent = items.len().checked_sub(2).map(|i| items[i]);
2941        match parent.and_then(|p| self.list_marker_on_line(p)) {
2942            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2943            None => self.quote_prefix_at(off),
2944        }
2945    }
2946
2947    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2948    /// inside one, `"> > "` inside two.
2949    ///
2950    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2951    /// the `>` and the space after it are twig's spelling rather than leaf's.
2952    /// The whole line prefix can't answer this: it also carries the indent of
2953    /// whatever the quote holds, which a blank separator line must *not* repeat.
2954    fn quote_prefix_at(&mut self, off: usize) -> String {
2955        let Ok(chain) = self
2956            .editor
2957            .document()
2958            .and_then(|mut d| d.ancestors_at_caret(off))
2959        else {
2960            return String::new();
2961        };
2962        let quotes: Vec<usize> = chain
2963            .iter()
2964            .filter(|m| m.kind == Kind::BlockQuote)
2965            .map(|m| m.node_id as usize)
2966            .collect();
2967        let Ok(nodes) = self.editor.nodes() else {
2968            return String::new();
2969        };
2970        quotes
2971            .iter()
2972            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2973            .filter_map(|s| self.source.get(s))
2974            .collect()
2975    }
2976
2977    /// Whether the item at `off` sits inside another one — the test Backspace
2978    /// uses to choose between outdenting and dropping the marker.
2979    ///
2980    /// Counted from the AST rather than from the line's leading whitespace,
2981    /// which is indentation in Markdown and, in Djot, may be nothing at all.
2982    fn item_is_nested(&mut self, off: usize) -> bool {
2983        self.editor
2984            .document()
2985            .and_then(|mut d| d.ancestors_at_caret(off))
2986            .map(|c| {
2987                c.into_iter()
2988                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2989                    .count()
2990                    > 1
2991            })
2992            .unwrap_or(false)
2993    }
2994
2995    /// The innermost list item containing `probe`, under twig's **caret**
2996    /// containment rule — a block's end is inside it.
2997    ///
2998    /// Half-open containment can't answer this. An empty item's span is exactly
2999    /// its marker, so the caret sitting after `- ` is one past the end and the
3000    /// item it is plainly in tests as out of reach; that is the shape
3001    /// double-Enter has to recognise to leave the list.
3002    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3003        let chain = self
3004            .editor
3005            .document()
3006            .and_then(|mut d| d.ancestors_at_caret(probe))
3007            .ok()?;
3008        let id = chain
3009            .iter()
3010            .rev()
3011            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3012            .node_id as usize;
3013        self.editor.nodes().ok()?.get(id).cloned()
3014    }
3015
3016    /// The list marker opening `off`'s line, per twig — `None` when that line
3017    /// opens no list item.
3018    ///
3019    /// [`Document::line_prefix`] is the whole hidden run from the line start:
3020    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
3021    /// and it is `None` on a *continuation* line, which opens nothing. That last
3022    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
3023    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3024    /// a paragraph and `  - b` is literal text — identical bytes, and only the
3025    /// parser knows which document it is looking at.
3026    ///
3027    /// The item's own marker is separated out via its
3028    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3029    /// the containers around it contribute and what the item does.
3030    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3031        let off = off.min(self.source.len());
3032        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3033        // The prefix belongs to a list only when an item's marker closes it —
3034        // a heading's `# ` or a bare quote's `> ` is a prefix too.
3035        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3036        let marker = item.marker_span.clone()?;
3037        if marker.end != prefix.end {
3038            return None;
3039        }
3040        Some(ListMarker {
3041            line_start: prefix.start,
3042            marker_start: marker.start,
3043            text: self.source.get(prefix)?.to_string(),
3044        })
3045    }
3046
3047    /// Whether the list item on `line_start`'s line is the **first item** of its
3048    /// list — the one Tab must not nest, because nesting needs a preceding
3049    /// sibling to become the new parent and a first item has none. `false` for a
3050    /// line that isn't a list item, and for an item with a sibling above it (the
3051    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3052    /// the same in a setext underline that opens no list at all.
3053    fn first_item_of_list(&mut self, line_start: usize) -> bool {
3054        let Some(marker) = self.list_marker_on_line(line_start) else {
3055            return false;
3056        };
3057        // Probe just inside the marker, where the item's own node is in reach —
3058        // the marker offset itself can resolve to the enclosing list, not the
3059        // `list_item`, whose span starts at the marker.
3060        let probe = marker.content_start().min(self.source.len());
3061        let Some(item) = self.innermost_list_item(probe) else {
3062            return false;
3063        };
3064        let Ok(nodes) = self.editor.nodes() else {
3065            return false;
3066        };
3067        match item.parent {
3068            // First when the parent list opens with this very item.
3069            Some(pid) => nodes
3070                .get(pid.0 as usize)
3071                .is_some_and(|p| p.first_child == Some(item.id)),
3072            // A parentless item is trivially the first (and only) one.
3073            None => true,
3074        }
3075    }
3076
3077    pub fn backspace(&mut self) {
3078        if let Some((s, e)) = self.selection() {
3079            self.splice(s, e, "", EditKind::Other);
3080            return;
3081        }
3082        // WYSIWYG: Backspace at the very start of a list item's content is a
3083        // structural key, not a character delete — it walks the "un-indent, then
3084        // un-list" ladder every list editor gives that keystroke (outdent a
3085        // nested item, strip a top-level one's marker to a paragraph). In source
3086        // view the `- ` is visible text the user is deleting a byte of, so it
3087        // keeps its literal meaning there, like Enter does.
3088        if self.view != View::Source && self.backspace_list_start() {
3089            return;
3090        }
3091        // WYSIWYG: and the same at the start of a heading's content — the `# `
3092        // there is markup the rich view hides, not text the user typed.
3093        if self.view != View::Source && self.backspace_heading_start() {
3094            return;
3095        }
3096        // WYSIWYG: and at the start of a block whose presentation is spelled
3097        // as hidden markup before it — djot's `{.center}` line, Markdown's
3098        // `<div class="center">` — Backspace takes that markup, the way it
3099        // takes a heading's `#`, rather than a byte out of it.
3100        if self.view != View::Source && self.backspace_attributed_block_start() {
3101            return;
3102        }
3103        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3104        // the markup apart under a caret that cannot see it — see
3105        // `delete_around_block_media`.
3106        if self.view != View::Source && self.delete_around_block_media(false) {
3107            return;
3108        }
3109        // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3110        // cell rather than taking the byte behind the caret — the row's closing
3111        // `|`, which the rich view never drew, so the key would have looked like
3112        // it did nothing. The stop before is the last cell's end.
3113        if self.view != View::Source && self.backspace_at_table_end() {
3114            return;
3115        }
3116        // WYSIWYG: at the start of a block's content, the byte behind the caret
3117        // is a block boundary, and Backspace over one is a join — twig's, so
3118        // that what a join is in each format is not this file's to know. After
3119        // the picture and table cases, which are block starts with their own
3120        // answers.
3121        if self.view != View::Source && self.backspace_joins_block() {
3122            return;
3123        }
3124        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3125        // stop, not a single newline. On a line with no text of its own, the byte
3126        // before the caret is a `\n` that spells part of a block boundary — the gap
3127        // between two blocks, drawn but never a caret home. Removing just it strands
3128        // the caret in that gap and leaves an odd blank line the eye reads as one
3129        // separator but the caret can't land on: the "extra newline" left behind
3130        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3131        // Deleting to the previous stop instead collapses the whole break at once,
3132        // landing the caret at the end of the block above. Two blank lines in a row
3133        // are one stop apart, so this still removes exactly one — the lone-Enter /
3134        // lone-Backspace symmetry the empty-line case is built on is untouched.
3135        if self.view != View::Source
3136            && self.caret > self.caret_floor()
3137            && self.caret_on_blank_line()
3138            && let Some(stop) = self.vmap.stop_before(self.caret)
3139        {
3140            let stop = stop.max(self.caret_floor());
3141            if stop < self.caret {
3142                if self.source[stop..self.caret].trim().is_empty() {
3143                    self.splice(stop, self.caret, "", EditKind::Delete);
3144                } else {
3145                    // Hidden markup stands between the stop and the caret — a
3146                    // `</div>`, a comment, a link reference definition — and
3147                    // collapsing to the stop would delete it. Take the blank
3148                    // line alone, with the newline that opened it, and land
3149                    // the caret where the collapse would have.
3150                    self.delete_blank_line_to(stop);
3151                }
3152                return;
3153            }
3154        }
3155        if self.caret > self.caret_floor() {
3156            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3157            // takes the whole tag — a single-byte step would leave a broken `<br`
3158            // showing in the cell. Rich view only (source view edits the literal).
3159            if self.view != View::Source
3160                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3161            {
3162                let start = start.max(self.caret_floor());
3163                if start < end {
3164                    self.splice(start, end, "", EditKind::Delete);
3165                    return;
3166                }
3167            }
3168            // Aim the delete at the character the writer can *see* behind the
3169            // caret, never at a delimiter the rich view drew nothing for. Two
3170            // steps, and either can apply: from the far side of a run's closing
3171            // `**` step back into the run (the caret is drawn at the end of its
3172            // word), and at the start of a run's text step out past its opening
3173            // `**` to the character in front of it, leaving the run standing.
3174            // Without them a plain Backspace unspells the phrase it is editing
3175            // and leaves a literal asterisk on screen.
3176            let end = if self.view == View::Source {
3177                self.caret
3178            } else {
3179                let inside = self.step_inside_close_delims(self.caret);
3180                // An attributed span with no text — `<span …></span>` as the
3181                // file was written — is hidden markup around nothing, and a
3182                // byte-step here would take its `>`. Backspace takes the span
3183                // whole, with the character before it: the character the key
3184                // looks aimed at, since the span draws nothing.
3185                if let Some(span) = self.run_span_of_content(inside..inside) {
3186                    let from = if self.source[..span.start].ends_with('\n') {
3187                        span.start
3188                    } else {
3189                        prev_boundary(&self.source, span.start)
3190                    };
3191                    let from = from.max(self.caret_floor());
3192                    self.splice(from, span.end, "", EditKind::Delete);
3193                    return;
3194                }
3195                self.skip_leading_open_delims(inside)
3196                    .max(self.caret_floor())
3197            };
3198            // Never delete back across the floor — that would eat hidden
3199            // frontmatter the WYSIWYG caret can't even see.
3200            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3201            // Take a hidden escape backslash with the char it escapes: the rich
3202            // view draws `\*` as a single `*`, so Backspace over it must delete
3203            // both bytes, never strand the `\` as a lone visible backslash (the
3204            // mirror of the Hidden-mode typing that wrote the escape). Source view
3205            // shows the `\`, so there it is an ordinary character.
3206            if self.view != View::Source
3207                && prev > self.caret_floor()
3208                && self.is_hidden_escape(prev - 1)
3209            {
3210                prev -= 1;
3211            }
3212            // The delete that takes the last of a span's text takes the span
3213            // with it, in the same edit: `<span …>i</span>` losing its `i`
3214            // would leave an empty span the map has no stop inside, so the
3215            // caret would draw at the next stop — a line away — until a
3216            // further key removed the span. Landing on the span's start is
3217            // where the letter was.
3218            if self.view != View::Source
3219                && let Some(span) = self.run_span_of_content(prev..end)
3220            {
3221                self.splice(span.start, span.end, "", EditKind::Delete);
3222                return;
3223            }
3224            // And the same for a block: the letter that was all of a centred
3225            // paragraph's text goes with the `<div>` around it, or the `{…}`
3226            // line above it, leaving a plain blank line where the letter was.
3227            // A paragraph with no text is no block, so the markup would stand
3228            // around nothing, the map would give it no caret home, and the
3229            // next key would take the tag apart.
3230            if self.view != View::Source
3231                && let Some(block) = self.attributed_block_of_content(prev..end)
3232            {
3233                self.splice(block.start, block.end, "", EditKind::Delete);
3234                return;
3235            }
3236            if prev < end {
3237                self.splice(prev, end, "", EditKind::Delete);
3238            }
3239        }
3240    }
3241
3242    /// Remove the blank line the caret is on — its own newline and the one
3243    /// that ended the line before it — and put the caret on `stop`, the caret
3244    /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3245    /// blank line that hidden markup separates from the block above: the
3246    /// navigable blank row after a `</div>` is always one of at least three
3247    /// newlines under the tag (the drawn separators either side of it), so
3248    /// taking two leaves the blank line the tag needs under it.
3249    fn delete_blank_line_to(&mut self, stop: usize) {
3250        let caret = self.caret;
3251        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3252        let line_end = self.source[caret..]
3253            .find('\n')
3254            .map_or(self.source.len(), |i| caret + i);
3255        let from = line_start.saturating_sub(1).max(stop);
3256        let to = (line_end + 1).min(self.source.len());
3257        self.splice(from, to, "", EditKind::Delete);
3258        self.caret = stop;
3259        self.anchor = None;
3260        self.goal_col = None;
3261        self.record_caret();
3262    }
3263
3264    /// Move the caret to the stop before it and consume the key — what
3265    /// Backspace does where the byte behind the caret is hidden markup it
3266    /// has no structural answer for, rather than take that markup apart.
3267    fn step_back_to_stop(&mut self) {
3268        // The map answers about offsets, so it has to be this revision's — see
3269        // `open_paragraph_at_block_edge`.
3270        self.rebuild_map();
3271        if let Some(off) = self
3272            .vmap
3273            .stop_before(self.caret)
3274            .filter(|&o| o >= self.caret_floor())
3275        {
3276            self.caret = off;
3277            self.anchor = None;
3278            self.goal_col = None;
3279        }
3280    }
3281
3282    /// Backspace's presentation behaviour: with the caret exactly at the start
3283    /// of a block's content, and that block's attributes spelled as hidden
3284    /// markup before it, strip the attributes. The peer of
3285    /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3286    /// reasoning: the `{.center}` line above a djot block and the
3287    /// `<div class="center">` around a Markdown one are what the byte behind
3288    /// the caret belongs to, and the rich view draws neither. The ordinary
3289    /// delete took the newline out of `{.center}\nhello` and left
3290    /// `{.center}hello` — the attribute line fused onto the text as prose —
3291    /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3292    ///
3293    /// The whole attribute set goes, the way the whole `#` marker does — the
3294    /// press is over the line that spells it, not over one key of it — and
3295    /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3296    /// djot line and unwraps the Markdown div. Where the block is the first of
3297    /// several in a div, twig has no sole child to unwrap and answers with a
3298    /// no-op, so the caret steps back to the stop before instead, as it does
3299    /// at a table's end. A later child of the div has an ordinary paragraph
3300    /// above it and is not this rule's.
3301    ///
3302    /// Returns whether it acted; `false` leaves Backspace its character delete.
3303    fn backspace_attributed_block_start(&mut self) -> bool {
3304        if !matches!(self.format, Format::Markdown | Format::Djot) {
3305            return false;
3306        }
3307        let caret = self.caret;
3308        let nodes = self.nodes();
3309        let Some(block) = nodes
3310            .iter()
3311            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3312            .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3313        else {
3314            return false;
3315        };
3316        match self.format {
3317            Format::Djot => {
3318                // twig records where the `{…}` block was written, so this is
3319                // the parser's own answer and not a scan for a `{` above the
3320                // block; `None` (a synthesized or merged set) is not a line
3321                // the caret is standing after.
3322                let spelled = self
3323                    .editor
3324                    .document()
3325                    .ok()
3326                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3327                    .is_some_and(|s| s.end <= caret);
3328                if !spelled {
3329                    return false;
3330                }
3331            }
3332            _ => {
3333                let Some(div) = block
3334                    .parent
3335                    .and_then(|p| nodes.iter().find(|n| n.id == p))
3336                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3337                else {
3338                    return false;
3339                };
3340                let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3341                if kids.clone().any(|k| k.span.start < block.span.start) {
3342                    return false;
3343                }
3344                if kids.nth(1).is_some() {
3345                    self.step_back_to_stop();
3346                    return true;
3347                }
3348            }
3349        }
3350        self.write_block_attrs("block attributes", Vec::new());
3351        true
3352    }
3353
3354    /// Backspace at the start of a block's content: join the block into the
3355    /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3356    /// the split Enter makes, spelled the format's way. Two paragraphs join
3357    /// on a soft break; a paragraph under a marker heading joins onto the
3358    /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3359    /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3360    /// taken as one; a quote's or an item's continuation prefix is written.
3361    /// The joined text takes the block above's presentation and containers,
3362    /// which is the rule every editor with a centred paragraph follows.
3363    ///
3364    /// Leaf used to join by deleting the one newline behind the caret, which
3365    /// is the right bytes for two Markdown paragraphs and nothing else: under
3366    /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3367    /// after a div it took the newline under the hidden `</div>`, which drew
3368    /// nothing different and took the tag apart on the next press. What a
3369    /// join is in each format is twig's to know, and now it does.
3370    ///
3371    /// Where twig refuses — the block above is a code block, a table or a
3372    /// rule with no text to join into, or the caret's block would have to
3373    /// leave a div that holds more after it — the caret steps back to the
3374    /// stop before instead, as it does at a table's end: the key moves the
3375    /// caret and takes no markup apart. Where nothing precedes the block, or
3376    /// the format cannot join at all, Backspace keeps its character delete.
3377    ///
3378    /// Returns whether it acted.
3379    fn backspace_joins_block(&mut self) -> bool {
3380        let caret = self.caret;
3381        if caret <= self.caret_floor() {
3382            return false;
3383        }
3384        let Some(text) = self.text_block_opening_at(caret) else {
3385            return false;
3386        };
3387        match self.join_blocks(caret) {
3388            Ok(change) => {
3389                // The caret keeps its place at the start of the text it stood
3390                // on, wherever the join put that text — after a soft break,
3391                // a space, or a quote's prefix. Found by the bytes, as
3392                // `block_content_in` finds a re-spelled block.
3393                let region = &self.source[change.new.clone()];
3394                let at = region
3395                    .find(&text)
3396                    .map_or(change.new.start, |i| change.new.start + i);
3397                self.land_after_join(at);
3398                true
3399            }
3400            Err(twig::Error::NotEditable) => {
3401                self.step_back_to_stop();
3402                true
3403            }
3404            Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3405            Err(e) => {
3406                self.status = Some(format!("join: {e}"));
3407                true
3408            }
3409        }
3410    }
3411
3412    /// Delete at the end of a block's content: join the block after it into
3413    /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3414    /// mirror, and the same twig gesture aimed at the next block. The caret
3415    /// stays where it was, which is where the joined text now begins after
3416    /// the separator. Where twig refuses, the caret steps forward to the next
3417    /// stop instead; where no block follows, Delete keeps its character
3418    /// delete.
3419    fn delete_forward_joins_block(&mut self) -> bool {
3420        let caret = self.caret;
3421        let at_end = self
3422            .nodes()
3423            .iter()
3424            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3425            .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3426        if !at_end {
3427            return false;
3428        }
3429        // The next stop, across a line end, in a block: what Delete at a
3430        // block's end points at. On the same line it is a hidden delimiter's
3431        // far side, which the ordinary delete handles; on a blank line it is
3432        // the empty paragraph the byte delete has always closed.
3433        self.rebuild_map();
3434        let Some(stop) = self.vmap.stop_after(caret) else {
3435            return false;
3436        };
3437        if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3438            return false;
3439        }
3440        match self.join_blocks(stop) {
3441            Ok(change) => {
3442                self.land_after_join(change.old.start);
3443                true
3444            }
3445            Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3446                self.caret = stop;
3447                self.anchor = None;
3448                self.goal_col = None;
3449                true
3450            }
3451            Err(twig::Error::UnsupportedFormat) => false,
3452            Err(e) => {
3453                self.status = Some(format!("join: {e}"));
3454                true
3455            }
3456        }
3457    }
3458
3459    /// The content bytes of the paragraph or heading whose content opens
3460    /// exactly at `off` — the block a Backspace there is at the start of.
3461    fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3462        self.nodes()
3463            .into_iter()
3464            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3465            .find_map(|n| {
3466                let c = n.content_span?;
3467                (c.start == off).then(|| self.source[c].to_string())
3468            })
3469    }
3470
3471    /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3472    /// plumbing every structural gesture has; the caret is the caller's to
3473    /// place from the change, via [`land_after_join`](Self::land_after_join).
3474    fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3475        if self.read_only {
3476            return Err(twig::Error::NotEditable);
3477        }
3478        self.record_caret();
3479        let change = self.editor.join_blocks(offset)?;
3480        self.last_edit_kind = None; // structural edit is its own undo step
3481        self.refresh();
3482        Ok(change)
3483    }
3484
3485    /// Finish a join: the caret at `at`, no selection, the map this
3486    /// revision's before the clamp — see `write_block_attrs` for why.
3487    fn land_after_join(&mut self, at: usize) {
3488        self.caret = at;
3489        self.anchor = None;
3490        self.goal_col = None;
3491        self.dirty = self.source != self.clean_source;
3492        self.status = None;
3493        self.rebuild_map();
3494        self.clamp_caret();
3495        self.record_caret();
3496    }
3497
3498    /// Backspace at a table's trailing stop: move onto the stop before it (the
3499    /// last cell's end) and consume the key. `false` anywhere else. See
3500    /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3501    /// not one to delete.
3502    fn backspace_at_table_end(&mut self) -> bool {
3503        // The map answers about offsets, so it has to be this revision's — see
3504        // `open_paragraph_at_block_edge`.
3505        self.rebuild_map();
3506        if !self.vmap.table_end_stop(self.caret) {
3507            return false;
3508        }
3509        if let Some(off) = self
3510            .vmap
3511            .stop_before(self.caret)
3512            .filter(|&o| o >= self.caret_floor())
3513        {
3514            self.caret = off;
3515            self.anchor = None;
3516            self.goal_col = None;
3517        }
3518        true
3519    }
3520
3521    /// Whether the caret's own source line holds nothing but whitespace — an
3522    /// empty paragraph, or the blank line a block boundary is spelled with. The
3523    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3524    /// no text of its own, so the newline before the caret belongs to the gap
3525    /// between blocks rather than to any word the caret is editing.
3526    fn caret_on_blank_line(&self) -> bool {
3527        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3528        let line_end = self.source[self.caret..]
3529            .find('\n')
3530            .map_or(self.source.len(), |i| self.caret + i);
3531        self.source[line_start..line_end].trim().is_empty()
3532    }
3533
3534    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3535    /// `edge` side — the byte range to delete whole. A table row is one source
3536    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3537    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3538    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3539    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3540    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3541    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
3542    /// apart — no ancestor walk needed. Rich view only; source view shows the
3543    /// literal tag and deletes it a byte at a time.
3544    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3545        let caret = self.caret;
3546        let nodes = self.nodes();
3547        let src = self.source.as_bytes();
3548        nodes
3549            .iter()
3550            .find(|n| {
3551                n.kind == Kind::HardBreak
3552                    && n.span.start < n.span.end
3553                    && src.get(n.span.start) == Some(&b'<')
3554                    && match edge {
3555                        BreakEdge::Backward => n.span.end == caret,
3556                        BreakEdge::Forward => n.span.start == caret,
3557                    }
3558            })
3559            .map(|n| (n.span.start, n.span.end))
3560    }
3561
3562    /// Whether the source byte at `off` is a backslash twig consumed as an escape
3563    /// (hidden in the rich view), as against a literal backslash (drawn). A
3564    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3565    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3566    /// round-trip needed.
3567    fn is_hidden_escape(&self, off: usize) -> bool {
3568        let b = self.source.as_bytes();
3569        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3570    }
3571
3572    /// Backspace's list behaviour: when the caret sits exactly at the start of a
3573    /// list item's content (right after its marker), outdent the item if it's
3574    /// nested, else strip the marker so it becomes a paragraph. Returns whether
3575    /// it acted — `false` leaves Backspace its ordinary character delete.
3576    fn backspace_list_start(&mut self) -> bool {
3577        let Some(marker) = self.list_marker_on_line(self.caret) else {
3578            return false;
3579        };
3580        // Only right after the marker. That the line opens a real item is
3581        // already settled: `list_marker_on_line` answers from the tree.
3582        if self.caret != marker.content_start() {
3583            return false;
3584        }
3585        if self.item_is_nested(marker.marker_start) {
3586            // Nested: give back one level, keeping the marker and carrying the
3587            // caret with it.
3588            self.outdent();
3589        } else {
3590            // Top level: drop the marker, leaving a paragraph, then renumber the
3591            // siblings the removed item was counted among. Only the marker goes —
3592            // a quote prefix in front of it still has a quote to hold up.
3593            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3594            self.renumber_here();
3595        }
3596        true
3597    }
3598
3599    /// Backspace's heading behaviour: with the caret exactly at the start of an
3600    /// ATX heading's content — right after the `#` marker the rich view hides —
3601    /// strip the marker so the line becomes a paragraph. The peer of
3602    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3603    /// reasoning: hidden block markup is structure, so the keystroke over it is
3604    /// structural.
3605    ///
3606    /// Without this the ordinary delete takes the space out of `# Title` and
3607    /// leaves `#Title`, which is no longer a heading at all — the hash the view
3608    /// had been hiding surfaces as literal text the user has to delete a second
3609    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3610    /// other end) goes with the marker for the same reason.
3611    ///
3612    /// Returns whether it acted; `false` leaves Backspace its character delete.
3613    fn backspace_heading_start(&mut self) -> bool {
3614        let caret = self.caret;
3615        // The heading whose content opens exactly at the caret. A bare `#` has no
3616        // content span at all — its content starts (and ends) where the line does.
3617        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3618            let (start, end) = match &n.content_span {
3619                Some(c) => (c.start, c.end),
3620                None => (n.span.end, n.span.end),
3621            };
3622            (n.kind == Kind::Heading && start == caret)
3623                .then(|| (n.span.clone(), end, n.marker_span.clone()))
3624        }) else {
3625            return false;
3626        };
3627        // twig reports the marker's own extent, so there is nothing to walk back
3628        // over and no `#` in this file. A setext heading has no marker — its
3629        // content opens the line — so it falls through to the ordinary delete,
3630        // as does anything else sitting at a content start.
3631        // `m.end == caret` is what excludes a setext heading, whose marker is the
3632        // underline *after* the content rather than a prefix before it.
3633        let Some(marker) = marker.filter(|m| m.end == caret) else {
3634            return false;
3635        };
3636        let start = marker.start;
3637        // A closing `#` sequence is hidden too, so it can't be left behind. Only
3638        // when the tail really is one: trailing spaces alone are nothing to strip.
3639        let tail = &self.source[content_end..span.end];
3640        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
3641            let kept = self.source[caret..content_end].to_string();
3642            self.splice(start, span.end, &kept, EditKind::Other);
3643            // The splice leaves the caret past the text it re-wrote; the caret
3644            // belongs where the content now starts, which is where it already was.
3645            self.caret = start;
3646            self.record_caret();
3647        } else {
3648            self.splice(start, caret, "", EditKind::Other);
3649        }
3650        true
3651    }
3652
3653    pub fn delete_forward(&mut self) {
3654        if let Some((s, e)) = self.selection() {
3655            self.splice(s, e, "", EditKind::Other);
3656        } else if self.caret < self.source.len() {
3657            // The mirror of Backspace's: forward-delete in front of a picture
3658            // would eat the `!` off its markup and leave a link where a photo was.
3659            if self.view != View::Source && self.delete_around_block_media(true) {
3660                return;
3661            }
3662            // And of Backspace's join: at the end of a block's content, Delete
3663            // joins the next block into this one.
3664            if self.view != View::Source && self.delete_forward_joins_block() {
3665                return;
3666            }
3667            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
3668            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
3669            // strands a broken `<br` in the cell.
3670            if self.view != View::Source
3671                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
3672            {
3673                self.splice(start, end, "", EditKind::Delete);
3674                return;
3675            }
3676            // The mirror of Backspace's two steps: from in front of a run's
3677            // opening `**` step into it, onto the first letter of its text, and
3678            // at the end of a run's text step out past its closing `**` to the
3679            // character beyond. Either way Delete takes the character it looks
3680            // like it is pointing at, and never a delimiter drawn as nothing.
3681            // The caret then settles back inside the run it was standing in —
3682            // see `settle_inside_close_delims`.
3683            let from = if self.view == View::Source {
3684                self.caret
3685            } else {
3686                let inside = self.step_inside_open_delims(self.caret);
3687                // The mirror of Backspace's empty-span rule: an attributed
3688                // span with no text goes whole, with the character after it.
3689                if let Some(span) = self.run_span_of_content(inside..inside) {
3690                    let to = if self.source[span.end..].starts_with('\n') {
3691                        span.end
3692                    } else {
3693                        next_boundary(&self.source, span.end)
3694                    };
3695                    self.splice(span.start, to, "", EditKind::Delete);
3696                    return;
3697                }
3698                self.skip_trailing_close_delims(inside)
3699            };
3700            let next = next_boundary(&self.source, from);
3701            // And of its emptying rules: the span goes with its last letter,
3702            // and so does the block's div or `{…}` line.
3703            if self.view != View::Source
3704                && let Some(span) = self.run_span_of_content(from..next)
3705            {
3706                self.splice(span.start, span.end, "", EditKind::Delete);
3707                return;
3708            }
3709            if self.view != View::Source
3710                && let Some(block) = self.attributed_block_of_content(from..next)
3711            {
3712                self.splice(block.start, block.end, "", EditKind::Delete);
3713                return;
3714            }
3715            if from < next {
3716                self.splice(from, next, "", EditKind::Delete);
3717            }
3718        }
3719    }
3720
3721    /// Delete from the caret back to the start of the previous word (⌥⌫ /
3722    /// Ctrl+⌫). Deletes the selection instead when one is active.
3723    pub fn delete_word_back(&mut self) {
3724        if let Some((s, e)) = self.selection() {
3725            self.splice(s, e, "", EditKind::Other);
3726        } else {
3727            // A word back from just past a picture is a word *of its markup*, and
3728            // a word back from in front of one runs through the paragraph break
3729            // into the prose above — dissolving the picture either way. See
3730            // `delete_around_block_media`.
3731            if self.view != View::Source && self.delete_around_block_media(false) {
3732                return;
3733            }
3734            let start = self.word_left_from(self.caret).max(self.caret_floor());
3735            if start < self.caret {
3736                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3737                self.splice(s, e, "", EditKind::Delete);
3738            }
3739        }
3740    }
3741
3742    /// Delete from the caret forward to the end of the next word (⌥⌦ /
3743    /// Ctrl+Del). Deletes the selection instead when one is active.
3744    pub fn delete_word_forward(&mut self) {
3745        if let Some((s, e)) = self.selection() {
3746            self.splice(s, e, "", EditKind::Other);
3747        } else {
3748            // The mirror: a word forward from in front of a picture is its markup.
3749            if self.view != View::Source && self.delete_around_block_media(true) {
3750                return;
3751            }
3752            let end = self.word_right_from(self.caret);
3753            if end > self.caret {
3754                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3755                self.splice(s, e, "", EditKind::Delete);
3756            }
3757        }
3758    }
3759
3760    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
3761    /// selection instead when one is active, as every other delete here does.
3762    ///
3763    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
3764    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
3765    /// stops at the first character and this takes the indentation with it, the
3766    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
3767    /// leave an indent behind that nothing can then ask to delete, where a caret
3768    /// left at column 0 is one press of Home away from either.
3769    pub fn delete_to_line_start(&mut self) {
3770        if let Some((s, e)) = self.selection() {
3771            self.splice(s, e, "", EditKind::Other);
3772            return;
3773        }
3774        // Never back across the floor: hidden frontmatter isn't on this line, or
3775        // on any line the WYSIWYG caret can see.
3776        let (start, _) = self.line_span();
3777        let start = start.max(self.caret_floor());
3778        if start < self.caret {
3779            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
3780            self.splice(s, e, "", EditKind::Delete);
3781        }
3782    }
3783
3784    /// Kill from the caret to the end of its line (^K). Deletes the selection
3785    /// instead when one is active.
3786    ///
3787    /// At the end of the line it does nothing, rather than pulling the line
3788    /// below up into this one. Joining has no meaning to give it in both views
3789    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
3790    /// there is nothing there to delete, while the newline a *source* line ends
3791    /// with is only half of the blank line that separates two paragraphs —
3792    /// deleting one leaves a soft break, which is not the join it looks like.
3793    /// The views agreeing is worth more than emacs' second press, and Delete is
3794    /// already the key that joins.
3795    pub fn delete_to_line_end(&mut self) {
3796        if let Some((s, e)) = self.selection() {
3797            self.splice(s, e, "", EditKind::Other);
3798            return;
3799        }
3800        let (_, end) = self.line_span();
3801        if end > self.caret {
3802            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
3803            self.splice(s, e, "", EditKind::Delete);
3804        }
3805    }
3806
3807    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
3808    ///
3809    /// A glyph-space range covers what the user can see, which for `**bold**` is
3810    /// the word and never the delimiters around it — so deleting the word on its
3811    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
3812    /// word, and the styling was the word's; the two go together. Only the
3813    /// node's delimiters are taken, and those are hidden here anyway, so nothing
3814    /// visible outside the range is lost.
3815    ///
3816    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
3817    /// the strong, and only then is the strong empty too.
3818    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
3819        if self.view == View::Source {
3820            return (start, end);
3821        }
3822        let nodes = self.nodes();
3823        let (mut s, mut e) = (start, end);
3824        loop {
3825            let mut grew = false;
3826            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
3827                let Some(text) = inline_content_span(n, &self.source) else {
3828                    continue;
3829                };
3830                // Some of its text survives, so the node still has a job.
3831                if text.start < s || text.end > e {
3832                    continue;
3833                }
3834                if n.span.start < s || n.span.end > e {
3835                    s = s.min(n.span.start);
3836                    e = e.max(n.span.end);
3837                    grew = true;
3838                }
3839            }
3840            if !grew {
3841                return (s, e);
3842            }
3843        }
3844    }
3845
3846    /// One splice of document text, keeping the **mark-edge rule**: an inline
3847    /// mark's content never begins or ends with whitespace. In Markdown and Djot
3848    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
3849    /// is four literal asterisks around a word, and a rich view drawing the
3850    /// document faithfully has no choice but to show them. That is correct
3851    /// rendering of what the file says, and nobody typing a space after a bold
3852    /// word meant to say it.
3853    ///
3854    /// So the space goes *outside* the run instead — `**bold** ` — which is the
3855    /// same document to a reader and a live one to a parser. The caret follows it
3856    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
3857    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
3858    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
3859    ///
3860    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
3861    /// through here, so the rule holds however the whitespace arrives at the
3862    /// edge. The repair is decided *after* the plain edit, by asking whether the
3863    /// mark actually died: a code span's backticks aren't whitespace-sensitive
3864    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
3865    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3866        let fix = self.mark_edge_fix(start, end, text);
3867        if !self.splice_exact(start, end, text, kind) {
3868            return false;
3869        }
3870        if let Some(fix) = fix {
3871            self.repair_mark_edges(fix);
3872        }
3873        if text.is_empty() && end > start {
3874            self.settle_inside_close_delims();
3875        }
3876        true
3877    }
3878
3879    /// After a delete, take a caret left standing past a run's closing delimiters
3880    /// back inside the run.
3881    ///
3882    /// A delete leaves the caret where the deleted bytes began, and when those
3883    /// bytes were the last thing after a marked phrase — the space the mark-edge
3884    /// rule pushed out of `**bold** `, say — that spot is the far side of the
3885    /// closing `**`. The rich view has nothing to draw there: the delimiters are
3886    /// hidden, so the caret shows at the end of the word either way, and the two
3887    /// offsets are one place on screen with two different meanings. Typing at the
3888    /// outer one lands past the run, so the writer who backspaced a space out of
3889    /// their bold phrase watches the next character come out plain, and the
3890    /// toolbar button go dark, with the caret never appearing to move.
3891    ///
3892    /// The end of the run's text is the caret's home there — a delete that took
3893    /// away everything after a phrase leaves the caret at the end of that phrase,
3894    /// which is inside it — so it settles onto that
3895    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
3896    /// walk, through every mark closing at the point): the word stays bold, the
3897    /// button stays lit, and the next character carries on the phrase.
3898    ///
3899    /// Rich view only, and only where a mark really closes at the caret — mid-run
3900    /// or in plain prose no span ends there and the caret stays put. The opening
3901    /// edge is left alone on purpose: a caret in front of a run inherits from the
3902    /// text on its left, which is the plain text outside.
3903    fn settle_inside_close_delims(&mut self) {
3904        if self.view != View::Wysiwyg {
3905            return;
3906        }
3907        let at = self.step_inside_close_delims(self.caret);
3908        if at != self.caret {
3909            self.caret = at;
3910            self.clear_pending();
3911            self.record_caret();
3912        }
3913    }
3914
3915    /// The splice exactly as asked, with no mark-edge repair — for the callers
3916    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
3917    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
3918    /// the bytes they inserted.
3919    ///
3920    /// One `edit_range` through twig, then re-anchor the caret from the returned
3921    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
3922    /// Markdown/Djot) leaves the document untouched and reports.
3923    ///
3924    /// Returns whether the edit landed — for a caller that has offsets of its
3925    /// own to place afterwards, which a rolled-back splice would leave pointing
3926    /// into text that never came to exist.
3927    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
3928        // The read-only gate, for every edit at once — see the field.
3929        if self.read_only {
3930            return false;
3931        }
3932        // twig records an undo step for every edit; when this one continues a
3933        // run of the same kind (typing, deleting), tell twig to fold it into the
3934        // step before it so the whole run undoes at once.
3935        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
3936        // Hand twig the pre-edit caret before the splice, so the undo step it
3937        // retires carries where the caret was standing.
3938        self.record_caret();
3939        match self.editor.edit_range(start, end, text) {
3940            Ok(change) => {
3941                if coalesce {
3942                    let _ = self.editor.coalesce_last_undo();
3943                }
3944                self.last_edit_kind = Some(kind);
3945                self.refresh();
3946                self.caret = change.new.end;
3947                self.anchor = None;
3948                self.goal_col = None;
3949                self.clear_pending();
3950                self.dirty = self.source != self.clean_source;
3951                self.status = None;
3952                // And the post-edit caret, so a later redo restores it.
3953                self.record_caret();
3954                true
3955            }
3956            // The edit was rolled back, so twig's history did not move and
3957            // neither may ours: pushing here would leave a step with no edit
3958            // under it and shift every later undo onto the wrong caret.
3959            Err(e) => {
3960                self.status = Some(format!("edit: {e}"));
3961                false
3962            }
3963        }
3964    }
3965
3966    /// The re-spelling that would keep the mark-edge rule for the edit
3967    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
3968    /// against a delimiter and the plain splice is already right. Computed
3969    /// *before* the edit, while the run's spans and delimiters can still be read
3970    /// off the document; applied afterwards, and only if the mark really died —
3971    /// see [`repair_mark_edges`](Self::repair_mark_edges).
3972    ///
3973    /// Rich view only. Source view is for typing raw markup, where a space put
3974    /// against a `**` is exactly the character it looks like.
3975    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
3976        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
3977            return None;
3978        }
3979        // Every inline mark standing over the edit, outermost first, with the
3980        // content span that says where its delimiters are.
3981        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
3982            .editor
3983            .ancestors_at(start)
3984            .unwrap_or_default()
3985            .into_iter()
3986            .filter_map(|m| {
3987                let kind = inline_kind(&m.kind)?;
3988                let content = m.content_span.clone()?;
3989                Some((kind, m.span.clone(), content))
3990            })
3991            .collect();
3992        // The innermost run whose *content* holds the whole edit: the one whose
3993        // text is being changed, rather than one the edit merely sits under.
3994        let (kind, span, content) = chain
3995            .iter()
3996            .rev()
3997            .find(|(_, _, c)| c.start <= start && end <= c.end)?
3998            .clone();
3999        // What that content becomes. Whitespace at either end of it is what
4000        // would put out the mark.
4001        let body = format!(
4002            "{}{text}{}",
4003            &self.source[content.start..start],
4004            &self.source[end..content.end]
4005        );
4006        let (lead, trail) = if body.trim().is_empty() {
4007            // Nothing but whitespace left: there is no content to mark at all,
4008            // and the delimiters go with it rather than closing on a space.
4009            (body.len(), 0)
4010        } else {
4011            (
4012                body.len() - body.trim_start().len(),
4013                body.len() - body.trim_end().len(),
4014            )
4015        };
4016        // Nothing against a delimiter, and something still between them: the
4017        // plain edit stands. An emptied run is broken just as surely (`**b**`
4018        // with the `b` deleted is the literal `****`) and is re-spelt as the
4019        // nothing it now says.
4020        if lead == 0 && trail == 0 && !body.is_empty() {
4021            return None;
4022        }
4023        // Marks that open or close exactly where this one does — `***both***` is
4024        // two runs sharing an edge — spell their delimiters as one run of bytes,
4025        // so the whitespace has to clear all of them together.
4026        let (mut open_at, mut close_at) = (span.start, span.end);
4027        for _ in 0..chain.len() {
4028            match chain.iter().find(|(_, _, c)| c.start == open_at) {
4029                Some((_, s, _)) => open_at = s.start,
4030                None => break,
4031            }
4032        }
4033        for _ in 0..chain.len() {
4034            match chain.iter().find(|(_, _, c)| c.end == close_at) {
4035                Some((_, s, _)) => close_at = s.end,
4036                None => break,
4037            }
4038        }
4039        let open = &self.source[open_at..content.start];
4040        let close = &self.source[content.end..close_at];
4041        let core = &body[lead..body.len() - trail];
4042        let respelt = if core.is_empty() {
4043            body.clone()
4044        } else {
4045            format!(
4046                "{}{open}{core}{close}{}",
4047                &body[..lead],
4048                &body[body.len() - trail..]
4049            )
4050        };
4051        // The caret sits just past the inserted text within the new content —
4052        // which, when that lands in the whitespace, is now outside the delimiters.
4053        let pos = (start - content.start) + text.len();
4054        let caret = if core.is_empty() || pos <= lead {
4055            open_at + pos
4056        } else if pos >= lead + core.len() {
4057            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4058        } else {
4059            open_at + lead + open.len() + (pos - lead)
4060        };
4061        Some(MarkEdgeFix {
4062            kind,
4063            probe: content.start,
4064            start: open_at,
4065            end: close_at + text.len() - (end - start),
4066            text: respelt,
4067            caret,
4068            // The marks in force here, resolved against any armed sticky delta —
4069            // what the writer is typing in, and so what has to still be true on
4070            // the far side of the delimiter the caret just stepped over.
4071            want: chain
4072                .iter()
4073                .filter(|(_, s, _)| start < s.end)
4074                .map(|(k, _, _)| *k)
4075                .collect::<InlineMarks>()
4076                .xor(self.pending_here()),
4077        })
4078    }
4079
4080    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
4081    /// did break the mark. Whether whitespace at a delimiter is fatal is the
4082    /// format's business, not leaf's: `**bold **` is no longer strong, while
4083    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
4084    /// don't care either. Asking the parser afterwards settles it for every kind
4085    /// and format at once, and costs a re-spelling only where one is due.
4086    ///
4087    /// The repair rides along with the edit that caused it — one undo step puts
4088    /// back what the writer typed, not a delimiter shuffle they never saw.
4089    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
4090        if fix.end > self.source.len() {
4091            return;
4092        }
4093        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
4094            return; // still a mark: these delimiters don't mind the whitespace
4095        }
4096        let resumed = self.last_edit_kind;
4097        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
4098            return;
4099        }
4100        let _ = self.editor.coalesce_last_undo();
4101        // The keystroke owns the undo step, so the run of typing it belongs to
4102        // keeps coalescing over the repair rather than breaking in two here.
4103        self.last_edit_kind = resumed;
4104        self.caret = fix.caret.min(self.source.len());
4105        self.anchor = None;
4106        self.goal_col = None;
4107        self.rearm(fix.want);
4108        self.clamp_caret();
4109        self.record_caret();
4110    }
4111
4112    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
4113    /// writer is typing in, carried across an edit that moved the caret out of
4114    /// the run holding them. Arms nothing when the caret already stands in
4115    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
4116    /// a clean delta here (see [`toggle`](Self::toggle)).
4117    fn rearm(&mut self, want: InlineMarks) {
4118        let here: InlineMarks = self
4119            .marks_at(self.caret)
4120            .into_iter()
4121            .map(|(k, _)| k)
4122            .collect();
4123        self.pending_marks = want.xor(here);
4124        self.pending_at = Some(self.caret);
4125    }
4126
4127    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
4128    /// which backslash-escapes any character that would otherwise open markup in
4129    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
4130    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
4131    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
4132    /// a collapsed point — a selection is deleted by the caller first, since
4133    /// `insert_literal` inserts rather than replaces.
4134    fn insert_literal_at(
4135        &mut self,
4136        at: usize,
4137        text: &str,
4138        kind: EditKind,
4139        force_coalesce: bool,
4140    ) -> bool {
4141        // The read-only gate: this door goes to twig directly, not through
4142        // `splice_exact`, so it guards itself — see the field.
4143        if self.read_only {
4144            return false;
4145        }
4146        // `force_coalesce` folds this into the immediately preceding edit (the
4147        // selection-delete of an overwrite) so the pair is one undo step; else it
4148        // coalesces only when it continues a run of the same-kind typing.
4149        let coalesce =
4150            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4151        // The mark-edge rule holds for typed text however it is spelled — see
4152        // `splice`. Only an insert twig passed through unchanged can use it,
4153        // since a fix is measured in the bytes that actually land, and an escape
4154        // adds bytes this couldn't have counted.
4155        let fix = self.mark_edge_fix(at, at, text);
4156        self.record_caret();
4157        match self.editor.insert_literal(at, text) {
4158            Ok(change) => {
4159                if coalesce {
4160                    let _ = self.editor.coalesce_last_undo();
4161                }
4162                self.last_edit_kind = Some(kind);
4163                self.refresh();
4164                self.caret = change.new.end;
4165                self.anchor = None;
4166                self.goal_col = None;
4167                self.clear_pending();
4168                self.dirty = self.source != self.clean_source;
4169                self.status = None;
4170                self.record_caret();
4171                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4172                    self.repair_mark_edges(fix);
4173                }
4174                true
4175            }
4176            Err(e) => {
4177                self.status = Some(format!("edit: {e}"));
4178                false
4179            }
4180        }
4181    }
4182
4183    /// After a structural list edit (a new item, a nest/unnest), renumber the
4184    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4185    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4186    /// renumber as its own edit; fold it into the edit that triggered it so the
4187    /// two undo as one, and only when it actually changed the source (a no-op or
4188    /// a caret outside any ordered list must not coalesce the real edit into the
4189    /// step before it).
4190    fn renumber_here(&mut self) {
4191        self.renumber_at(self.caret);
4192    }
4193
4194    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4195    /// caret — for an edit that leaves the caret one past the item it just wrote,
4196    /// where twig resolves no list to renumber.
4197    fn renumber_at(&mut self, off: usize) {
4198        // The read-only gate — this door reaches twig without the splice.
4199        if self.read_only {
4200            return;
4201        }
4202        let before = self.source.clone();
4203        if self.editor.renumber_ordered_lists(off).is_err() {
4204            return; // not inside an ordered list — nothing to renumber
4205        }
4206        self.refresh();
4207        if self.source != before {
4208            let _ = self.editor.coalesce_last_undo();
4209            self.dirty = self.source != self.clean_source;
4210            self.clamp_caret();
4211            self.record_caret();
4212        }
4213    }
4214
4215    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4216    /// sub-item written directly beneath a text line reparses that text as a
4217    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
4218    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4219    /// bullets can't underline anything, so swap the dash for a `*`: the item
4220    /// stays an empty nested bullet, the parent stays prose, and the source
4221    /// round-trips instead of hiding a heading the user never asked for. Folded
4222    /// into the triggering edit's undo step, the way renumbering is.
4223    ///
4224    /// Gated on the collapse having actually happened (the swapped dash was
4225    /// swallowed into a `heading`), so a real setext heading the author wrote —
4226    /// or a `- x` with content, which can't underline anything — is never
4227    /// touched. This has to live in the *edit*, not the renderer: leaving the
4228    /// hazardous bytes on disk and only painting over them would ship a file
4229    /// every other CommonMark tool reads as a heading.
4230    ///
4231    /// This one keeps its own byte scan, and has to: the hazard is precisely
4232    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4233    /// which asks twig which lines open an item — reports nothing here. There is
4234    /// no node to ask about. It is also the last Markdown spelling leaf writes on
4235    /// purpose rather than for want of an answer; once twig spells continuations
4236    /// itself, avoiding the trap becomes twig's, and this goes.
4237    ///
4238    /// [`list_marker_on_line`]: Self::list_marker_on_line
4239    fn avoid_setext_collapse(&mut self) {
4240        let caret = self.caret.min(self.source.len());
4241        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4242        let bytes = self.source.as_bytes();
4243        let mut dash = line_start;
4244        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4245            dash += 1;
4246        }
4247        // A dash bullet is the only marker that doubles as a setext underline.
4248        if bytes.get(dash) != Some(&b'-') {
4249            return;
4250        }
4251        // Only an *empty* item is a bare underline; `- x` carries content and
4252        // can't fold the line above into a heading.
4253        let line_end = self.source[dash..]
4254            .find('\n')
4255            .map_or(self.source.len(), |i| dash + i);
4256        if !self.source[dash + 1..line_end].trim().is_empty() {
4257            return;
4258        }
4259        // The tell: that dash was swallowed into a `heading`. A properly nested
4260        // empty item sits under a `list_item`, with no heading in reach. Probe
4261        // the dash byte itself (well inside the heading), not the caret, whose
4262        // end-of-line offset can fall on the half-open span boundary.
4263        let collapsed = self
4264            .editor
4265            .ancestors_at(dash)
4266            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4267            .unwrap_or(false);
4268        if !collapsed {
4269            return;
4270        }
4271        let caret = self.caret;
4272        if self.splice(dash, dash + 1, "*", EditKind::Other) {
4273            // Same width, so the caret keeps its column; fold into the edit that
4274            // triggered this so Tab stays one undo step.
4275            let _ = self.editor.coalesce_last_undo();
4276            self.caret = caret.min(self.source.len());
4277            self.clamp_caret();
4278            self.record_caret();
4279        }
4280    }
4281
4282    fn snapshot(&self) -> CaretState {
4283        CaretState {
4284            caret: self.caret,
4285            anchor: self.anchor,
4286        }
4287    }
4288
4289    /// Hand twig the current caret and selection as the blob for the live
4290    /// document state. Called before an edit — so the step twig retires records
4291    /// where the caret was, and undo can restore it — and again once the op has
4292    /// placed the caret, so redo restores where the edit left it.
4293    ///
4294    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4295    /// caret through its own history, so coalescing falls out for free (folding
4296    /// two twig steps into one drops the intermediate blob, keeping the run's
4297    /// first) and the parallel stacks that had to march in lockstep — and could
4298    /// silently drift out of it — are gone.
4299    fn record_caret(&mut self) {
4300        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4301    }
4302
4303    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4304    /// the toggled region selected so a second press cleanly reverses it.
4305    pub fn toggle(&mut self, kind: InlineKind) {
4306        // The read-only gate — this door reaches twig without the splice.
4307        if self.read_only {
4308            return;
4309        }
4310        // Ahead of the no-selection branch below: arming a mark for text not yet
4311        // typed is a promise `insert` cannot keep in a format with no delimiters
4312        // to spell it with. Per *kind*, not per format — Markdown spells five
4313        // of the eight marks (highlight among them, under the `highlight`
4314        // extension leaf parses with), djot all eight, HTML seven.
4315        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4316            return;
4317        }
4318        let Some((s, e)) = self.selection() else {
4319            // No selection: arm the mark for the next text typed here, the way a
4320            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4321            // in the flow of typing without ever selecting anything — the delta
4322            // is realised onto the freshly typed text by `insert`. A fresh caret
4323            // position starts the delta over from the marks actually in force.
4324            if self.pending_at != Some(self.caret) {
4325                self.pending_marks = InlineMarks::empty();
4326                self.pending_at = Some(self.caret);
4327            }
4328            self.pending_marks.flip(kind);
4329            self.status = None;
4330            return;
4331        };
4332        // Whitespace at the edge of a selection is not part of what was chosen —
4333        // a double-click takes the space after the word with it — and a mark
4334        // cannot close against one anyway: `**word **` is four literal asterisks
4335        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4336        let picked = &self.source[s..e];
4337        let (s, e) = (
4338            s + (picked.len() - picked.trim_start().len()),
4339            e - (picked.len() - picked.trim_end().len()),
4340        );
4341        if s >= e {
4342            self.status = Some(format!("{kind:?}: nothing selected to mark"));
4343            return;
4344        }
4345        // Styling a selection is a one-shot act, not a sticky mode.
4346        self.clear_pending();
4347        self.record_caret();
4348        match self.editor.toggle_inline(s, e, kind) {
4349            Ok(change) => {
4350                self.last_edit_kind = None; // structural edit is its own undo step
4351                self.refresh();
4352                self.anchor = Some(change.new.start);
4353                self.caret = change.new.end;
4354                self.dirty = self.source != self.clean_source;
4355                self.status = None;
4356                self.record_caret();
4357            }
4358            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4359        }
4360    }
4361
4362    /// Whether the caret stands in a highlight — what a frontend asks to enable
4363    /// or disable its highlight-colour controls, the way
4364    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4365    ///
4366    /// A fact about the *caret*, and the other half of
4367    /// [`Capabilities::mark_color`], which is the fact about the format. A
4368    /// frontend needs both: djot spells a highlight and no colour for it, so a
4369    /// caret standing in `{=word=}` answers `true` here and still has no palette
4370    /// to offer.
4371    ///
4372    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4373    /// the palette appears exactly where the Highlight button is lit — with one
4374    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4375    /// into lights the button and answers `false` here, because there is no node
4376    /// to colour until the text exists.
4377    pub fn caret_in_mark(&mut self) -> bool {
4378        self.mark_offset().is_some()
4379    }
4380
4381    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4382    /// standing in the highlight the gesture means — or `None` when neither end
4383    /// of what is selected is in one.
4384    ///
4385    /// The caret first, and the selection's *start* after it, because of what
4386    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4387    /// whole, with the caret at its far edge, one past the closing `==` and so
4388    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4389    /// and colour it — the two presses a coloured highlight is made of — would
4390    /// otherwise refuse on the second, having just written the highlight the
4391    /// author is pointing at.
4392    fn mark_offset(&mut self) -> Option<usize> {
4393        let in_mark = |d: &mut Self, off: usize| {
4394            d.marks_at(off)
4395                .into_iter()
4396                .any(|(k, _)| k == InlineKind::Mark)
4397                .then_some(off)
4398        };
4399        let caret = self.caret.min(self.source.len());
4400        in_mark(self, caret).or_else(|| {
4401            let start = self.selection()?.0;
4402            in_mark(self, start)
4403        })
4404    }
4405
4406    /// The colour of the highlight at the caret — `None` both when the caret is
4407    /// in no highlight and when the highlight it is in names no colour, which
4408    /// are the same answer to "which swatch is lit".
4409    ///
4410    /// The innermost mark, by span, for the same reason
4411    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4412    /// what the caret is *in* is the deepest node containing it. A `data-color`
4413    /// naming a colour this build has no variant for reads as `None` — the
4414    /// renderer already draws that as a plain highlight rather than guessing,
4415    /// and the toolbar agrees with the renderer.
4416    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4417        let at = self.mark_offset()?;
4418        self.mark_color_at(at)
4419    }
4420
4421    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4422    /// the innermost `mark` covering it, and the colour it names.
4423    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4424        self.nodes()
4425            .into_iter()
4426            .filter(|n| n.kind == Kind::Mark)
4427            .filter(|n| n.span.start <= off && off < n.span.end)
4428            .min_by_key(|n| n.span.end - n.span.start)
4429            .and_then(|n| MarkColor::from_attrs(&n.attrs))
4430    }
4431
4432    /// Colour the highlight at the caret, or clear its colour with `None` — the
4433    /// palette behind a toolbar's Highlight button.
4434    ///
4435    /// Markdown only, and the one gesture whose availability is a fact about the
4436    /// *parse extensions* rather than about the format alone: the colour is
4437    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4438    /// records as the mark's `data-color`, and only an editor parsing with
4439    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4440    /// document) reads it back that way. Djot spells the highlight and no colour
4441    /// for it, so this refuses there — see [`Capabilities::mark_color`].
4442    ///
4443    /// **A colour is a property of a highlight that already exists.** There is
4444    /// no "highlight this in red" here, because that is two splices and would be
4445    /// two undo steps under one press; a frontend that wants it calls
4446    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4447    /// order the two buttons already sit in. With no highlight at the caret this
4448    /// says so in the status line and writes nothing.
4449    ///
4450    /// The caret keeps its place in the *text*: the splice is entirely in the
4451    /// prefix between the opening `==` and the first word, so an offset past it
4452    /// rides the emoji's width, and one standing on the prefix itself lands
4453    /// where the prefix now ends.
4454    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4455        // The read-only gate — this door reaches twig without the splice.
4456        if self.read_only {
4457            return;
4458        }
4459        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4460            return;
4461        }
4462        let Some(at) = self.mark_offset() else {
4463            self.status = Some("highlight colour: no highlight at the caret".into());
4464            return;
4465        };
4466        // Clearing a colour a highlight hasn't got is twig's one *successful*
4467        // no-op, and the `Change` it hands back then describes whatever edit came
4468        // before it — a stale span that would drag the caret somewhere it never
4469        // was. Answer it here, where the question is cheap, rather than trusting
4470        // a change that isn't one.
4471        if color.is_none() && self.mark_color_at(at).is_none() {
4472            self.status = None;
4473            return;
4474        }
4475        self.record_caret();
4476        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4477            Ok(change) => {
4478                // Re-anchored from the offsets as they were, *before* `refresh`
4479                // sees the new bytes: the caret it clamps is one standing inside
4480                // a prefix that didn't exist a moment ago, and walking it back to
4481                // a char boundary of the emoji loses the place this is restoring.
4482                let caret = reanchor(self.caret, &change);
4483                let anchor = self.anchor.map(|a| reanchor(a, &change));
4484                self.last_edit_kind = None; // structural edit is its own undo step
4485                self.refresh();
4486                self.caret = caret;
4487                self.anchor = anchor;
4488                self.dirty = self.source != self.clean_source;
4489                self.status = None;
4490                self.clamp_caret();
4491                self.record_caret();
4492            }
4493            Err(e) => self.status = Some(format!("highlight colour: {e}")),
4494        }
4495    }
4496
4497    /// One press of a colour swatch: colour the highlight at the caret, or —
4498    /// over a selection that isn't highlighted yet — highlight it and colour it,
4499    /// as **one** undo step.
4500    ///
4501    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4502    /// one splice; this is the compound every toolbar actually presses, and it
4503    /// lives here rather than in each frontend because the rule it encodes —
4504    /// what a swatch means when there is no highlight under it yet — is one
4505    /// answer, not one per frontend. The two splices are folded into a single
4506    /// history step, so the press that made a red highlight is taken back by a
4507    /// single undo rather than leaving an uncoloured one behind.
4508    ///
4509    /// `None` clears the colour, and over an unhighlighted selection means
4510    /// simply "highlight this" — the same thing the Highlight button does.
4511    /// A bare caret in no highlight is left alone with a status line, because
4512    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4513    /// colour cannot be armed with it.
4514    pub fn highlight(&mut self, color: Option<MarkColor>) {
4515        if self.caret_in_mark() || self.selection().is_none() {
4516            self.set_mark_color(color);
4517            return;
4518        }
4519        self.toggle(InlineKind::Mark);
4520        // The format may not spell a highlight at all (`toggle` said so), and
4521        // there is nothing to colour if it doesn't.
4522        if self.status.is_some() {
4523            return;
4524        }
4525        let before = self.revision;
4526        self.set_mark_color(color);
4527        // Only fold when the colour really spliced. `highlight(None)` over a
4528        // fresh highlight is a no-op by design, and coalescing there would eat
4529        // the *previous* edit into the toggle instead.
4530        if self.revision != before {
4531            let _ = self.editor.coalesce_last_undo();
4532        }
4533    }
4534
4535    // ── the presentation vocabulary ─────────────────────────────────────────
4536    //
4537    // Six gestures and five queries over twig's two attribute ops. Each gesture
4538    // edits **one key and keeps the rest**: it reads the node's attributes,
4539    // removes its own key (and, for alignment, its own tokens out of `class`),
4540    // adds the new value or nothing, and passes the list back whole — twig's
4541    // contract is replace-not-merge, so the read is the caller's job. A
4542    // paragraph that came in as `class="lead center" id="intro"
4543    // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4544    // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4545    // touched, which is what lets a document from elsewhere pass through the
4546    // editor unharmed.
4547    //
4548    // Clearing is the same gesture with `None`: the key goes, and an empty list
4549    // at the end unwraps the span or the Markdown div, which twig does.
4550
4551    /// Set — or with `None` clear — the alignment of the block the caret is in.
4552    ///
4553    /// A block property, so the gesture is `set_block_attrs` on the caret's
4554    /// block **whatever is selected**: a line is a block's, and "centre this"
4555    /// with three words selected means the paragraph, not the words. The
4556    /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4557    /// same `class` are kept.
4558    ///
4559    /// In Markdown the attributes live on a `<div>` around the block — twig has
4560    /// no paragraph attribute syntax to write — and this reads them back off
4561    /// that div when the block is its sole child, so a second press rewrites
4562    /// the div rather than nesting a second one.
4563    pub fn set_alignment(&mut self, align: Option<Align>) {
4564        let attrs = self.block_attrs_at_caret();
4565        if align.is_none()
4566            && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4567        {
4568            return;
4569        }
4570        let attrs = with_class_token(
4571            &attrs,
4572            |t| Align::from_token(t).is_some(),
4573            align.map(Align::name),
4574        );
4575        self.write_block_attrs("alignment", attrs);
4576    }
4577
4578    /// Set — or with `None` clear — the line spacing of the block the caret is
4579    /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4580    /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4581    /// three names or an exact ratio, written in its canonical spelling.
4582    pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4583        let attrs = self.block_attrs_at_caret();
4584        if spacing.is_none()
4585            && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4586                LineHeight::from_attrs(a).is_some()
4587            })
4588        {
4589            return;
4590        }
4591        let spelling = spacing.map(LineHeight::name);
4592        let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4593        self.write_block_attrs("line spacing", attrs);
4594    }
4595
4596    /// Set — or with `None` clear — the size of the selected run, or of the
4597    /// caret's whole block when nothing is selected.
4598    ///
4599    /// Size, face and colour are the *run's*, and the block's when no run is
4600    /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4601    /// the range in an attributed span or re-styles the span it already lies in
4602    /// (never nesting a second, and unwrapping it when the last key goes). With
4603    /// no selection it is `set_block_attrs` on the caret's block, so that "make
4604    /// this paragraph larger" is a click with the caret in it rather than a
4605    /// select-all first.
4606    ///
4607    /// The walker reads the key at both levels with the nearer winning, so a
4608    /// span's `data-size` inside a block carrying its own applies to the span.
4609    ///
4610    /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4611    /// what a menu offers first because a step reads as a step up under every
4612    /// theme, or the point size an author asked for, which is exact and is all
4613    /// it is. Either is written in its canonical spelling, so a size set twice
4614    /// from the same field writes the same bytes both times.
4615    pub fn set_font_size(&mut self, size: Option<FontSize>) {
4616        let spelling = size.map(FontSize::name);
4617        self.set_run_attr("size", "data-size", spelling.as_deref());
4618    }
4619
4620    /// Set — or with `None` clear — the face of the selected run, or of the
4621    /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4622    /// [`FontFace`] under `data-font` — one of the four generics, or the family
4623    /// the author named, which the frontends resolve through the platform's
4624    /// font registry and fall back to the body face without.
4625    pub fn set_font_family(&mut self, font: Option<FontFace>) {
4626        let spelling = font.as_ref().map(FontFace::name);
4627        self.set_run_attr("font", "data-font", spelling.as_deref());
4628    }
4629
4630    /// Set — or with `None` clear — the *text* colour of the selected run, or of
4631    /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
4632    /// with [`TextColor`] under `data-color` — one of the seven names, whose
4633    /// two inks the theme owns, or the triple the author picked, which is
4634    /// painted as written in both appearances.
4635    ///
4636    /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
4637    /// writes, and a different thing: that one colours a highlight's
4638    /// *background* and rides the `mark` node twig owns the spelling of, this
4639    /// one colours the letters and rides an attributed span. The two never
4640    /// collide, because a `mark` is a `mark` and a span is a span — and they
4641    /// share a vocabulary on purpose, so that a frontend with a red for a
4642    /// highlight has a red for text and both are *that* red.
4643    pub fn set_text_color(&mut self, color: Option<TextColor>) {
4644        let spelling = color.map(TextColor::name);
4645        self.set_run_attr("text colour", "data-color", spelling.as_deref());
4646    }
4647
4648    /// Insert a page break at the caret — `::page-break`, a leaf directive with
4649    /// no label and no attributes, which twig spells in every format that names
4650    /// a leaf container (Markdown under the `directives` extension
4651    /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
4652    /// page-break` fence).
4653    ///
4654    /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
4655    /// places a rule, and for the same reason: a directive is a block, so twig
4656    /// alone has nowhere to put one mid-paragraph and lands it after the
4657    /// caret's whole block. A bare paragraph is therefore parted at the caret
4658    /// first and the break aimed at the *first* half. See that method for the
4659    /// whole of the rule, including why a code block, a list item, a table and
4660    /// a setext heading are left unsplit.
4661    ///
4662    /// The frontends that paginate read the row's
4663    /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
4664    /// the ones that do not draw the `⧉ page-break` placeholder every leaf
4665    /// directive gets.
4666    pub fn insert_page_break(&mut self) {
4667        if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
4668            return;
4669        }
4670        self.caret = self.skip_trailing_close_delims(self.caret);
4671        // A selection is replaced by the break, as a rule replaces one.
4672        if let Some((s, e)) = self.selection() {
4673            self.splice(s, e, "", EditKind::Other);
4674        }
4675        self.anchor = None;
4676        self.record_caret();
4677        let at = self.caret;
4678        if self.caret_parts_bare_paragraph() {
4679            // A failure here is not fatal: the break still lands after the
4680            // block, which is what this call was trying to improve on.
4681            let _ = self.editor.split_block(at);
4682        }
4683        match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
4684            Ok(change) => {
4685                self.last_edit_kind = None;
4686                self.refresh();
4687                self.anchor = None;
4688                self.caret = change.new.end;
4689                self.dirty = self.source != self.clean_source;
4690                self.status = None;
4691                self.clamp_caret();
4692                self.record_caret();
4693            }
4694            Err(e) => self.status = Some(format!("page break: {e}")),
4695        }
4696    }
4697
4698    /// The alignment in force at the caret, or `None` for the theme's default —
4699    /// which swatch of an alignment control is lit.
4700    ///
4701    /// Read off the nearest node that names one: the block the caret is in, and
4702    /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
4703    /// shape, one property along.
4704    pub fn alignment_at_caret(&mut self) -> Option<Align> {
4705        self.presentation_chain()
4706            .iter()
4707            .find_map(|attrs| Align::from_attrs(attrs))
4708    }
4709
4710    /// The line spacing in force at the caret, or `None` for the theme's own.
4711    /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
4712    pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
4713        self.presentation_chain()
4714            .iter()
4715            .find_map(|attrs| LineHeight::from_attrs(attrs))
4716    }
4717
4718    /// The size in force at the caret, or `None` for the theme's own — the
4719    /// entry a size menu shows ticked.
4720    ///
4721    /// Run-level, so the chain starts one node deeper: the attributed span the
4722    /// caret stands in, then its block, then the `div`s around it. The nearest
4723    /// wins, which is the rule the walker draws by.
4724    ///
4725    /// A name or a value, whichever the nearest node wrote. A `data-size` the
4726    /// grammar does not cover — a `huge` from elsewhere — is not a size this
4727    /// can answer, so the answer is `None` and the menu ticks *Default*, the
4728    /// same thing it did before the vocabulary opened.
4729    pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
4730        self.presentation_chain()
4731            .iter()
4732            .find_map(|attrs| FontSize::from_attrs(attrs))
4733    }
4734
4735    /// The face in force at the caret, or `None` for the theme's body face.
4736    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
4737    pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
4738        self.presentation_chain()
4739            .iter()
4740            .find_map(|attrs| FontFace::from_attrs(attrs))
4741    }
4742
4743    /// The *text* colour in force at the caret, or `None` for the theme's.
4744    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
4745    /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
4746    /// highlight's background off a `mark`, and a `mark` is never in this chain.
4747    pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
4748        self.presentation_chain()
4749            .iter()
4750            .find_map(|attrs| TextColor::from_attrs(attrs))
4751    }
4752
4753    /// The selection-or-caret half of the three run-level gestures: a span over
4754    /// a real selection, the caret's block over none.
4755    fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
4756        match self.selection() {
4757            Some((start, end)) => {
4758                let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
4759                self.write_run_attrs(what, start, end, attrs);
4760            }
4761            None => {
4762                let own = self.block_attrs_at_caret();
4763                if value.is_none()
4764                    && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
4765                {
4766                    return;
4767                }
4768                let attrs = with_attr(&own, key, value);
4769                self.write_block_attrs(what, attrs);
4770            }
4771        }
4772    }
4773
4774    /// A clear this gesture cannot carry out, said out loud instead of written:
4775    /// the node it rewrites — the caret's block, or the `<div>` around it that
4776    /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
4777    /// — does not name the property at all, and a `div` further out does.
4778    ///
4779    /// Handing twig the block's attributes with the key already absent changes
4780    /// no byte, and the query goes on answering `Some` off the div: the menu
4781    /// entry the author pressed stays unticked, and nothing says why. Twig's
4782    /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
4783    /// write on a node it is not rewriting — the honest answer is the status
4784    /// line, in the voice the other refusals use.
4785    ///
4786    /// `names` is the property's own reading of an attribute list, because
4787    /// alignment lives in a `class` token rather than a key of its own. Spans
4788    /// are skipped: one inside the block is not what a *block* gesture writes
4789    /// either, but neither is it "the div around the block", and the run-level
4790    /// gestures reach it through a selection.
4791    fn refuse_clear_from_div(
4792        &mut self,
4793        what: &str,
4794        own: &Attrs,
4795        names: impl Fn(&Attrs) -> bool,
4796    ) -> bool {
4797        if names(own) {
4798            return false;
4799        }
4800        let caret = self.caret.min(self.source.len());
4801        if !self
4802            .attr_chain_at(caret)
4803            .iter()
4804            .any(|(span, attrs)| !span && names(attrs))
4805        {
4806            return false;
4807        }
4808        self.status = Some(format!("{what}: set on the div around the block"));
4809        true
4810    }
4811
4812    /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
4813    /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
4814    /// has.
4815    ///
4816    /// **The caret keeps its place in the text, not its byte offset.** How a
4817    /// format spells a block's attributes is markup written *around* the block
4818    /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
4819    /// it in Markdown, a longer opening tag in HTML — and every one of those
4820    /// grows or shrinks above the author's own bytes. Where twig's change
4821    /// rewrites the block whole (Markdown's div is spliced as one region, block
4822    /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
4823    /// and parks the caret at the end of the splice, past the closing `</div>`:
4824    /// the caret is then in no block at all, so a second press of the same menu
4825    /// answers "no block at the caret" and the toolbar's queries read nothing.
4826    /// [`reanchor_in_block`] is what carries it across instead — the block's
4827    /// content span before and after, which is the one thing the respelling
4828    /// leaves alone.
4829    ///
4830    /// Read *before* the splice and applied *after* `refresh`, because both
4831    /// halves of that mapping are facts about a tree twig is between: the
4832    /// block's old bytes are gone once the edit lands, and its new ones are not
4833    /// in `self.source` until the refresh puts them there.
4834    fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
4835        if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
4836            return;
4837        }
4838        // A blank line has no block to carry an attribute, and twig answers
4839        // `NotFound` there — say so in leaf's own words instead.
4840        let Some(at) = self.block_offset_for_caret() else {
4841            self.status = Some(format!("{what}: no block at the caret"));
4842            return;
4843        };
4844        self.record_caret();
4845        let pairs = attr_pairs(&attrs);
4846        let was = self.block_content_at(at);
4847        let text = was.clone().map(|s| self.source[s].to_string());
4848        match self.editor.set_block_attrs(at, &pairs) {
4849            Ok(change) => {
4850                let (caret, anchor) = (self.caret, self.anchor);
4851                self.last_edit_kind = None; // structural edit is its own undo step
4852                self.refresh();
4853                let now = self.block_content_in(&change.new, text.as_deref());
4854                // A block the two halves cannot both name — a code block, a
4855                // caret in a list's marker — takes the plain arithmetic, which
4856                // is what it had before.
4857                let block = was.as_ref().zip(now.as_ref());
4858                self.caret = reanchor_in_block(caret, &change, block);
4859                self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
4860                self.dirty = self.source != self.clean_source;
4861                self.status = None;
4862                // The clamp reads the caret floor off the map, and this edit
4863                // can move the floor: taking the `{…}` line off a djot
4864                // document's first block moves the first rendered offset to 0,
4865                // and a floor read from the old map stood the caret past the
4866                // block's text. So the map is this revision's before the clamp
4867                // — see `open_paragraph_at_block_edge`.
4868                self.rebuild_map();
4869                self.clamp_caret();
4870                self.record_caret();
4871            }
4872            Err(e) => self.status = Some(format!("{what}: {e}")),
4873        }
4874    }
4875
4876    /// The content span of the innermost paragraph or heading covering `off` —
4877    /// the author's own bytes, without the `# ` or the `<p>` that spells the
4878    /// block around them.
4879    ///
4880    /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
4881    /// reads, so that what a gesture re-anchors by is the block it wrote to.
4882    fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
4883        self.nodes()
4884            .into_iter()
4885            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4886            .filter(|n| n.span.start <= off && off <= n.span.end)
4887            .min_by_key(|n| n.span.end - n.span.start)
4888            .map(|n| n.content_span.unwrap_or(n.span))
4889    }
4890
4891    /// [`block_content_at`](Self::block_content_at)'s other half: the content
4892    /// span of the block `region` holds now, found by the bytes it held before.
4893    ///
4894    /// Matched on the text rather than taken as the first block in the region,
4895    /// because a rewritten region is markup and all — `<div class="center">`
4896    /// carries words of its own — and because the block this gesture moved is
4897    /// the one whose content the respelling did not touch. `None` where the
4898    /// region holds no block at all, which is djot's every case: the `{…}` line
4899    /// is spliced above the block and the block itself never moves through the
4900    /// change at all, only past it.
4901    fn block_content_in(
4902        &mut self,
4903        region: &Range<usize>,
4904        text: Option<&str>,
4905    ) -> Option<Range<usize>> {
4906        let text = text?;
4907        let spans: Vec<Range<usize>> = self
4908            .nodes()
4909            .into_iter()
4910            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4911            .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
4912            .map(|n| n.content_span.unwrap_or(n.span))
4913            .collect();
4914        spans
4915            .into_iter()
4916            .find(|s| self.source.get(s.clone()) == Some(text))
4917    }
4918
4919    /// Hand `attrs` to twig as the attribute set of the span over `[start,
4920    /// end)` — wrapping one, or re-styling the one the range already lies in,
4921    /// or unwrapping it when `attrs` is empty.
4922    ///
4923    /// What the splice leaves selected is the span's **content** — the author's
4924    /// words — and not the whole of `change.new`, which is markup and all:
4925    /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
4926    /// selection reaching past the node's own span lies in no span at all, so a
4927    /// second press of the menu would nest a fresh one instead of re-styling
4928    /// the one just written.
4929    fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
4930        if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
4931            return;
4932        }
4933        self.record_caret();
4934        let pairs = attr_pairs(&attrs);
4935        match self.editor.wrap_range_attrs(start, end, &pairs) {
4936            Ok(change) => {
4937                self.last_edit_kind = None;
4938                self.refresh();
4939                let content = self.span_content_in(&change.new);
4940                self.anchor = Some(content.start);
4941                self.caret = content.end;
4942                self.dirty = self.source != self.clean_source;
4943                self.status = None;
4944                self.clamp_caret();
4945                self.record_caret();
4946            }
4947            Err(e) => self.status = Some(format!("{what}: {e}")),
4948        }
4949    }
4950
4951    /// The content range of the attributed span `spliced` now holds — the
4952    /// outermost one inside it, since that is the one just written — or
4953    /// `spliced` itself where the splice left no span, which is what an unwrap
4954    /// leaves behind.
4955    fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
4956        self.nodes()
4957            .into_iter()
4958            .filter(wysiwyg::is_run_span)
4959            .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
4960            .max_by_key(|n| n.span.end - n.span.start)
4961            .and_then(|n| n.content_span)
4962            .unwrap_or_else(|| spliced.clone())
4963    }
4964
4965    /// The attribute set `set_block_attrs` is about to **replace** at the caret
4966    /// — which is the block's own, except in Markdown, where twig writes a
4967    /// block's attributes onto a `<div>` around it and rewrites that div when
4968    /// the block is its sole child. Reading the paragraph there would hand back
4969    /// an empty list and quietly drop everything the div said.
4970    ///
4971    /// Empty when the caret is in no block at all, which is the same list a
4972    /// block carrying no attributes gives — and the right one either way, since
4973    /// the gesture then refuses on its own.
4974    fn block_attrs_at_caret(&mut self) -> Attrs {
4975        let Some(off) = self.block_offset_for_caret() else {
4976            return Vec::new();
4977        };
4978        let nodes = self.nodes();
4979        let Some(block) = nodes
4980            .iter()
4981            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
4982            .filter(|n| n.span.start <= off && off <= n.span.end)
4983            .min_by_key(|n| n.span.end - n.span.start)
4984        else {
4985            return Vec::new();
4986        };
4987        if self.format == Format::Markdown
4988            && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
4989            && wysiwyg::element_tag(parent) == Some("div")
4990            && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
4991        {
4992            return parent.attrs.clone();
4993        }
4994        block.attrs.clone()
4995    }
4996
4997    /// The attribute set `wrap_range_attrs` is about to **replace** over
4998    /// `[start, end)` — the innermost attributed span the range lies inside,
4999    /// which twig re-styles rather than nesting a second one in. Empty when the
5000    /// range lies in no span, where the gesture mints a fresh one.
5001    fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
5002        self.nodes()
5003            .into_iter()
5004            .filter(wysiwyg::is_run_span)
5005            .filter(|n| n.span.start <= start && end <= n.span.end)
5006            .min_by_key(|n| n.span.end - n.span.start)
5007            .map(|n| n.attrs)
5008            .unwrap_or_default()
5009    }
5010
5011    /// The attribute lists that bear on a presentation query, **nearest first**:
5012    /// the attributed spans the caret stands in (innermost first), then its
5013    /// block, then the `div`s around it. A `find_map` down this is the whole of
5014    /// each query, and the order is the rule the walker draws by.
5015    ///
5016    /// Read at the caret, and at the selection's *start* when the caret stands
5017    /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
5018    /// caret one past the span it just wrote — `toggle`'s convention — so
5019    /// asking the menu which entry that press just ticked must not answer
5020    /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
5021    fn presentation_chain(&mut self) -> Vec<Attrs> {
5022        let caret = self.caret.min(self.source.len());
5023        let mut chain = self.attr_chain_at(caret);
5024        if !chain.iter().any(|(span, _)| *span)
5025            && let Some((start, _)) = self.selection()
5026        {
5027            let alt = self.attr_chain_at(start);
5028            if alt.iter().any(|(span, _)| *span) {
5029                chain = alt;
5030            }
5031        }
5032        chain.into_iter().map(|(_, attrs)| attrs).collect()
5033    }
5034
5035    /// [`presentation_chain`](Self::presentation_chain) at one offset — every
5036    /// node bearing the vocabulary that covers it, innermost first, each paired
5037    /// with whether it is an attributed span (which is what tells the caller
5038    /// its run-level answer came from a run).
5039    ///
5040    /// Sorted by span length, which *is* the nesting order: a span lies inside
5041    /// its block and a block inside its div, so shortest-first is
5042    /// nearest-first without a second tree walk.
5043    fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
5044        let off = off.min(self.source.len());
5045        let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
5046        for n in self.nodes() {
5047            let span = wysiwyg::is_run_span(&n);
5048            let block = matches!(n.kind, Kind::Para | Kind::Heading);
5049            let div = wysiwyg::element_tag(&n) == Some("div");
5050            if !(span || block || div) {
5051                continue;
5052            }
5053            // A span is half-open, the way a mark is: the offset one past it is
5054            // the text after it. A block and a div claim their end too, so a
5055            // caret resting at the end of a line still reads its paragraph.
5056            let inside = if span {
5057                n.span.start <= off && off < n.span.end
5058            } else {
5059                n.span.start <= off && off <= n.span.end
5060            };
5061            if !inside {
5062                continue;
5063            }
5064            hits.push((n.span.end - n.span.start, span, n.attrs));
5065        }
5066        hits.sort_by_key(|(len, _, _)| *len);
5067        hits.into_iter()
5068            .map(|(_, span, attrs)| (span, attrs))
5069            .collect()
5070    }
5071
5072    /// Convert the block at the caret to a heading level or paragraph.
5073    pub fn set_block(&mut self, kind: BlockKind) {
5074        // The read-only gate — this door reaches twig without the splice.
5075        if self.read_only {
5076            return;
5077        }
5078        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
5079            return;
5080        }
5081        self.record_caret();
5082        // A blank line has no node to convert, and twig opens a block there
5083        // rather than declining — so the caret's own offset is the right thing
5084        // to hand it when `block_offset_for_caret` finds nothing.
5085        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
5086        match self.editor.set_block(offset, kind) {
5087            Ok(change) => {
5088                self.last_edit_kind = None;
5089                self.refresh();
5090                // Opening a block on a blank line writes a marker the caret
5091                // belongs *after*; converting an existing one moves nothing.
5092                self.caret = self.caret.max(change.new.end);
5093                self.clamp_caret();
5094                self.anchor = None;
5095                self.dirty = self.source != self.clean_source;
5096                self.status = None;
5097                self.record_caret();
5098            }
5099            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5100        }
5101    }
5102
5103    /// Whether `off` is inside a text block (paragraph, heading, code block…).
5104    fn has_block_at(&mut self, off: usize) -> bool {
5105        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
5106            chain
5107                .iter()
5108                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5109        })
5110    }
5111
5112    /// The offset to hand twig's `set_block`: the caret when it is already inside
5113    /// a block, otherwise nudged onto the previous character (a caret at a line
5114    /// end sits at the doc level, outside the block). `None` when the caret is on
5115    /// a blank line — a new paragraph with no block node to convert.
5116    fn block_offset_for_caret(&mut self) -> Option<usize> {
5117        let caret = self.caret.min(self.source.len());
5118        if self.has_block_at(caret) {
5119            return Some(caret);
5120        }
5121        // Nudge to the previous character — but never across a newline: that would
5122        // target the previous block, and a blank line genuinely has no block.
5123        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
5124            && ch != '\n'
5125            && self.has_block_at(i)
5126        {
5127            return Some(i);
5128        }
5129        None
5130    }
5131
5132    /// The heading level of the text block at the caret, or `None` when that
5133    /// block is not a heading.
5134    pub fn current_heading_level(&mut self) -> Option<u32> {
5135        let caret = self.caret;
5136        self.nodes()
5137            .into_iter()
5138            .filter(|n| n.kind == Kind::Heading)
5139            .find(|n| n.span.start <= caret && caret <= n.span.end)
5140            .and_then(|n| n.level)
5141    }
5142
5143    /// The inline marks in force at the caret (or over the selection) — what a
5144    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
5145    /// inline counterpart. Cheap enough to call every frame: one twig
5146    /// `ancestors_at` query per caret (two with a selection), each walking root
5147    /// → deepest node at one offset. It never snapshots the tree the way
5148    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5149    /// the only allocation is twig's own small ancestor `Vec`.
5150    ///
5151    /// **A selection reports a mark only when the mark covers *all* of it.**
5152    /// That's what every real toolbar means by an active button — Bold lit over
5153    /// a half-bold selection would claim a press turns bold *off*, when
5154    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5155    /// Whole-coverage is asked as "is the same mark node standing over both the
5156    /// first and the last character?": inline nodes are contiguous, so one node
5157    /// covering both ends covers every byte between them. Two touching runs
5158    /// (`**a****b**`) are two nodes, and correctly light nothing.
5159    ///
5160    /// At a bare caret a mark is active when the caret stands inside the mark's
5161    /// span — `span.start <= caret < span.end`, delimiters included, which is
5162    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5163    /// opening `*` (2) through the last byte of the closing `**` (9) are all
5164    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5165    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5166    /// typing would actually land inside the marked run. The offset one past the
5167    /// mark (10) is the text after it and reports nothing, at the end of the
5168    /// buffer exactly as in the middle.
5169    pub fn active_inline_marks(&mut self) -> InlineMarks {
5170        let Some((start, end)) = self.selection() else {
5171            // The marks actually in force at the caret, flipped by any armed
5172            // sticky delta — so `⌘b` at a bare caret lights the Bold button
5173            // immediately, before a single character is typed.
5174            let base: InlineMarks = self
5175                .marks_at(self.caret)
5176                .into_iter()
5177                .map(|(k, _)| k)
5178                .collect();
5179            return base.xor(self.pending_here());
5180        };
5181        // The selection's *last character*, not its exclusive end: `end` is the
5182        // offset one past the selection, which for a selection ending exactly at
5183        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5184        // entirely bold, but offset 10 is the space after).
5185        let last = prev_boundary(&self.source, end);
5186        let head = self.marks_at(start);
5187        let tail = self.marks_at(last);
5188        head.into_iter()
5189            .filter(|m| tail.contains(m))
5190            .map(|(k, _)| k)
5191            .collect()
5192    }
5193
5194    /// The inline marks whose span covers `off`, each with the id of the node
5195    /// carrying it — the id is what lets a selection tell one mark node from
5196    /// another of the same kind.
5197    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5198        let off = off.min(self.source.len());
5199        self.editor
5200            .ancestors_at(off)
5201            .unwrap_or_default()
5202            .into_iter()
5203            // `span.end` is the offset one *past* the mark, so it isn't in it.
5204            // twig already resolves a boundary to whatever starts there — in
5205            // `**bold** x` offset 8 is the following text, not the strong — but
5206            // when nothing follows, the tie has nobody to break for and the
5207            // chain still ends at the mark. That would make the answer at the
5208            // last offset of the document depend on whether the file happens to
5209            // end in a newline; the rule is `span.start <= off < span.end`, and
5210            // it's the same rule at the end of a buffer as in the middle.
5211            .filter(|m| off < m.span.end)
5212            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5213            .collect()
5214    }
5215
5216    /// Toggle a heading at the caret: if the block is already this heading level,
5217    /// revert it to a paragraph; otherwise convert it to this heading level.
5218    /// This gives the heading commands the same toggle feel as bold/italic/code —
5219    /// re-applying a heading a line already has turns it back into body text.
5220    pub fn toggle_heading(&mut self, level: u32) {
5221        if self.current_heading_level() == Some(level) {
5222            self.set_block(BlockKind::Paragraph);
5223        } else {
5224            self.set_block(BlockKind::Heading(level));
5225        }
5226    }
5227
5228    /// Toggle a block quote around the selection, or around the block at the
5229    /// caret — the toolbar's Quote button.
5230    pub fn toggle_blockquote(&mut self) {
5231        self.toggle_container(BlockContainerKind::BlockQuote);
5232    }
5233
5234    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5235    /// over the block at the caret — one op with the kind as a flag, the way
5236    /// `toggle_heading` takes its level, so a frontend needs no twig type to
5237    /// name the two buttons.
5238    ///
5239    /// Pressing the *other* list's button while in a list converts in place
5240    /// rather than nesting, so the pair reads as one three-state control
5241    /// (bulleted / numbered / neither) rather than two independent wrappers.
5242    pub fn toggle_list(&mut self, ordered: bool) {
5243        self.toggle_container(if ordered {
5244            BlockContainerKind::OrderedList
5245        } else {
5246            BlockContainerKind::BulletList
5247        });
5248    }
5249
5250    // ── Task list items ──────────────────────────────────────────────────────
5251    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5252    // inline content of the item's first paragraph rather than part of its
5253    // marker, so adding or removing one must leave the item's continuation
5254    // indentation alone, and an item inside a quote is found past the quote
5255    // markers. leaf names the gesture and the offset; the spelling is twig's.
5256
5257    /// Whether the list item at the caret carries a checkbox, and which way it
5258    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5259    /// item or no item at all. What a toolbar reads to light its checkbox button.
5260    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5261        self.task_checked_at(self.caret)
5262    }
5263
5264    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5265    /// offset — what a frontend asks before deciding a click landed on a box.
5266    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5267        self.innermost_list_item(offset.min(self.source.len()))?
5268            .checked
5269    }
5270
5271    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5272    /// A no-op with a reported reason when the caret is in no task item — minting
5273    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5274    pub fn toggle_task_checked(&mut self) {
5275        self.toggle_task_at(self.caret);
5276    }
5277
5278    /// Tick or untick the task item covering `offset` — what a *click* on a
5279    /// rendered checkbox is. Separate from the caret form because a click carries
5280    /// its own offset and must not first move the caret there: ticking a box
5281    /// three paragraphs away should not take the cursor with it.
5282    pub fn toggle_task_at(&mut self, offset: usize) {
5283        // The read-only gate — this door reaches twig without the splice.
5284        if self.read_only {
5285            return;
5286        }
5287        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5288            return;
5289        }
5290        let offset = offset.min(self.source.len());
5291        self.record_caret();
5292        match self.editor.toggle_task_checked(offset) {
5293            Ok(_) => self.after_task_edit(),
5294            Err(e) => self.status = Some(format!("task: {e}")),
5295        }
5296    }
5297
5298    /// Give the list item at the caret a checkbox, or take its checkbox away —
5299    /// the gesture that converts between a plain bullet and a task. A new box
5300    /// arrives unticked.
5301    pub fn toggle_task_item(&mut self) {
5302        // The read-only gate — this door reaches twig without the splice.
5303        if self.read_only {
5304            return;
5305        }
5306        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5307            return;
5308        }
5309        let caret = self.caret.min(self.source.len());
5310        self.record_caret();
5311        match self.editor.toggle_task_item(caret) {
5312            Ok(_) => self.after_task_edit(),
5313            Err(e) => self.status = Some(format!("task: {e}")),
5314        }
5315    }
5316
5317    /// Settle after a task gesture. The caret rides its old byte offset and is
5318    /// clamped back in: a box is three or four bytes on the item's first line, so
5319    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5320    /// real stop either way.
5321    fn after_task_edit(&mut self) {
5322        self.last_edit_kind = None;
5323        self.refresh();
5324        self.anchor = None;
5325        self.dirty = self.source != self.clean_source;
5326        self.status = None;
5327        self.clamp_caret();
5328        self.record_caret();
5329    }
5330
5331    // ── Tables ───────────────────────────────────────────────────────────────
5332    // A table is a grid, and twig edits it as one — add/remove/move a row or
5333    // column, set a column's alignment — re-spelling the whole table in a single
5334    // splice. Every gesture is anchored at the caret's cell. leaf just names the
5335    // gesture and re-reads the result; the whole table's numbering, borders, and
5336    // delimiter are twig's to keep straight.
5337
5338    /// Whether the caret is inside a table — what a frontend asks to enable or
5339    /// disable its table controls.
5340    ///
5341    /// An HTML `<table>` still answers `true`: the caret really is in a table,
5342    /// and the reason the grid controls stay dark there is
5343    /// [`Capabilities::table`], which is a fact about the document's format
5344    /// rather than about the caret. A frontend needs both.
5345    pub fn caret_in_table(&mut self) -> bool {
5346        let caret = self.caret.min(self.source.len());
5347        self.editor
5348            .ancestors_at(caret)
5349            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5350            .unwrap_or(false)
5351    }
5352
5353    /// One grid op, guarded and settled — the shared body of the seven below.
5354    ///
5355    /// The guard is why this exists rather than seven copies of the same three
5356    /// lines, and it is the one guard leaf cannot delegate to twig. The table
5357    /// editor is the gesture family that consults no `Syntax` table (it spells a
5358    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5359    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5360    /// grid as a *pipe table* and reports success, swapping the element out for
5361    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5362    /// downstream could tell that from a successful edit — the splice is real,
5363    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5364    /// stopping at the door rather than detecting after the fact. See
5365    /// [`spells_pipe_tables`].
5366    fn table_op(
5367        &mut self,
5368        what: &str,
5369        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5370    ) {
5371        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5372            return;
5373        }
5374        self.record_caret();
5375        let at = self.caret;
5376        let r = op(&mut self.editor, at);
5377        self.apply_table(r, what);
5378    }
5379
5380    /// Insert an empty row below (`below`) or above the caret's row.
5381    pub fn table_insert_row(&mut self, below: bool) {
5382        self.table_op("table row", |e, at| e.table_insert_row(at, below));
5383    }
5384
5385    /// Delete the caret's row (not the header, not the last body row).
5386    pub fn table_delete_row(&mut self) {
5387        self.table_op("table row", |e, at| e.table_delete_row(at));
5388    }
5389
5390    /// Insert an empty column right (`right`) or left of the caret's column.
5391    pub fn table_insert_column(&mut self, right: bool) {
5392        self.table_op("table column", |e, at| e.table_insert_column(at, right));
5393    }
5394
5395    /// Delete the caret's column (unless it is the only one).
5396    pub fn table_delete_column(&mut self) {
5397        self.table_op("table column", |e, at| e.table_delete_column(at));
5398    }
5399
5400    /// Set the caret's column to `alignment`.
5401    pub fn table_set_alignment(&mut self, alignment: Alignment) {
5402        self.table_op("table alignment", |e, at| {
5403            e.table_set_alignment(at, alignment)
5404        });
5405    }
5406
5407    /// Move the caret's row one place down (`down`) or up, within the body rows.
5408    pub fn table_move_row(&mut self, down: bool) {
5409        self.table_op("table row", |e, at| e.table_move_row(at, down));
5410    }
5411
5412    /// Move the caret's column one place right (`right`) or left.
5413    pub fn table_move_column(&mut self, right: bool) {
5414        self.table_op("table column", |e, at| e.table_move_column(at, right));
5415    }
5416
5417    /// Settle the caret and document flags after a table op (or report its
5418    /// error). twig re-spells the whole table, so the caret rides its old byte
5419    /// offset and is clamped back into the rebuilt bytes — near enough to where
5420    /// it was, since the op preserves the cells' content and order around it.
5421    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5422        match result {
5423            Ok(()) => {
5424                self.last_edit_kind = None;
5425                self.refresh();
5426                self.anchor = None;
5427                self.clamp_caret();
5428                self.dirty = self.source != self.clean_source;
5429                self.status = None;
5430                self.record_caret();
5431            }
5432            Err(e) => self.status = Some(format!("{what}: {e}")),
5433        }
5434    }
5435
5436    /// One `toggle_block_container` over the block-level target.
5437    ///
5438    /// leaf says *where*; twig decides everything else — which blocks the range
5439    /// covers, whether that means wrapping, unwrapping, nesting or converting,
5440    /// and how this document's format spells the prefix. The rule that a
5441    /// container only comes off when the range covers every block it holds is
5442    /// what the re-anchoring below is built around.
5443    fn toggle_container(&mut self, kind: BlockContainerKind) {
5444        // The read-only gate — this door reaches twig without the splice.
5445        if self.read_only {
5446            return;
5447        }
5448        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5449            return;
5450        }
5451        let selected = self.selection();
5452        // A blank line holds no block, and twig opens an *empty* container on one
5453        // — since 3.2.0; it used to decline the range with `NotFound`, which is
5454        // why this used to lend it a scratch paragraph to wrap. Worth knowing
5455        // here because the line-for-line caret mapping below cannot describe it:
5456        // opening one under a paragraph writes the blank line the format needs
5457        // above the marker too, so the rewritten region has a line the old one
5458        // didn't, and "the same line, the same distance from its end" lands on
5459        // that new blank instead of in the container.
5460        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5461        // Without a selection the target is the caret's own block, resolved the
5462        // way `set_block` resolves it — a caret at a line end sits at the doc
5463        // level and has to be nudged back onto the block it looks like it's in.
5464        // An empty range is enough: twig widens to the whole lines it touches.
5465        let (start, end) = match selected {
5466            Some(range) => range,
5467            None => {
5468                let off = self.block_offset_for_caret().unwrap_or(self.caret);
5469                (off, off)
5470            }
5471        };
5472        self.record_caret();
5473        match self.editor.toggle_block_container(start, end, kind) {
5474            Ok(change) => {
5475                // Read the caret's place out of the *pre-edit* source, before
5476                // `refresh` swaps that source out from under it.
5477                let place = (selected.is_none() && !opened_empty)
5478                    .then(|| self.caret_line_tail(&change.old));
5479                self.last_edit_kind = None; // structural edit is its own undo step
5480                self.refresh();
5481                match place {
5482                    // Both land the caret at the far end of what twig wrote, and
5483                    // differ only in what they leave selected.
5484                    //
5485                    // From a selection: select what the container now holds, the
5486                    // way `toggle` keeps its marked region selected — and for a
5487                    // stronger reason than symmetry: a container comes *off* only
5488                    // a range covering every block it holds, so a selection left
5489                    // on its old bytes (now short by a prefix per line) would nest
5490                    // on the second press instead of reversing the first.
5491                    //
5492                    // From a blank line: nothing to select, and the end of the
5493                    // region is exactly past the bare `> ` / `- ` twig wrote —
5494                    // the caret standing inside the container that was asked for.
5495                    None => {
5496                        self.anchor = (!opened_empty).then_some(change.new.start);
5497                        self.caret = change.new.end;
5498                    }
5499                    Some(place) => {
5500                        self.anchor = None;
5501                        self.caret = self.line_tail_offset(&change.new, place);
5502                    }
5503                }
5504                self.dirty = self.source != self.clean_source;
5505                self.status = None;
5506                self.clamp_caret();
5507                self.record_caret();
5508            }
5509            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5510        }
5511    }
5512
5513    /// The caret's place inside the region a container toggle is rewriting, in
5514    /// the only terms the rewrite preserves: which of the region's lines it sits
5515    /// on, and how many bytes of that line lie ahead of it.
5516    ///
5517    /// A container's markup goes in at column 0 and never touches what follows
5518    /// on the line, so that pair survives the edit exactly where a byte offset
5519    /// does not — a caret left on its old offset slides back by one prefix per
5520    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
5521    /// `> ` it just asked for.
5522    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
5523        let caret = self.caret.clamp(old.start, old.end);
5524        let line = self.source[old.start..caret].matches('\n').count();
5525        let end = self.source[caret..old.end]
5526            .find('\n')
5527            .map_or(old.end, |i| caret + i);
5528        (line, end - caret)
5529    }
5530
5531    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
5532    /// region: the offset `tail` bytes back from the end of the region's `line`.
5533    ///
5534    /// Both walks are clamped rather than trusted, because the one op that does
5535    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
5536    /// the items back apart with blank lines between them — and a caret landing
5537    /// on the nearest line of the right item beats one landing out of the region
5538    /// entirely.
5539    fn line_tail_offset(
5540        &self,
5541        new: &std::ops::Range<usize>,
5542        (line, tail): (usize, usize),
5543    ) -> usize {
5544        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
5545        let mut start = 0;
5546        for _ in 0..line {
5547            match region[start..].find('\n') {
5548                Some(i) => start += i + 1,
5549                None => break,
5550            }
5551        }
5552        let end = region[start..]
5553            .find('\n')
5554            .map_or(region.len(), |i| start + i);
5555        new.start + end.saturating_sub(tail).max(start)
5556    }
5557
5558    /// Link the selection to `destination` — the toolbar's Link button. With no
5559    /// selection it acts at the caret, which re-points a link the caret is
5560    /// already standing in (twig replaces an existing link's destination and
5561    /// keeps its text) and otherwise spells a link that has no text of its own:
5562    /// an autolink (`<https://x.dev>`) where the destination is one, and
5563    /// `[destination](destination)` where it isn't.
5564    ///
5565    /// `destination` reaches twig raw. Escaping it is format knowledge and the
5566    /// two formats genuinely disagree — Markdown ends a destination at the first
5567    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
5568    /// itself — so the side holding the document is the side that gets to spell
5569    /// it. A destination twig can't carry at all (one with a newline) comes back
5570    /// as an error rather than a quietly rewritten URL.
5571    pub fn insert_link(&mut self, destination: &str) {
5572        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
5573            return;
5574        }
5575        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5576        self.record_caret();
5577        match self.editor.insert_link(start, end, destination) {
5578            Ok(change) => {
5579                self.last_edit_kind = None;
5580                self.refresh();
5581                match self.link_text_span(change.new.start) {
5582                    // A link with text of its own: select it, so typing replaces
5583                    // a `[dest](dest)`'s stand-in label and a second press
5584                    // re-points what the first one linked.
5585                    Some(text) => {
5586                        self.anchor = (text.start != text.end).then_some(text.start);
5587                        self.caret = text.end;
5588                    }
5589                    // An autolink is finished the moment it's written — its text
5590                    // *is* the URL. Leaving it selected would aim the next press
5591                    // at the one shape twig still wraps instead of re-points.
5592                    None => {
5593                        self.anchor = None;
5594                        self.caret = change.new.end;
5595                    }
5596                }
5597                self.dirty = self.source != self.clean_source;
5598                self.status = None;
5599                self.clamp_caret();
5600                self.record_caret();
5601            }
5602            Err(e) => self.status = Some(format!("link: {e}")),
5603        }
5604    }
5605
5606    /// Insert a block-level image at the caret: `![alt](destination)`. Any
5607    /// selection becomes the alt text (so "select a caption, insert image" labels
5608    /// it); with no selection, `alt` is used — empty for none. The caret lands
5609    /// just past the inserted image.
5610    ///
5611    /// Both halves go through twig (`insert_literal` for the alt text,
5612    /// `insert_image` for the image), so neither is spelled here. That used to be a
5613    /// `format!`, and it was wrong the first time an app inserted a real filename:
5614    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
5615    /// not an image at all — and the fix is per-format, since moving into the
5616    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
5617    pub fn insert_image(&mut self, destination: &str, alt: &str) {
5618        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
5619            return;
5620        }
5621        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5622        self.record_caret();
5623        // With no selection and an explicit `alt`, the alt text has to exist in the
5624        // document before it can be the image's — and it is raw caller input, so
5625        // it goes in through `insert_literal`, which escapes it for the format
5626        // rather than letting a `]` in someone's caption close the image early.
5627        let (start, end) = if start == end && !alt.is_empty() {
5628            match self.editor.insert_literal(start, alt) {
5629                Ok(change) => (change.new.start, change.new.end),
5630                Err(e) => {
5631                    self.status = Some(format!("image: {e}"));
5632                    return;
5633                }
5634            }
5635        } else {
5636            (start, end)
5637        };
5638        match self.editor.insert_image(start, end, destination) {
5639            Ok(change) => {
5640                self.last_edit_kind = None;
5641                self.refresh();
5642                // Just past the image, nothing selected — where a caret belongs
5643                // after inserting one.
5644                self.anchor = None;
5645                self.caret = change.new.end;
5646                self.dirty = self.source != self.clean_source;
5647                self.status = None;
5648                self.clamp_caret();
5649                self.record_caret();
5650            }
5651            Err(e) => self.status = Some(format!("image: {e}")),
5652        }
5653    }
5654
5655    /// Insert a block-level image, video, or audio at the caret. The image case
5656    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
5657    /// HTML elements, which is the only spelling Markdown and Djot have for them:
5658    ///
5659    /// ```text
5660    /// <video src="clip.mp4" controls>alt</video>
5661    /// <audio src="take.mp3" controls>alt</audio>
5662    /// ```
5663    ///
5664    /// HTML rather than a `::video{…}` directive deliberately. A directive means
5665    /// something only to an app that knows the vocabulary, so the document would
5666    /// read as literal punctuation everywhere else; `<video>` is what every other
5667    /// renderer already understands, and what leaf's own reader picks back up
5668    /// through `html_elements` promotion (see [`parse_extensions`]).
5669    ///
5670    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
5671    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
5672    /// `html_elements`. Before that only the multi-line form parsed as a block at
5673    /// all, and this wrote three lines to work around it.
5674    ///
5675    /// `controls` is always written: a player with no transport is a still frame
5676    /// the reader can't do anything with. Any selection becomes the element's
5677    /// fallback text, exactly as it becomes an image's alt.
5678    ///
5679    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
5680    /// applies, and bites harder here: a `"` in `destination` closes the
5681    /// attribute. A frontend taking these from a file picker is fine; one taking
5682    /// them from free text should keep them tame.
5683    ///
5684    /// [`MediaInfo`]: crate::MediaInfo
5685    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
5686        if kind == MediaKind::Image {
5687            return self.insert_image(destination, alt);
5688        }
5689        // Gated on the *image* gesture, not on one of its own — there isn't one,
5690        // since the bytes below are spelled here rather than by twig, and an HTML
5691        // document would in fact parse them. The button is one control with three
5692        // kinds behind it, and two of them working in a format where the third
5693        // cannot is a worse surface than three that agree — especially as
5694        // `insert_image` is the kind anyone reaches for first.
5695        if self.refuse_unsupported("media", Gesture::InsertImage) {
5696            return;
5697        }
5698        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
5699        let alt_text = self
5700            .selected_text()
5701            .map(str::to_string)
5702            .unwrap_or_else(|| alt.to_string());
5703        let tag = match kind {
5704            MediaKind::Audio => "audio",
5705            _ => "video",
5706        };
5707        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
5708        self.edit(start, end, &markup);
5709    }
5710
5711    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
5712    /// button. Spelling and placement are both twig's; leaf used to write `---`
5713    /// itself, which was the Markdown spelling in a djot document too.
5714    ///
5715    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
5716    /// mid-paragraph and lands it after the caret's whole block. To get a rule
5717    /// *at* the caret — the paragraph parted in two around it, which is what a
5718    /// rule button is understood to do — the paragraph is first divided with
5719    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
5720    /// the offset `split_block` returns puts the rule after the *second* half
5721    /// instead, which is a rule in the right document and the wrong place.
5722    ///
5723    /// Only a plain paragraph is split, and only where there is something to
5724    /// part: at the paragraph's end the split has no second half to mint and
5725    /// would write the separator anyway — a blank line and the empty slot Enter
5726    /// leaves for the next paragraph, which the rule then lands above and
5727    /// nothing fills — so there the rule goes straight after the paragraph,
5728    /// which is where the split-and-aim was sending it regardless. At the
5729    /// paragraph's *start* the split is kept, though it parts nothing either:
5730    /// `|para` becomes `\npara` with the caret on the new blank line, and a
5731    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
5732    /// "before the paragraph" is said through a gesture that only knows
5733    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
5734    /// else the rule simply lands after the block, which is both twig's own
5735    /// answer and the better one: splitting a fenced code block would leave two
5736    /// fences with a rule between them, and splitting a list item would mint an
5737    /// item nobody asked for on the way to a rule that lands after the list
5738    /// regardless. A table and a setext heading refuse the split outright, so
5739    /// they take the same path by themselves.
5740    pub fn insert_thematic_break(&mut self) {
5741        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
5742        {
5743            return;
5744        }
5745        self.caret = self.skip_trailing_close_delims(self.caret);
5746        // A selection is replaced by the rule, so collapse it first and let the
5747        // split-and-rule below run from the caret it leaves behind.
5748        if let Some((s, e)) = self.selection() {
5749            self.splice(s, e, "", EditKind::Other);
5750        }
5751        self.anchor = None;
5752        self.record_caret();
5753        let at = self.caret;
5754        if self.caret_parts_bare_paragraph() {
5755            // A failure here is not fatal: the rule still lands after the block,
5756            // which is exactly what this call was trying to improve on.
5757            let _ = self.editor.split_block(at);
5758        }
5759        match self.editor.insert_thematic_break(at) {
5760            Ok(change) => {
5761                self.last_edit_kind = None;
5762                self.refresh();
5763                self.anchor = None;
5764                self.caret = change.new.end;
5765                self.dirty = self.source != self.clean_source;
5766                self.status = None;
5767                self.clamp_caret();
5768                self.record_caret();
5769            }
5770            Err(e) => self.status = Some(format!("thematic break: {e}")),
5771        }
5772    }
5773
5774    /// Insert a fresh table at the caret — the toolbar's Table button. One
5775    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
5776    /// the document's own dialect and placed the way its thematic break is:
5777    /// after the caret's block, blank-separated. A bare paragraph is parted
5778    /// around the caret first, exactly as
5779    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
5780    /// table lands *at* the caret rather than after everything the caret's
5781    /// paragraph says.
5782    ///
5783    /// The caret ends in the first header cell, selected the way Tab selects
5784    /// a cell — the natural next act is to type the heading, and Tab then
5785    /// walks the grid. That cell is read back from the rebuilt table map
5786    /// rather than computed from the splice, because twig's blank line and
5787    /// quote prefix put the first bar at an offset only the reparse knows.
5788    ///
5789    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
5790    /// rows or columns is twig's refusal (a header with nothing under it is
5791    /// what its row delete refuses to leave), reported through `status`.
5792    pub fn insert_table(&mut self, rows: usize, cols: usize) {
5793        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
5794            return;
5795        }
5796        self.caret = self.skip_trailing_close_delims(self.caret);
5797        if let Some((s, e)) = self.selection() {
5798            self.splice(s, e, "", EditKind::Other);
5799        }
5800        self.anchor = None;
5801        self.record_caret();
5802        let at = self.caret;
5803        if self.caret_parts_bare_paragraph() {
5804            let _ = self.editor.split_block(at);
5805        }
5806        match self.editor.insert_table(at, rows, cols) {
5807            Ok(change) => {
5808                self.last_edit_kind = None;
5809                self.refresh();
5810                self.anchor = None;
5811                self.caret = change.new.end;
5812                self.dirty = self.source != self.clean_source;
5813                self.status = None;
5814                self.clamp_caret();
5815                // Into the first header cell of the table just written: the
5816                // first table whose grid begins inside the splice.
5817                self.rebuild_map();
5818                let first_cell = self
5819                    .vmap
5820                    .tables
5821                    .iter()
5822                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
5823                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
5824                    .map(|cell| (cell.start, cell.end));
5825                if let Some((start, end)) = first_cell {
5826                    self.select_cell(start, end);
5827                }
5828                self.record_caret();
5829            }
5830            Err(e) => self.status = Some(format!("table: {e}")),
5831        }
5832    }
5833
5834    /// Whether the caret sits in a paragraph and nothing else — no list item, no
5835    /// quote, no fence, no table — with paragraph text still ahead of it. The
5836    /// one shape where parting the block around the caret is unambiguously what
5837    /// a rule button means; see
5838    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
5839    /// container is left to take the rule after itself.
5840    ///
5841    /// The "text ahead" half is what keeps `split_block` from running at the
5842    /// one edge where its output composes badly. At a paragraph's end twig
5843    /// cannot mint the empty second half (no format spells an empty
5844    /// paragraph), so it writes only the separator — a blank line and the
5845    /// slot Enter leaves for the paragraph to come — and a block then aimed at
5846    /// the first half lands above a slot that nothing fills: `para\n` with the
5847    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
5848    /// as nothing ahead, since the split would shed it as the second half's
5849    /// leading indent and leave the same slot. Which end of the newline a
5850    /// paragraph's span stops at differs between the formats (Markdown before
5851    /// it, djot after), which is why this reads the remaining bytes rather
5852    /// than comparing offsets. The paragraph's
5853    /// start is deliberately not the same case — see
5854    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
5855    /// split is kept.
5856    fn caret_parts_bare_paragraph(&mut self) -> bool {
5857        let caret = self.caret.min(self.source.len());
5858        let Ok(chain) = self.editor.ancestors_at(caret) else {
5859            return false;
5860        };
5861        let mut para_end = None;
5862        for m in chain {
5863            match m.kind {
5864                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
5865                Kind::ListItem
5866                | Kind::TaskListItem
5867                | Kind::BlockQuote
5868                | Kind::CodeBlock
5869                | Kind::Table => return false,
5870                _ => {}
5871            }
5872        }
5873        match para_end {
5874            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
5875            _ => false,
5876        }
5877    }
5878
5879    /// The destination of the link under the caret — what a Link prompt shows so
5880    /// ⌘K on an existing link edits its URL instead of asking for it again.
5881    /// `None` when the caret stands in no link.
5882    ///
5883    /// An autolink carries no separate destination: its text *is* the URL, so
5884    /// that's what comes back for one.
5885    pub fn link_destination_at_caret(&mut self) -> Option<String> {
5886        self.link_destination_at(self.caret)
5887    }
5888
5889    /// The destination of the link at `off`.
5890    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
5891    /// the caret isn't.
5892    ///
5893    /// The offset form exists for the same reason
5894    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
5895    /// the document somewhere else — a footnote's text in a popover, say — has
5896    /// rows and runs but no caret in them, and still needs to know which of those
5897    /// runs a reader can follow.
5898    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
5899        self.nodes()
5900            .into_iter()
5901            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
5902            .filter(|n| n.span.start <= off && off < n.span.end)
5903            .max_by_key(|n| n.span.start)
5904            .and_then(|n| n.destination.or(n.text))
5905    }
5906
5907    /// Where the locator `id` lands in this document — the `#v2` half of a
5908    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
5909    /// answers to it.
5910    ///
5911    /// The other end of a link, and the reason this exists: without it a
5912    /// destination has only file granularity, so following a citation into a
5913    /// chapter drops the reader at the top of it to hunt for the verse. Which is
5914    /// also why it is a *document* query rather than a caret one — the document
5915    /// being asked is usually not the one the reader is in.
5916    ///
5917    /// Three readings, tried in order, because the same `#some-heading` is
5918    /// written three ways across the formats leaf opens:
5919    ///
5920    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
5921    ///    the auto-ids djot mints for its headings. The only exact answer, so it
5922    ///    goes first — a document that says `{#v1}` has settled the question.
5923    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
5924    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
5925    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
5926    ///    authored anywhere land on a djot heading.
5927    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
5928    ///    none and `{#custom}` is literal text in a Markdown heading — so for
5929    ///    the format most vaults are written in, the heading's own words are the
5930    ///    only thing a fragment can name. This is the rule every Markdown
5931    ///    renderer already follows, which is what makes `#a-heading` mean in
5932    ///    diaryx what it means on the web.
5933    ///
5934    /// Ties go to the earliest match, then to the widest: a duplicated id is the
5935    /// document's mistake and the first one is the answer every anchor
5936    /// implementation gives, while preferring the wider span picks the section
5937    /// over the heading that opens it — more for a peek to show, same place to
5938    /// land.
5939    pub fn locate(&mut self, id: &str) -> Option<Landing> {
5940        let id = id.trim();
5941        if id.is_empty() {
5942            return None;
5943        }
5944        let nodes = self.nodes();
5945
5946        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
5947        // is picking, among nodes that start together, the one that ends last.
5948        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
5949            matches
5950                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
5951                .map(|n| Landing {
5952                    start: n.span.start,
5953                    end: n.span.end,
5954                })
5955        };
5956
5957        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
5958            return Some(landing);
5959        }
5960        let want = slug(id);
5961        if want.is_empty() {
5962            return None;
5963        }
5964        if let Some(landing) = pick(
5965            &mut nodes
5966                .iter()
5967                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
5968        ) {
5969            return Some(landing);
5970        }
5971
5972        // A heading by its words. Its span is one line, so the end comes from
5973        // where the *section* it opens gives out — the next heading that is not
5974        // under it, or the end of the document. A Markdown heading has no
5975        // section node to ask (twig only builds those for djot), and a peek that
5976        // showed the heading alone would answer "what does that say" with the
5977        // title of the thing it says.
5978        let heading = nodes
5979            .iter()
5980            .filter(|n| n.kind == Kind::Heading)
5981            .filter(|n| {
5982                n.content_span
5983                    .clone()
5984                    .and_then(|s| self.source.get(s))
5985                    .is_some_and(|text| slug(text) == want)
5986            })
5987            .min_by_key(|n| n.span.start)?;
5988        let level = heading.level.unwrap_or(u32::MAX);
5989        let end = nodes
5990            .iter()
5991            .filter(|n| n.kind == Kind::Heading)
5992            .filter(|n| n.span.start > heading.span.start)
5993            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
5994            .map(|n| n.span.start)
5995            .min()
5996            .unwrap_or(self.source.len());
5997        Some(Landing {
5998            start: heading.span.start,
5999            end,
6000        })
6001    }
6002
6003    /// Write a footnote at the caret — the toolbar's Footnote button, and the
6004    /// one gesture in the footnote story that *authors* rather than follows.
6005    ///
6006    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
6007    /// the `[^1]:` definition at the end of the document. Half a footnote is not
6008    /// a footnote — a bare reference with nothing defining it renders as literal
6009    /// brackets — so a single button that wrote only the reference would leave
6010    /// the author to hand-spell the other half in a document that had just
6011    /// stopped showing them what the first half meant. One edit also means one
6012    /// undo takes both back.
6013    ///
6014    /// The definition's body is left empty and **the caret lands in it**, which
6015    /// is the whole point of pressing the button: nobody wants a reference to a
6016    /// note they have not written yet. Getting back to where they were writing
6017    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
6018    /// the same return leg a reader following a reference already uses, so the
6019    /// author is left standing on the near end of a round trip that works.
6020    ///
6021    /// A selection collapses to its *end* rather than being replaced: a
6022    /// reference annotates the words before it, so "select the claim, add a
6023    /// footnote" should mark that claim, not consume it.
6024    pub fn insert_footnote(&mut self) {
6025        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
6026            return;
6027        }
6028        let at = self.selection().map_or(self.caret, |(_, end)| end);
6029        self.anchor = None;
6030        self.caret = at;
6031        self.record_caret();
6032        let label = self.next_footnote_label();
6033        match self.editor.insert_footnote(at, &label) {
6034            Ok(change) => {
6035                self.last_edit_kind = None;
6036                self.refresh();
6037                self.anchor = None;
6038                // `change.new` runs from the reference to the end of the
6039                // document, so its start is the `[^1]` just written and
6040                // `footnote_at` resolves it to the note the same way a reader's
6041                // tap does — and to the note's *body*, which is already a caret
6042                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
6043                // and has none), so this needs no snap on top. The fallback is
6044                // the reference's own offset: a format that spelled the pair some
6045                // way leaf can't read back should still leave the caret on the
6046                // edit rather than at the far end of a document it just grew.
6047                self.caret = self
6048                    .footnote_at(change.new.start)
6049                    .and_then(|note| note.offset)
6050                    .unwrap_or(change.new.start);
6051                self.dirty = self.source != self.clean_source;
6052                self.status = None;
6053                self.clamp_caret();
6054                self.record_caret();
6055            }
6056            Err(e) => self.status = Some(format!("footnote: {e}")),
6057        }
6058    }
6059
6060    /// The label to give a footnote the author has not named: the lowest counting
6061    /// number no footnote in the document is already wearing.
6062    ///
6063    /// twig takes the label rather than minting one, because it holds no opinion
6064    /// about what a document's footnotes should be called — and it is right not
6065    /// to. Numbering them is what every author of a numbered note expects, and
6066    /// re-using a taken number would silently point the new reference at somebody
6067    /// else's note (twig reuses an existing definition rather than appending a
6068    /// second one, which is the right rule for citing a note twice on purpose and
6069    /// exactly the wrong accident to have by default).
6070    ///
6071    /// *References* are counted alongside definitions, not just definitions: a
6072    /// document carrying a dangling `[^2]` has a 2 that means something to
6073    /// whoever wrote it, and minting a definition for it here would answer a
6074    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
6075    /// count entirely — they take no number, so they block none.
6076    fn next_footnote_label(&mut self) -> String {
6077        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
6078            .into_iter()
6079            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
6080            .filter_map(|label| label.parse().ok())
6081            .collect();
6082        taken.extend(
6083            self.nodes()
6084                .into_iter()
6085                .filter(|n| n.kind == Kind::FootnoteReference)
6086                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
6087                .filter_map(|label| label.parse::<u32>().ok()),
6088        );
6089        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
6090    }
6091
6092    /// The footnote reference under the caret, resolved to the note it names.
6093    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
6094    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
6095        self.footnote_at(self.caret)
6096    }
6097
6098    /// The footnote reference at `off`, resolved to the note it names — what a
6099    /// frontend shows when a reader activates a `[^1]`.
6100    ///
6101    /// A reference is not a link node, so
6102    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
6103    /// (and should not) answer for one: a link names a destination to leave for,
6104    /// a reference names a note that is already in this document. Following one
6105    /// is a move within the page, which is why this hands back an `offset`
6106    /// rather than something to open.
6107    ///
6108    /// Offset-based rather than caret-only because the gesture that wants this
6109    /// most is the one that must not move the caret: a pointer hovering a `[1]`
6110    /// asks what note it names without disturbing where the reader was typing.
6111    /// The caret is just the offset a click already placed —
6112    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
6113    ///
6114    /// `None` when `off` stands in no reference. A reference whose note the
6115    /// document never defines is *not* `None` — it answers with the label it
6116    /// looked for and no text, which is what lets a frontend say so instead of
6117    /// silently doing nothing.
6118    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
6119        // Innermost-wins by latest start, the rule its link sibling uses.
6120        let span = self
6121            .nodes()
6122            .into_iter()
6123            .filter(|n| n.kind == Kind::FootnoteReference)
6124            .filter(|n| n.span.start <= off && off < n.span.end)
6125            .max_by_key(|n| n.span.start)?
6126            .span;
6127        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
6128
6129        // The note itself. Definitions are roots beside `doc` rather than
6130        // children of it, so they're asked for directly — see
6131        // `wysiwyg::footnote_definitions`.
6132        let note = wysiwyg::footnote_definitions(&mut self.editor)
6133            .into_iter()
6134            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
6135        let Some(note) = note else {
6136            return Some(FootnoteRef {
6137                label,
6138                text: None,
6139                offset: None,
6140                end: None,
6141            });
6142        };
6143        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
6144        Some(FootnoteRef {
6145            label,
6146            text: body
6147                .clone()
6148                .and_then(|b| self.source.get(b))
6149                .map(str::to_string),
6150            // The body's start, not the definition's — see `FootnoteRef::offset`.
6151            offset: body.clone().map(|b| b.start),
6152            end: body.map(|b| b.end),
6153        })
6154    }
6155
6156    /// The footnote *definition* the caret stands in, and where the reference
6157    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6158    /// at the caret's offset.
6159    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6160        self.footnote_definition_at(self.caret)
6161    }
6162
6163    /// The footnote definition spanning `off`, and where the reference that
6164    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6165    ///
6166    /// The mirror image, deliberately: the same gesture that takes a reader from
6167    /// `[1]` down to the note takes them from the note back up to `[1]`, so
6168    /// following a footnote is a round trip rather than a fall. It needs no
6169    /// memory of how the reader arrived — the document says where the reference
6170    /// is — which is what makes it work for a reader who scrolled to the notes
6171    /// themselves, and what keeps it right after an edit moves either end.
6172    ///
6173    /// `None` when `off` stands in no definition. A definition nothing cites is
6174    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6175    /// its label and no offset, so a frontend can say "nothing refers to this"
6176    /// rather than offer a jump that goes nowhere.
6177    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6178        // Definitions are roots beside `doc`, so `nodes()` — which walks the
6179        // document body — never reports one. They're asked for directly, the way
6180        // `footnote_at` asks for the note it resolves to.
6181        //
6182        // Closed at the end, unlike the half-open test its neighbours use. A
6183        // definition's span stops at its last content byte — the newline ending
6184        // the line is outside it — so `span.end` is the caret stop at the end of
6185        // the note's own row, not the first byte of anything after. Excluding it
6186        // meant the one caret an author is guaranteed to have, the one left
6187        // sitting at the end of the note they just typed, was in no definition at
6188        // all: writing a note and then asking to go back to its reference
6189        // answered nothing. Two definitions in a row still can't both match —
6190        // there is a blank line between them — and `max_by_key` decides anyway.
6191        let note = wysiwyg::footnote_definitions(&mut self.editor)
6192            .into_iter()
6193            .filter(|m| m.span.start <= off && off <= m.span.end)
6194            .max_by_key(|m| m.span.start)?;
6195        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6196
6197        // The earliest reference carrying this label. `min` rather than a `find`,
6198        // because `nodes()` reports a flattened walk whose order is twig's
6199        // business, not document order. Bound first: the walk needs `&mut self`
6200        // and reading the labels back out needs `&self.source`.
6201        let nodes = self.nodes();
6202        let offset = nodes
6203            .into_iter()
6204            .filter(|n| n.kind == Kind::FootnoteReference)
6205            .filter(|n| {
6206                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6207            })
6208            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6209            .map(|n| n.span.start + 2)
6210            .min();
6211        Some(FootnoteDef { label, offset })
6212    }
6213
6214    /// The destination of the image under the caret — what an image prompt shows
6215    /// so editing an existing image starts from its current URL instead of blank,
6216    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6217    /// `None` when the caret stands in no image. A caret resting just after a
6218    /// block image (its trailing stop) is still "in" it — the half-open span test
6219    /// excludes that offset, which is the intended precision: past the image is
6220    /// past it.
6221    pub fn image_destination_at_caret(&mut self) -> Option<String> {
6222        let off = self.caret;
6223        self.nodes()
6224            .into_iter()
6225            .filter(|n| n.kind == Kind::Image)
6226            .filter(|n| n.span.start <= off && off < n.span.end)
6227            .max_by_key(|n| n.span.start)
6228            .and_then(|n| n.destination)
6229    }
6230
6231    /// The language of the fenced code block the caret stands in — what a
6232    /// language prompt shows so editing it starts from the current value rather
6233    /// than blank. `None` when the caret is in no code block, or in one whose
6234    /// fence carries no language (or an indented block, which has no fence).
6235    pub fn code_language_at_caret(&mut self) -> Option<String> {
6236        let start = self.code_block_start_at_caret()?;
6237        wysiwyg::code_language(&self.source, start)
6238    }
6239
6240    /// Whether the caret stands in a fenced code block — the one a language
6241    /// prompt could edit. A frontend gates its "set language" affordance on this
6242    /// (an indented block, which can't carry a language, reports `false`).
6243    pub fn caret_in_fenced_code(&mut self) -> bool {
6244        self.code_block_start_at_caret()
6245            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6246    }
6247
6248    /// Set (or clear, with `""`) the language of the fenced code block the caret
6249    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6250    /// block, and a reported error for a language the format's fence cannot
6251    /// carry.
6252    ///
6253    /// twig rewrites the info string, so the fence's own width — measured
6254    /// against a body neither side touches — is kept, and a language holding a
6255    /// space, a line end or the fence character is refused rather than written
6256    /// out to reparse as something else. Leaf used to splice over the info span
6257    /// itself and `trim()` the input, which handled the one bad case it had
6258    /// thought of.
6259    pub fn set_code_language(&mut self, lang: &str) {
6260        // The read-only gate — this door reaches twig without the splice.
6261        if self.read_only {
6262            return;
6263        }
6264        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6265            return;
6266        }
6267        if self.code_block_start_at_caret().is_none() {
6268            return;
6269        }
6270        let lang = lang.trim();
6271        // `None` clears the info string; `Some("")` asks for an empty one. Both
6272        // write a bare fence, and the prompt's empty value means "clear".
6273        let want = (!lang.is_empty()).then_some(lang);
6274        self.record_caret();
6275        match self.editor.set_code_language(self.caret, want) {
6276            Ok(_) => {
6277                self.last_edit_kind = None;
6278                self.refresh();
6279                self.anchor = None;
6280                self.dirty = self.source != self.clean_source;
6281                self.status = None;
6282                self.clamp_caret();
6283                self.record_caret();
6284            }
6285            Err(e) => self.status = Some(format!("code language: {e}")),
6286        }
6287    }
6288
6289    /// The `span.start` of the code block covering the caret — the anchor
6290    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6291    /// in none.
6292    fn code_block_start_at_caret(&mut self) -> Option<usize> {
6293        let off = self.caret;
6294        self.nodes()
6295            .into_iter()
6296            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6297            .max_by_key(|n| n.span.start)
6298            .map(|n| n.span.start)
6299    }
6300
6301    /// The source range of the text inside the link covering `off` — what sits
6302    /// between its `[` and `]`. `None` when twig reports no link there.
6303    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6304        self.nodes()
6305            .into_iter()
6306            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6307            // the other's `span.start`; the link that starts latest at or before
6308            // `off` is the one `off` is actually in.
6309            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6310            .max_by_key(|n| n.span.start)
6311            .and_then(|n| n.content_span)
6312    }
6313
6314    // ── undo / redo ───────────────────────────────────────────────────────────
6315    // twig owns the history of *bytes* (it owns the buffer) and now carries the
6316    // caret through it too: `record_caret` stashes each state's caret in twig's
6317    // opaque per-step blob, and undo/redo hand it back with the source they
6318    // restore. So leaf keeps no history of its own — no parallel stacks to march
6319    // in lockstep and silently drift out of it.
6320
6321    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6322    /// where they were when that step began.
6323    pub fn undo(&mut self) {
6324        if self.read_only {
6325            return;
6326        }
6327        let (undone, redoable) = (self.undo_steps, self.redo_steps);
6328        match self.editor.undo() {
6329            Ok(Some(change)) => {
6330                self.after_history(change);
6331                // `refresh` counted the restore as an edit; it was a step back.
6332                self.undo_steps = undone.saturating_sub(1);
6333                self.redo_steps = redoable + 1;
6334            }
6335            Ok(None) => {
6336                self.undo_steps = 0;
6337                self.status = Some("nothing to undo".into());
6338            }
6339            Err(e) => self.status = Some(format!("undo: {e}")),
6340        }
6341    }
6342
6343    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6344    /// selection back where that step originally left them.
6345    pub fn redo(&mut self) {
6346        if self.read_only {
6347            return;
6348        }
6349        let (undone, redoable) = (self.undo_steps, self.redo_steps);
6350        match self.editor.redo() {
6351            Ok(Some(change)) => {
6352                self.after_history(change);
6353                // `refresh` counted the restore as an edit; it was a step forward.
6354                self.undo_steps = undone + 1;
6355                self.redo_steps = redoable.saturating_sub(1);
6356            }
6357            Ok(None) => {
6358                self.redo_steps = 0;
6359                self.status = Some("nothing to redo".into());
6360            }
6361            Err(e) => self.status = Some(format!("redo: {e}")),
6362        }
6363    }
6364
6365    /// Refresh the cached source and put the caret back where the step being
6366    /// undone/redone had it, clearing any active run.
6367    ///
6368    /// The caret comes from twig's blob for the restored state (what
6369    /// `record_caret` stored). `change` is only the fallback for a state with no
6370    /// blob — a caret at the end of the restored text, which is where this always
6371    /// landed before the blobs were kept. It is the edit site, not where the user
6372    /// was standing, so it's a floor and not the behaviour: undoing should hand
6373    /// back the document *and* the place you were working, which for an edit made
6374    /// anywhere but under the caret are two different places.
6375    fn after_history(&mut self, change: Change) {
6376        self.refresh();
6377        match self
6378            .editor
6379            .caret_blob()
6380            .ok()
6381            .and_then(|b| CaretState::from_blob(&b))
6382        {
6383            Some(state) => {
6384                self.caret = state.caret.min(self.source.len());
6385                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6386            }
6387            None => {
6388                self.caret = change.new.end.min(self.source.len());
6389                self.anchor = None;
6390            }
6391        }
6392        self.goal_col = None;
6393        self.last_edit_kind = None;
6394        self.dirty = self.source != self.clean_source;
6395        self.status = None;
6396        self.clamp_caret();
6397    }
6398
6399    // ── the file ──────────────────────────────────────────────────────────────
6400
6401    #[cfg(feature = "fs")]
6402    pub fn save(&mut self) {
6403        if self.is_untitled() {
6404            // No path to write and no name to invent: ⌘S on an untitled document
6405            // is a Save As, and only a frontend has a picker to ask with. Say so
6406            // rather than failing at the filesystem with an empty path.
6407            self.status = Some("untitled — save as…".into());
6408            return;
6409        }
6410        let path = self.path.clone();
6411        if self.write(&path) {
6412            self.mark_saved();
6413        }
6414    }
6415
6416    /// Save As: write the document to `path` and *move* it there — `self.path`
6417    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6418    /// what Save As means; a copy would leave the user editing a document whose
6419    /// name is no longer where their keystrokes go.
6420    ///
6421    /// The move only happens if the bytes actually landed. A failed write leaves
6422    /// the path, `dirty`, and the disk watermark exactly as they were, with the
6423    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6424    /// must never come away believing it was saved.
6425    ///
6426    /// An existing `path` is overwritten, and the caller is the one that knows
6427    /// whether to ask first: a Save As picker has already run that prompt, and a
6428    /// second confirmation from down here would be the same question twice.
6429    ///
6430    /// `format` does **not** follow the new extension. The buffer is parsed as
6431    /// the format it was opened with, and re-reading it as another one is a
6432    /// conversion — a different, lossy operation that would throw away the undo
6433    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6434    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6435    /// until it's reopened.
6436    #[cfg(feature = "fs")]
6437    pub fn save_as(&mut self, path: PathBuf) {
6438        if !self.write(&path) {
6439            return;
6440        }
6441        self.path = path;
6442        self.mark_saved();
6443    }
6444
6445    /// Put `source` on disk at `path`, reporting whether it got there. The one
6446    /// place leaf writes a document, so a save and a Save As can't disagree
6447    /// about what a failure looks like.
6448    #[cfg(feature = "fs")]
6449    fn write(&mut self, path: &Path) -> bool {
6450        match std::fs::write(path, self.source.as_bytes()) {
6451            Ok(()) => true,
6452            Err(e) => {
6453                self.status = Some(format!("save failed: {e}"));
6454                false
6455            }
6456        }
6457    }
6458
6459    /// Re-base the document's saved watermark to the current bytes: clears
6460    /// `dirty`, records `source` as the new clean state (so undoing back to here
6461    /// clears the flag again), and re-stamps the on-disk hash.
6462    ///
6463    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
6464    /// the hook a **filesystem-free host** calls itself once it has persisted
6465    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
6466    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
6467    /// are already where that host wants them, and this just tells the model they
6468    /// are safe.
6469    pub fn mark_saved(&mut self) {
6470        self.clean_source = self.source.clone();
6471        self.dirty = false;
6472        // The bytes on disk are now ours, so this is the new watermark: without
6473        // re-stamping it, every save would report its own work as an external
6474        // change forever after.
6475        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
6476        self.status = Some(format!("saved {}", self.file_name()));
6477    }
6478
6479    /// What the file looks like now against the bytes leaf last read or wrote.
6480    ///
6481    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
6482    /// so this is a filesystem round-trip, not a per-frame question — ask it
6483    /// when a window regains focus, on a timer, or before a save.
6484    ///
6485    /// This *only* reports the file. Whether the document also has unsaved edits
6486    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
6487    /// [`DiskState::Changed`] means a save overwrites someone's work and a
6488    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
6489    /// it has no way to ask — so it hands a frontend both halves and lets it put
6490    /// the question to the person who can answer it.
6491    #[cfg(feature = "fs")]
6492    pub fn disk_state(&self) -> DiskState {
6493        let Some(want) = self.disk_hash else {
6494            return DiskState::Untitled;
6495        };
6496        match std::fs::read(&self.path) {
6497            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
6498            Ok(_) => DiskState::Changed,
6499            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
6500            Err(_) => DiskState::Unreadable,
6501        }
6502    }
6503
6504    /// Re-read the file and replace the document with what's there — the other
6505    /// answer to a [`DiskState::Changed`].
6506    ///
6507    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
6508    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
6509    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
6510    /// document shouldn't have to argue with a guard.
6511    ///
6512    /// **The undo history survives, and the reload is one step in it.** The
6513    /// whole buffer is spliced with the file's bytes through the same door every
6514    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
6515    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
6516    /// swapped the document out from under a reader gives them back what they
6517    /// were looking at, marked dirty, and ^Z again carries on into whatever they
6518    /// had done before it. This used to build a fresh parse and drop the stack,
6519    /// on the reasoning that twig's history belongs to the buffer and these are
6520    /// different bytes; that is true of *rebasing* a step onto them and not of
6521    /// recording the swap itself as one, which is all this is. A splice twig
6522    /// won't take falls back to the fresh parse, and only that path still costs
6523    /// the history.
6524    ///
6525    /// The caret keeps its byte offset, clamped to the new length; the selection
6526    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
6527    /// file changed, so it can't know where the caret "still" is. Clamping keeps
6528    /// it where the user left it in the common case (a change further down the
6529    /// file, or none in the text they're sitting in), and never puts it
6530    /// somewhere invalid. A selection has two such offsets and no such excuse —
6531    /// silently reinterpreting one over changed bytes would arm the *next*
6532    /// keystroke to delete something the user never selected.
6533    ///
6534    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
6535    /// document alone with a status.
6536    #[cfg(feature = "fs")]
6537    pub fn reload(&mut self) {
6538        if self.is_untitled() {
6539            self.status = Some("no file to reload".into());
6540            return;
6541        }
6542        let bytes = match std::fs::read(&self.path) {
6543            Ok(b) => b,
6544            Err(e) => {
6545                self.status = Some(format!("reload failed: {e}"));
6546                return;
6547            }
6548        };
6549        let Ok(source) = String::from_utf8(bytes) else {
6550            self.status = Some("reload failed: file is not UTF-8".into());
6551            return;
6552        };
6553        // Already these bytes — someone saved a file back unchanged, or leaf's
6554        // own write is being read back. Re-baseline against it and stop: a
6555        // splice of the text onto itself would put an undo step on the stack for
6556        // something nobody did.
6557        if source == self.source {
6558            self.disk_hash = Some(hash_bytes(source.as_bytes()));
6559            self.clean_source = source;
6560            self.dirty = false;
6561            self.status = Some(format!("reloaded {}", self.file_name()));
6562            return;
6563        }
6564        let caret = self.caret;
6565        // The pre-reload caret, so undoing the swap puts it back where the
6566        // reader was standing — the same bracketing `splice_exact` does.
6567        self.record_caret();
6568        if self
6569            .editor
6570            .edit_range(0, self.source.len(), &source)
6571            .is_ok()
6572        {
6573            self.refresh();
6574        } else {
6575            // twig wouldn't take the splice. Start over from the bytes, which is
6576            // what this always did, and is the one path that still costs the
6577            // history — `format` is the format this document *is*, not what the
6578            // (unchanged) name now says, see `save_as`.
6579            match new_editor(source.as_bytes(), self.format) {
6580                Ok(editor) => {
6581                    self.editor = editor;
6582                    self.source = source.clone();
6583                    // Not going through `refresh`, so the revision has to move
6584                    // here or every frontend keeps painting the old file from
6585                    // cache.
6586                    self.revision += 1;
6587                }
6588                Err(e) => {
6589                    self.status = Some(format!("reload failed: {e}"));
6590                    return;
6591                }
6592            }
6593        }
6594        self.disk_hash = Some(hash_bytes(source.as_bytes()));
6595        self.clean_source = self.source.clone();
6596        self.caret = caret.min(self.source.len());
6597        self.anchor = None;
6598        self.goal_col = None;
6599        self.last_edit_kind = None;
6600        self.dirty = false;
6601        self.status = Some(format!("reloaded {}", self.file_name()));
6602        self.clamp_caret();
6603        // And the post-reload caret, so a redo restores it.
6604        self.record_caret();
6605    }
6606
6607    /// Re-read the source from twig after it has changed the document. The one
6608    /// funnel every edit, undo, and redo comes through — so it's where the
6609    /// revision moves, and anything cached against the text dies here.
6610    fn refresh(&mut self) {
6611        if let Ok(s) = self.editor.source_str() {
6612            self.source = s;
6613        }
6614        self.revision += 1;
6615        // An edit is a step onto the history and the end of anything undone;
6616        // `undo`/`redo` come through here too and correct this after.
6617        self.undo_steps += 1;
6618        self.redo_steps = 0;
6619        self.clamp_caret();
6620    }
6621
6622    /// Whether [`undo`](Self::undo) has a step to take back — for a native
6623    /// Edit menu to enable its item by. See the note on `undo_steps` for what
6624    /// "has" means here.
6625    pub fn can_undo(&self) -> bool {
6626        !self.read_only && self.undo_steps > 0
6627    }
6628
6629    /// Whether [`redo`](Self::redo) has an undone step to restore.
6630    pub fn can_redo(&self) -> bool {
6631        !self.read_only && self.redo_steps > 0
6632    }
6633
6634    // ── caret movement ─────────────────────────────────────────────────────────
6635    // `extend` grows the selection (Shift+motion): it pins the anchor on the
6636    // first extended step and moves only the caret; an un-extended motion drops
6637    // the selection.
6638
6639    /// Place the caret at byte `offset` (clamped to a char boundary), extending
6640    /// the selection when `extend` is set. The public form of `move_to`, for a
6641    /// frontend that hit-tests pixels straight to a source offset.
6642    pub fn place_caret(&mut self, offset: usize, extend: bool) {
6643        self.goal_col = None;
6644        let before = self.caret;
6645        // A pixel hit-test can land between the visible caret stops — in the
6646        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
6647        // Snap to the nearest real stop so the caret can't come to rest where it
6648        // would draw in one place and type in another. The `(row, col)` click
6649        // path (`click`) already snaps this way through `offset_of_pos`; the
6650        // source view reaches every byte, so it snaps to nothing.
6651        let target = match self.view {
6652            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
6653            // The source view reaches every byte, so there is no stop to snap
6654            // to — but "every byte" still means every *character* boundary. A
6655            // caret resting inside a multi-byte character draws nowhere real
6656            // and panics the next time anything slices there.
6657            View::Source => self.char_boundary_at_or_before(offset),
6658        };
6659        self.move_to(target, extend);
6660        self.clamp_caret();
6661        self.debug_assert_on_a_stop(before);
6662    }
6663
6664    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
6665    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
6666    /// grab the metadata) while the source view still selects the literal whole.
6667    pub fn select_all(&mut self) {
6668        self.anchor = Some(self.caret_floor());
6669        self.caret = self.source.len();
6670        self.goal_col = None;
6671        self.last_edit_kind = None;
6672        self.status = None;
6673    }
6674
6675    /// Select the word (or whitespace / punctuation run) at `offset` — the
6676    /// double-click gesture. Anchors on the run's start with the caret at its
6677    /// end so a following Shift-motion extends from the far edge.
6678    pub fn select_word_at(&mut self, offset: usize) {
6679        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
6680        self.anchor = Some(s);
6681        self.caret = e;
6682        self.goal_col = None;
6683        self.last_edit_kind = None;
6684        self.status = None;
6685        self.clamp_caret();
6686    }
6687
6688    /// Select the whole enclosing text block (paragraph, heading, list item's
6689    /// text…) at `offset` — the triple-click gesture. Reads the range straight
6690    /// from the AST (twig's `content_span`), so it selects the entire *logical*
6691    /// paragraph even when that paragraph soft-wraps across several visual rows —
6692    /// where a visual-row-based select breaks down, because one source offset at
6693    /// a wrap boundary belongs to two rows at once.
6694    pub fn select_block_at(&mut self, offset: usize) {
6695        let off = offset.min(self.source.len());
6696        let range = self
6697            .editor
6698            .ancestors_at(off)
6699            .ok()
6700            .and_then(|chain| {
6701                // Ancestors run root → deepest; the deepest node that is neither
6702                // an inline span nor a multi-block container is the text block
6703                // the caret sits in (a paragraph, a heading, a code block…).
6704                chain
6705                    .into_iter()
6706                    .rev()
6707                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
6708                    .map(|m| m.content_span.unwrap_or(m.span))
6709            })
6710            .unwrap_or_else(|| source_line_range(&self.source, off));
6711        self.anchor = Some(range.start.min(self.source.len()));
6712        self.caret = range.end.min(self.source.len());
6713        self.goal_col = None;
6714        self.last_edit_kind = None;
6715        self.status = None;
6716        self.clamp_caret();
6717    }
6718
6719    /// Select the exact source range `[start, end)` — anchor at `start`, caret
6720    /// at `end` — without snapping either end to a visible caret stop.
6721    ///
6722    /// The one caret verb that takes a range it was *handed* rather than one it
6723    /// worked out, for a host that already knows the bytes it means: a search
6724    /// hit, an annotation's footprint, a quote re-anchored through
6725    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
6726    /// wrong tool for that, and not by a little — it snaps to the nearest
6727    /// *visible* stop, and where a range butts up against a hidden delimiter
6728    /// the nearest stop is the one before it, so selecting the "needle" of
6729    /// `**needle**` comes back with "needl" and an edit against it strands the
6730    /// "e".
6731    ///
6732    /// What `place_caret` does that is bookkeeping rather than snapping still
6733    /// happens here, because a host handing in a range is not asking to opt out
6734    /// of the invariants:
6735    ///
6736    /// - both ends are clamped into the document and up to
6737    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
6738    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
6739    ///   types into the metadata;
6740    /// - both land on character boundaries, so nothing slices a `é` in half;
6741    /// - the sticky vertical goal column is dropped, and any armed inline mark
6742    ///   disarmed, since a range from outside inherits neither.
6743    ///
6744    /// An empty range is a caret rather than a selection —
6745    /// [`selection`](Self::selection) reports `None` for it, as it does for any
6746    /// anchor that has met the caret.
6747    pub fn select_range(&mut self, start: usize, end: usize) {
6748        let floor = self.caret_floor();
6749        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
6750        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
6751        self.anchor = Some(anchor);
6752        self.caret = caret;
6753        self.goal_col = None;
6754        self.status = None;
6755        self.last_edit_kind = None;
6756        self.clear_pending();
6757    }
6758
6759    /// `offset` itself if it is a character boundary, else the boundary before
6760    /// it. An offset that isn't one draws nowhere real and panics the next time
6761    /// anything slices there.
6762    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
6763        let mut o = offset.min(self.source.len());
6764        while o > 0 && !self.source.is_char_boundary(o) {
6765            o -= 1;
6766        }
6767        o
6768    }
6769
6770    /// The lowest source offset the caret may occupy in the active view. In
6771    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
6772    /// the first rendered offset; the source view reaches everything, so it's 0.
6773    fn caret_floor(&self) -> usize {
6774        match self.view {
6775            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
6776            View::Source => 0,
6777        }
6778    }
6779
6780    /// Land in a table cell with its whole content selected — the anchor at the
6781    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
6782    /// like tabbing into a form field: the text comes up selected, so typing
6783    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
6784    /// end`) collapses to a plain caret home (an empty selection is no selection).
6785    fn select_cell(&mut self, start: usize, end: usize) {
6786        self.select_range(start, end);
6787    }
6788
6789    fn move_to(&mut self, offset: usize, extend: bool) {
6790        if extend {
6791            if self.anchor.is_none() {
6792                self.anchor = Some(self.caret);
6793            }
6794        } else {
6795            self.anchor = None;
6796        }
6797        self.caret = offset.min(self.source.len()).max(self.caret_floor());
6798        self.status = None;
6799        // A caret move ends the current typing/deletion run, so the next edit
6800        // starts a fresh undo group rather than coalescing across the gap.
6801        self.last_edit_kind = None;
6802        // Moving away disarms any sticky mark — "start bold" applies only where
6803        // it was asked for, not wherever the caret next lands.
6804        self.clear_pending();
6805    }
6806
6807    // In the source view, motion walks source bytes / source lines. In the
6808    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
6809    // what steps the caret cleanly over hidden delimiters.
6810
6811    pub fn move_left(&mut self, extend: bool) {
6812        self.goal_col = None;
6813        if !extend && let Some((s, _e)) = self.selection() {
6814            self.move_to(s, false);
6815            return;
6816        }
6817        let target = match self.view {
6818            View::Source => {
6819                if self.caret > 0 {
6820                    prev_boundary(&self.source, self.caret)
6821                } else {
6822                    0
6823                }
6824            }
6825            // Walks caret *stops*, not columns: decoration (a table border, a
6826            // cell's padding) is stepped over in one press, and a hidden
6827            // delimiter never holds the caret up — though the end of a mark's
6828            // content is a stop of its own (`VisualMap::mark_ends`), so
6829            // leaving `**bold**` from past its `**` is a press onto the end of
6830            // the bold and another onto the `d`.
6831            View::Wysiwyg => self
6832                .vmap
6833                .caret_stop_before(self.caret)
6834                .unwrap_or(self.caret),
6835        };
6836        let before = self.caret;
6837        self.move_to(target, extend);
6838        self.debug_assert_on_a_stop(before);
6839    }
6840
6841    pub fn move_right(&mut self, extend: bool) {
6842        self.goal_col = None;
6843        if !extend && let Some((_s, e)) = self.selection() {
6844            self.move_to(e, false);
6845            return;
6846        }
6847        let target = match self.view {
6848            View::Source => {
6849                if self.caret < self.source.len() {
6850                    next_boundary(&self.source, self.caret)
6851                } else {
6852                    self.caret
6853                }
6854            }
6855            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
6856        };
6857        let before = self.caret;
6858        self.move_to(target, extend);
6859        self.debug_assert_on_a_stop(before);
6860    }
6861
6862    /// Move to the start of the previous word (⌥← / Ctrl+←).
6863    pub fn move_word_left(&mut self, extend: bool) {
6864        self.goal_col = None;
6865        let before = self.caret;
6866        let target = self.word_left_from(self.caret);
6867        self.move_to(target, extend);
6868        self.debug_assert_on_a_stop(before);
6869    }
6870
6871    /// Move to the end of the next word (⌥→ / Ctrl+→).
6872    pub fn move_word_right(&mut self, extend: bool) {
6873        self.goal_col = None;
6874        let before = self.caret;
6875        let target = self.word_right_from(self.caret);
6876        self.move_to(target, extend);
6877        self.debug_assert_on_a_stop(before);
6878    }
6879
6880    // Word boundaries are found in the space the *view* is in. The source view
6881    // walks the source, because there the source is what's rendered. WYSIWYG
6882    // walks the rendered text instead: `**` is invisible to the user, so it has
6883    // to be invisible to word motion too — a caret parked inside one draws in
6884    // the column after `bold` and types two bytes earlier, and a word-delete
6885    // that stops there shreds the markup into `a ** c`.
6886
6887    /// The word boundary to the left of `off` in the active view's space.
6888    fn word_left_from(&self, off: usize) -> usize {
6889        match self.view {
6890            View::Source => prev_word(&self.source, off),
6891            View::Wysiwyg => self.glyph_word_left(off),
6892        }
6893    }
6894
6895    /// The word boundary to the right of `off` in the active view's space.
6896    fn word_right_from(&self, off: usize) -> usize {
6897        match self.view {
6898            View::Source => next_word(&self.source, off),
6899            View::Wysiwyg => self.glyph_word_right(off),
6900        }
6901    }
6902
6903    /// The character class of the glyph drawn at stop `off`.
6904    ///
6905    /// Read from the source, because a stop points at the source byte its glyph
6906    /// came from — the source *is* where the rendered character is written. What
6907    /// makes the walk glyph space rather than source space is that it only ever
6908    /// visits stops, and the hidden bytes between them have none.
6909    fn class_at(&self, off: usize) -> Class {
6910        self.source
6911            .get(off..)
6912            .and_then(|s| s.chars().next())
6913            .map_or(Class::Space, classify)
6914    }
6915
6916    /// [`next_word`] in glyph space: skip any leading separators, then consume
6917    /// the following word run, with the stop table standing in for the source's
6918    /// characters.
6919    fn glyph_word_right(&self, from: usize) -> usize {
6920        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
6921            return from;
6922        };
6923        let mut in_word = false;
6924        loop {
6925            match self.class_at(off) {
6926                Class::Word => in_word = true,
6927                _ if in_word => return off,
6928                _ => {}
6929            }
6930            match self.vmap.stop_after(off) {
6931                Some(next) => off = next,
6932                None => return off,
6933            }
6934        }
6935    }
6936
6937    /// [`prev_word`] in glyph space: skip separators walking left, then consume
6938    /// the preceding word run.
6939    fn glyph_word_left(&self, from: usize) -> usize {
6940        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
6941            return from;
6942        };
6943        let mut in_word = false;
6944        while let Some(prev) = self.vmap.stop_before(off) {
6945            match self.class_at(prev) {
6946                Class::Word => in_word = true,
6947                _ if in_word => return off,
6948                _ => {}
6949            }
6950            off = prev;
6951        }
6952        off
6953    }
6954
6955    /// After a motion that walks the visual map, the caret must be *on* the map.
6956    /// A stop is the only offset where the caret draws and edits in the same
6957    /// place, and it's the invariant both a caret parked inside an emoji and one
6958    /// parked inside a `**` were quietly breaking.
6959    ///
6960    /// Only when the caret actually moved: a walk with nowhere to go leaves it
6961    /// where it was, which is wherever the floor or a frontend put it rather
6962    /// than somewhere this motion chose.
6963    fn debug_assert_on_a_stop(&self, before: usize) {
6964        debug_assert!(
6965            self.view != View::Wysiwyg
6966                || self.vmap.num_rows() == 0
6967                || self.caret == before
6968                || self.vmap.is_stop(self.caret),
6969            "motion left the caret at {}, which is not a caret stop: it would draw in \
6970             one place and type in another",
6971            self.caret
6972        );
6973    }
6974
6975    // Up and Down run off the ends of the document rather than stopping dead at
6976    // them: Up from the first row lands at the document's start, Down from the
6977    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
6978    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
6979    // reaching the end of the text is what a reader means by it.
6980    //
6981    // The views used to disagree here by accident rather than by decision: the
6982    // source view fell into the edge behaviour through `row_col_to_offset`
6983    // clamping an out-of-range row to the end of the string, while WYSIWYG had
6984    // no row below to walk to and did nothing at all. They share the rule now,
6985    // each in its own space — the source view reaches every byte, WYSIWYG only
6986    // the offsets it draws.
6987
6988    pub fn move_up(&mut self, extend: bool) {
6989        let (row, col) = self.caret_pos();
6990        let goal = self.goal_col.unwrap_or(col);
6991        let target = match self.view {
6992            View::Source => match row.checked_sub(1) {
6993                Some(r) => row_col_to_offset(&self.source, r, goal),
6994                None => self.reachable_start(),
6995            },
6996            // A table's border rules are drawn but hold no caret, so Up steps
6997            // over them to the row that does.
6998            View::Wysiwyg => match self.vmap.navigable_above(row) {
6999                Some(r) => self.row_target(r, goal),
7000                None => self.reachable_start(),
7001            },
7002        };
7003        self.step_vertical(target, goal, extend);
7004    }
7005
7006    pub fn move_down(&mut self, extend: bool) {
7007        let (row, col) = self.caret_pos();
7008        let goal = self.goal_col.unwrap_or(col);
7009        let target = match self.view {
7010            View::Source => match self.source_row_below(row) {
7011                Some(r) => row_col_to_offset(&self.source, r, goal),
7012                None => self.reachable_end(),
7013            },
7014            View::Wysiwyg => match self.vmap.navigable_below(row) {
7015                Some(r) => self.row_target(r, goal),
7016                None => self.reachable_end(),
7017            },
7018        };
7019        self.step_vertical(target, goal, extend);
7020    }
7021
7022    /// Land a vertical motion at `target`, latching the `goal` column it aimed
7023    /// with so the rest of the run keeps aiming there.
7024    ///
7025    /// A motion with nowhere to go changes *nothing*, the goal column included:
7026    /// the latch used to run before the early return at the top of the document,
7027    /// so an Up that did nothing still armed a column, and the next Down aimed
7028    /// at one the caret had never been in.
7029    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
7030        let before = self.caret;
7031        if target == before {
7032            return;
7033        }
7034        self.goal_col = Some(goal);
7035        self.move_to(target, extend);
7036        self.debug_assert_on_a_stop(before);
7037    }
7038
7039    /// The source line below `row`, or `None` when `row` is the last one. Lines
7040    /// are counted by newline, so a trailing one leaves a real, empty last line
7041    /// for the caret to sit on — the document ends below it, not on it.
7042    fn source_row_below(&self, row: usize) -> Option<usize> {
7043        let last = self.source.bytes().filter(|&b| b == b'\n').count();
7044        (row < last).then_some(row + 1)
7045    }
7046
7047    /// Where a vertical motion aiming at the `goal` column lands on visual row
7048    /// `r`: the column clamped to the row, mapped to its offset, then held
7049    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
7050    /// column belongs to the row below, and a gutter's column 0 points at the
7051    /// block rather than at this row.
7052    fn row_target(&self, r: usize, goal: usize) -> usize {
7053        let (start, end) = self.row_bounds(r);
7054        self.vmap
7055            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
7056            .clamp(start, end)
7057    }
7058
7059    /// The first and last offsets the caret can reach in the active view.
7060    ///
7061    /// Not the same span in both: the source view shows every byte, so it can
7062    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
7063    /// sits below the first stop, and a document's trailing newline is drawn
7064    /// nowhere and so sits past the last.
7065    fn reachable_start(&self) -> usize {
7066        match self.view {
7067            View::Source => 0,
7068            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
7069        }
7070    }
7071
7072    fn reachable_end(&self) -> usize {
7073        match self.view {
7074            View::Source => self.source.len(),
7075            View::Wysiwyg => self
7076                .vmap
7077                .stop_at_or_before(self.source.len())
7078                .unwrap_or(self.caret),
7079        }
7080    }
7081
7082    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
7083    /// including the space a soft wrap ate off its end, which is drawn on this
7084    /// row however much the offset past it belongs to the next one.
7085    fn row_span(&self, r: usize) -> (usize, usize) {
7086        let start = self
7087            .vmap
7088            .row_start(r)
7089            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
7090        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
7091        (start.min(end), end)
7092    }
7093
7094    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
7095    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
7096    /// this row's last position is the one before it — the offset before the
7097    /// space the wrap ate, where the caret draws just past the row's last word
7098    /// and types there too.
7099    ///
7100    /// Aiming at the shared offset instead is what stalled End: it is the row's
7101    /// last *column*, so End pressed on the row reached it and then read back as
7102    /// the row below's start, where a second press ran on to that row's end and
7103    /// the next to the one after — End walking down the paragraph a row a press.
7104    fn row_bounds(&self, r: usize) -> (usize, usize) {
7105        let (start, end) = self.row_span(r);
7106        let wraps = self
7107            .vmap
7108            .navigable_below(r)
7109            .and_then(|b| self.vmap.row_start(b))
7110            .is_some_and(|off| off == end);
7111        match wraps {
7112            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
7113            false => (start, end),
7114        }
7115    }
7116
7117    /// The `[start, end]` of the line Home and End aim at: the visual row in
7118    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
7119    ///
7120    /// A soft-wrapped row is a line here, because it is one to the eye and the
7121    /// eye is what these keys are aimed by — a reader pressing End means the end
7122    /// of the line they can see. (`select_block_at` wants the opposite and reads
7123    /// the AST for it: a triple-click grabs the whole paragraph, however many
7124    /// rows it folds into.)
7125    fn line_bounds(&self) -> (usize, usize) {
7126        let (row, _) = self.caret_pos();
7127        match self.view {
7128            View::Source => {
7129                let start = line_start(&self.source, row);
7130                (start, line_end_from(&self.source, start))
7131            }
7132            View::Wysiwyg => self.row_bounds(row),
7133        }
7134    }
7135
7136    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
7137    /// *drawn* — what a kill takes.
7138    ///
7139    /// The two part only at a soft wrap, over the space the wrap ate: the caret
7140    /// can't stand after it (that offset opens the row below, and End stopping
7141    /// there would walk), but it is on this row, and a kill that spared it would
7142    /// leave a double space behind where the row's text had been. Deleting it
7143    /// joins nothing — a wrap is drawn, not written.
7144    fn line_span(&self) -> (usize, usize) {
7145        let (row, _) = self.caret_pos();
7146        match self.view {
7147            View::Source => self.line_bounds(),
7148            View::Wysiwyg => self.row_span(row),
7149        }
7150    }
7151
7152    /// The first offset in `[start, end]` holding something other than
7153    /// whitespace, or `end` when the line holds nothing else — where Home aims.
7154    ///
7155    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7156    /// so a hidden delimiter is never taken for the line's first character (nor
7157    /// landed on), and the source view steps the source it is showing.
7158    fn first_non_space(&self, start: usize, end: usize) -> usize {
7159        let mut off = start;
7160        while off < end {
7161            if self.class_at(off) != Class::Space {
7162                return off;
7163            }
7164            off = match self.view {
7165                View::Source => next_boundary(&self.source, off),
7166                View::Wysiwyg => match self.vmap.stop_after(off) {
7167                    Some(next) => next,
7168                    None => return end,
7169                },
7170            };
7171        }
7172        end
7173    }
7174
7175    /// Home: to the first character on the line, or to column 0 when the caret
7176    /// is already on it — the two-press toggle every editor spells this way.
7177    /// The indentation is somewhere the caret has to be able to reach and almost
7178    /// never where a reader is headed, so it costs the second press.
7179    pub fn move_home(&mut self, extend: bool) {
7180        self.goal_col = None;
7181        let (start, end) = self.line_bounds();
7182        let text = self.first_non_space(start, end);
7183        let target = if self.caret == text { start } else { text };
7184        let before = self.caret;
7185        self.move_to(target, extend);
7186        self.debug_assert_on_a_stop(before);
7187    }
7188
7189    /// End: to the end of the line.
7190    pub fn move_end(&mut self, extend: bool) {
7191        self.goal_col = None;
7192        let (_, end) = self.line_bounds();
7193        let before = self.caret;
7194        self.move_to(end, extend);
7195        self.debug_assert_on_a_stop(before);
7196    }
7197
7198    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7199    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7200    /// when the caret isn't in a table, or is already in the last/first cell — the
7201    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7202    /// meaning everywhere else.
7203    pub fn cell_hop(&mut self, forward: bool) -> bool {
7204        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7205            return false;
7206        };
7207        // Flatten to document (row-major) order and step one cell either way.
7208        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7209        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7210        let next = if forward {
7211            i.checked_add(1)
7212        } else {
7213            i.checked_sub(1)
7214        };
7215        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7216            return false; // at the table's edge; leave Tab to the frontend
7217        };
7218        self.select_cell(start, end);
7219        true
7220    }
7221
7222    /// Move the caret to the cell directly above (`down == false`) or below in
7223    /// the same column, landing with the cell's whole content selected (see
7224    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7225    /// when the caret isn't in a table), so the frontend can fall through — the
7226    /// vertical counterpart of [`Self::cell_hop`].
7227    ///
7228    /// A ragged row that is short a column clamps to its last cell, so Down never
7229    /// falls out of the table over a gap the row above happened to have.
7230    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7231        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7232            return false;
7233        };
7234        let target = match down {
7235            true => r + 1,
7236            false if r == 0 => return false,
7237            false => r - 1,
7238        };
7239        let Some(row) = grid.get(target) else {
7240            return false;
7241        };
7242        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7243            return false;
7244        };
7245        self.select_cell(start, end);
7246        true
7247    }
7248
7249    /// The table containing `off` as a row-major grid of `(start, end)` cell
7250    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7251    /// isn't in a table. Read straight off the visual map's laid-out grid, so
7252    /// every cell (an empty one included, whose derived home twig gives no
7253    /// `content_span` for) is present and in the order Tab walks them.
7254    // Grid, row, column — three returns that only ever travel together, and a
7255    // named type for the pair of them would be read at one call site.
7256    #[allow(clippy::type_complexity)]
7257    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7258        for t in &self.vmap.tables {
7259            let mut pos = None;
7260            let grid: Vec<Vec<(usize, usize)>> = t
7261                .grid
7262                .iter()
7263                .enumerate()
7264                .map(|(r, row)| {
7265                    row.cells
7266                        .iter()
7267                        .enumerate()
7268                        .map(|(c, cell)| {
7269                            if pos.is_none() && off >= cell.start && off <= cell.end {
7270                                pos = Some((r, c));
7271                            }
7272                            (cell.start, cell.end)
7273                        })
7274                        .collect()
7275                })
7276                .collect();
7277            if let Some((r, c)) = pos {
7278                return Some((grid, r, c));
7279            }
7280        }
7281        None
7282    }
7283
7284    // ── table key policy ──────────────────────────────────────────────────────
7285    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7286    // as one policy every frontend shares, rather than each re-deriving it. Each
7287    // reports whether it acted *as a table key*; a `false` hands the key back to
7288    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7289    // everywhere else.
7290
7291    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7292    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7293    /// back and simply stays put at the very first cell. `false` when the caret
7294    /// isn't in a table.
7295    pub fn cell_tab(&mut self, forward: bool) -> bool {
7296        if !self.caret_in_table() {
7297            return false;
7298        }
7299        if self.cell_hop(forward) {
7300            return true;
7301        }
7302        // Off the last cell: grow the table by a row and step into its first
7303        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7304        if forward {
7305            self.append_row_and_enter(0);
7306        }
7307        true
7308    }
7309
7310    /// Return inside a table: drop to the cell below in the same column,
7311    /// appending a new row when the caret is already in the last one. `false`
7312    /// when the caret isn't in a table, so the frontend inserts a newline.
7313    pub fn cell_return(&mut self) -> bool {
7314        if !self.caret_in_table() {
7315            return false;
7316        }
7317        if self.cell_move_vertical(true) {
7318            return true;
7319        }
7320        // Already on the last row: grow one below and drop into the same column.
7321        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7322        self.append_row_and_enter(col);
7323        true
7324    }
7325
7326    /// Append a row below the caret's (last) row and land in `col` of it. The
7327    /// caret is in the last row, so twig's "insert below" makes the fresh row the
7328    /// table's new last — but twig re-spells the whole table, moving every byte,
7329    /// so the destination is read back from the rebuilt grid by the table's
7330    /// position (stable across a row insert), not from the pre-edit caret.
7331    fn append_row_and_enter(&mut self, col: usize) {
7332        let table = self.caret_table_index();
7333        self.table_insert_row(true);
7334        self.rebuild_map();
7335        let Some((start, end)) = table
7336            .and_then(|ti| self.vmap.tables.get(ti))
7337            .and_then(|t| t.grid.last())
7338            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7339            .map(|cell| (cell.start, cell.end))
7340        else {
7341            return;
7342        };
7343        self.select_cell(start, end);
7344    }
7345
7346    /// The index, among the document's tables, of the one the caret sits in —
7347    /// `None` when it's in none. Used to re-find a table after an edit re-spells
7348    /// it (a row insert leaves the table order unchanged).
7349    fn caret_table_index(&self) -> Option<usize> {
7350        let off = self.caret;
7351        self.vmap.tables.iter().position(|t| {
7352            t.grid
7353                .iter()
7354                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7355        })
7356    }
7357
7358    /// Shift+Return inside a table: insert a hard line break *within* the current
7359    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7360    /// table, so the frontend inserts an ordinary line break.
7361    ///
7362    /// A table row is a single source line, so the newline-spelled hard break
7363    /// can't live in a cell. twig spells the in-cell break the format's way
7364    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7365    /// break round-trips as structure the renderer reads back as a line — not the
7366    /// opaque raw HTML the old raw-splice left behind.
7367    ///
7368    /// Djot has no idiomatic in-cell break, so twig refuses it
7369    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7370    /// would render as the literal text `<br>`. The gesture is still *consumed*
7371    /// there — returning `false` would let the frontend insert a real newline,
7372    /// which splits the one-line row — it just leaves the cell unchanged and says
7373    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7374    ///
7375    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7376    /// have to be read together: djot is not the only `false`, and naming it in
7377    /// the message was already a guess that HTML — which spells the break as its
7378    /// own `<br>` — would have made wrong.
7379    pub fn cell_line_break(&mut self) -> bool {
7380        if self.read_only || !self.caret_in_table() {
7381            return false;
7382        }
7383        self.record_caret();
7384        match self.editor.insert_line_break(self.caret) {
7385            Ok(change) => {
7386                self.last_edit_kind = None;
7387                self.refresh();
7388                self.caret = change.new.end;
7389                self.anchor = None;
7390                self.goal_col = None;
7391                self.clamp_caret();
7392                self.dirty = self.source != self.clean_source;
7393                self.status = None;
7394                self.record_caret();
7395            }
7396            Err(twig::Error::UnsupportedFormat) => {
7397                self.status = Some(format!(
7398                    "in-cell line breaks aren't supported in {}",
7399                    self.format_name()
7400                ));
7401            }
7402            Err(_) => {}
7403        }
7404        true
7405    }
7406
7407    /// Rebuild the visual map at the width the last build used. A structural edit
7408    /// bumps the revision and swaps the source in, but leaves the *map* stale;
7409    /// when a single gesture edits and then moves over the result (Tab appending
7410    /// a row, then stepping into it), the move needs the map to already show the
7411    /// edit rather than waiting for the frontend's next frame.
7412    fn rebuild_map(&mut self) {
7413        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7414        self.build_map(wrap);
7415    }
7416
7417    /// Move the caret to the very start of the document (⌘↑ on macOS,
7418    /// Ctrl+Home on Windows/Linux).
7419    pub fn move_doc_start(&mut self, extend: bool) {
7420        self.goal_col = None;
7421        self.move_to(0, extend);
7422    }
7423
7424    /// Move the caret to the very end of the document (⌘↓ on macOS,
7425    /// Ctrl+End on Windows/Linux).
7426    pub fn move_doc_end(&mut self, extend: bool) {
7427        self.goal_col = None;
7428        let end = self.source.len();
7429        self.move_to(end, extend);
7430    }
7431
7432    /// Point the caret at the body cell `(row, col)` the mouse landed on —
7433    /// `col` being a cell of the terminal grid, which is what a display column
7434    /// is. A click on the far cell of a wide character lands at that
7435    /// character's start; the mapping's own doc-comments carry the rule.
7436    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
7437        self.goal_col = None;
7438        let target = match self.view {
7439            View::Source => row_col_to_offset(&self.source, row, col),
7440            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
7441        };
7442        let before = self.caret;
7443        self.move_to(target, extend);
7444        self.debug_assert_on_a_stop(before);
7445    }
7446
7447    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
7448    /// screen if it has moved since the last frame, and never scroll past the
7449    /// last of `rows`.
7450    ///
7451    /// Only if it has *moved* — that's the whole point. Revealing the caret on
7452    /// every frame ties the viewport to it, and a scroll wheel that fights the
7453    /// caret for the viewport loses: the view snaps back the instant it tries to
7454    /// pass the caret's row, so the document can't be scrolled beyond what's
7455    /// already on screen. A caret move is the frontend's cue to follow; a scroll
7456    /// with the caret sitting still is the reader's cue to leave it alone.
7457    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
7458        if self.drawn_caret != Some(self.caret) {
7459            if caret_row < self.scroll {
7460                self.scroll = caret_row;
7461            } else if height > 0 && caret_row >= self.scroll + height {
7462                self.scroll = caret_row + 1 - height;
7463            }
7464            self.drawn_caret = Some(self.caret);
7465        }
7466        self.scroll = self.scroll.min(rows.saturating_sub(1));
7467    }
7468
7469    /// The caret's screen position `(row, col)` in the active view's grid, with
7470    /// `col` a display column: the cell to draw the caret in, which on a line of
7471    /// `你好` or emoji is not the count of characters before it.
7472    pub fn caret_pos(&self) -> (usize, usize) {
7473        match self.view {
7474            View::Source => offset_to_row_col(&self.source, self.caret),
7475            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
7476        }
7477    }
7478
7479    fn clamp_caret(&mut self) {
7480        if self.caret > self.source.len() {
7481            self.caret = self.source.len();
7482        }
7483        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
7484        // any selection anchor) to the first rendered offset.
7485        let floor = self.caret_floor();
7486        if self.caret < floor {
7487            self.caret = floor;
7488        }
7489        if let Some(a) = self.anchor
7490            && a < floor
7491        {
7492            self.anchor = Some(floor);
7493        }
7494        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
7495            self.caret -= 1;
7496        }
7497    }
7498}
7499
7500// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
7501
7502// Left/right motion and backspace/delete step by *grapheme cluster*, not
7503// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
7504// marks moves and deletes as the single character a user sees. Grapheme
7505// boundaries are a superset of char boundaries, so the caret stays valid for twig.
7506
7507/// How an insert of `text` groups for undo: a single typed character folds into
7508/// the run of typing around it, while a newline or a multi-character insert is a
7509/// step of its own.
7510fn typed_edit_kind(text: &str) -> EditKind {
7511    if text.chars().take(2).count() == 1 && text != "\n" {
7512        EditKind::Insert
7513    } else {
7514        EditKind::Other
7515    }
7516}
7517
7518fn prev_boundary(s: &str, i: usize) -> usize {
7519    let mut cursor = GraphemeCursor::new(i, s.len(), true);
7520    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
7521}
7522
7523fn next_boundary(s: &str, i: usize) -> usize {
7524    let mut cursor = GraphemeCursor::new(i, s.len(), true);
7525    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
7526}
7527
7528// ── word boundaries ──────────────────────────────────────────────────────────
7529// The shared primitive behind word-wise motion, word deletion, and
7530// double-click-to-select-a-word. A "word" is a maximal run of one character
7531// class; whitespace and punctuation are their own classes, so motion skips
7532// cleanly between them the way native text fields do.
7533
7534#[derive(PartialEq, Eq, Clone, Copy)]
7535enum Class {
7536    Word,
7537    Space,
7538    Other,
7539}
7540
7541/// The source range of an inline node's own visible text — the part of it a
7542/// WYSIWYG caret can reach, as against the delimiters that only spell it.
7543/// `None` for a node with no interior to empty (a `str`, a break).
7544///
7545/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
7546/// one delimiter in from the span — the same place the renderer maps it to. A
7547/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
7548/// against the source rather than trusted: a range guessed wrong here is text
7549/// deleted wrong.
7550fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
7551    if let Some(span) = n.content_span.clone() {
7552        return Some(span);
7553    }
7554    match n.kind.as_str() {
7555        "verbatim" | "inline_math" => {
7556            let text = n.text.as_ref()?;
7557            let start = n.span.start + 1;
7558            let range = start..start + text.len();
7559            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
7560        }
7561        _ => None,
7562    }
7563}
7564
7565/// The `id` a node declares, or `None` for one that declares none — the
7566/// attribute djot writes for a `{#v1}` and mints for a heading.
7567///
7568/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
7569/// names nothing, so it reads as absent rather than as the empty string.
7570fn declared_id(n: &FlatNode) -> Option<&str> {
7571    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
7572}
7573
7574/// A heading's words reduced to the form a link fragment spells them in:
7575/// lowercase, runs of anything else collapsed to a single `-`, with none left
7576/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
7577///
7578/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
7579/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
7580/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
7581/// any other language is still a heading someone will link to. Underscores
7582/// survive for the same reason they do on the web: they are word characters
7583/// wherever identifiers are written.
7584fn slug(text: &str) -> String {
7585    let mut out = String::new();
7586    let mut pending = false;
7587    for c in text.chars() {
7588        if c.is_alphanumeric() || c == '_' {
7589            if pending && !out.is_empty() {
7590                out.push('-');
7591            }
7592            pending = false;
7593            out.extend(c.to_lowercase());
7594        } else {
7595            pending = true;
7596        }
7597    }
7598    out
7599}
7600
7601fn is_block_container(kind: &Kind) -> bool {
7602    matches!(
7603        kind,
7604        Kind::Doc
7605            | Kind::Section
7606            | Kind::BlockQuote
7607            | Kind::BulletList
7608            | Kind::OrderedList
7609            | Kind::TaskList
7610            | Kind::ListItem
7611            | Kind::TaskListItem
7612            // Every `container` — a directive in any of its three forms, or a
7613            // promoted HTML element. A *text* directive is really inline, so
7614            // claiming it here is a small overreach, and the deliberate one this
7615            // function's kind-only peer `is_inline_kind` documents: the pair is
7616            // consulted together, and answering "block container" for something
7617            // inline is what keeps an ancestor walk from stopping short of the
7618            // paragraph that actually holds it.
7619            | Kind::Container
7620    )
7621}
7622
7623/// The `[start, end)` byte range of the source line containing `off` (newline
7624/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
7625/// line between paragraphs).
7626fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
7627    let off = off.min(s.len());
7628    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
7629    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
7630    start..end
7631}
7632
7633/// How many leading bytes an outdent takes off `line`: a whole indent level
7634/// where the line has one, and whatever it has where it has less.
7635///
7636/// A leading tab counts as a level on its own. It's indentation some other
7637/// editor wrote, and one tab is one level everywhere it came from — measuring it
7638/// in spaces it doesn't contain would leave it untouchable.
7639fn outdent_width(line: &str, unit: usize) -> usize {
7640    if line.starts_with('\t') {
7641        return 1;
7642    }
7643    line.bytes().take(unit).take_while(|b| *b == b' ').count()
7644}
7645
7646/// A list marker found at the head of a line, together with everything before it
7647/// that a sibling line has to repeat.
7648///
7649/// The three offsets differ only inside a block quote, where `>   - b` opens with
7650/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
7651/// `line_start == marker_start`, and `text` is the plain `"  - "`.
7652#[derive(Clone, Debug)]
7653struct ListMarker {
7654    /// The line's first byte.
7655    line_start: usize,
7656    /// Where the marker proper begins, past any quote prefix. The offset to hand
7657    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
7658    marker_start: usize,
7659    /// `line_start` through the marker's trailing space — quote prefix, indent
7660    /// and bullet together, which is what the next item's line opens with.
7661    text: String,
7662}
7663
7664impl ListMarker {
7665    /// Where the item's content starts — one past the marker's trailing space.
7666    fn content_start(&self) -> usize {
7667        self.line_start + self.text.len()
7668    }
7669}
7670
7671fn classify(c: char) -> Class {
7672    if c == '_' || c.is_alphanumeric() {
7673        Class::Word
7674    } else if c.is_whitespace() {
7675        Class::Space
7676    } else {
7677        Class::Other
7678    }
7679}
7680
7681/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
7682/// skip any leading separators, then consume the following word run.
7683fn next_word(s: &str, i: usize) -> usize {
7684    let mut off = i;
7685    let mut in_word = false;
7686    for c in s[i..].chars() {
7687        if classify(c) == Class::Word {
7688            in_word = true;
7689        } else if in_word {
7690            break;
7691        }
7692        off += c.len_utf8();
7693    }
7694    off
7695}
7696
7697/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
7698/// skip separators walking left, then consume the preceding word run.
7699fn prev_word(s: &str, i: usize) -> usize {
7700    let mut off = i;
7701    let mut in_word = false;
7702    for c in s[..i].chars().rev() {
7703        if classify(c) == Class::Word {
7704            in_word = true;
7705        } else if in_word {
7706            break;
7707        }
7708        off -= c.len_utf8();
7709    }
7710    off
7711}
7712
7713/// The `[start, end)` run of same-class characters surrounding `off` — the
7714/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
7715/// the run ending there is used.
7716fn word_range_at(s: &str, off: usize) -> (usize, usize) {
7717    if s.is_empty() {
7718        return (0, 0);
7719    }
7720    let off = off.min(s.len());
7721    let reference = if off < s.len() {
7722        s[off..].chars().next()
7723    } else {
7724        s[..off].chars().next_back()
7725    };
7726    let Some(rc) = reference else {
7727        return (off, off);
7728    };
7729    let class = classify(rc);
7730
7731    let mut start = off;
7732    for c in s[..start].chars().rev() {
7733        if classify(c) == class {
7734            start -= c.len_utf8();
7735        } else {
7736            break;
7737        }
7738    }
7739    let mut end = off;
7740    for c in s[end..].chars() {
7741        if classify(c) == class {
7742            end += c.len_utf8();
7743        } else {
7744            break;
7745        }
7746    }
7747    (start, end)
7748}
7749
7750/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
7751/// the line's start — terminal cells, not characters, so the column names the
7752/// cell the caret is drawn in even on a line of `你好` or emoji.
7753fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
7754    let off = off.min(s.len());
7755    let mut row = 0;
7756    let mut line_start = 0;
7757    for (i, &b) in s.as_bytes().iter().enumerate() {
7758        if i >= off {
7759            break;
7760        }
7761        if b == b'\n' {
7762            row += 1;
7763            line_start = i + 1;
7764        }
7765    }
7766    (row, wysiwyg::text_width(&s[line_start..off]))
7767}
7768
7769/// The byte offset at display column `col` of `row` (clamped to that line's
7770/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
7771///
7772/// A column landing *inside* a character — the second cell of `你`, or any cell
7773/// but the first of an emoji — resolves to that character's start, which is the
7774/// column the caret would have been drawn at to begin with. So both cells of a
7775/// wide character mean the character, and every offset survives the round trip
7776/// out to a column and back. The walk steps by grapheme cluster for the same
7777/// reason the caret does: a cluster is the character, and the cells belong to it
7778/// rather than to the codepoints spelling it.
7779fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
7780    let start = line_start(s, row);
7781    let end = line_end_from(s, start);
7782    let mut off = start;
7783    let mut at = 0; // the display column `off` sits at
7784    while off < end {
7785        let next = next_boundary(s, off).min(end);
7786        let cells = wysiwyg::text_width(&s[off..next]);
7787        if at + cells > col {
7788            break; // `col` is one of this cluster's own cells
7789        }
7790        at += cells;
7791        off = next;
7792    }
7793    off
7794}
7795
7796fn line_start(s: &str, row: usize) -> usize {
7797    if row == 0 {
7798        return 0;
7799    }
7800    let mut r = 0;
7801    for (i, &b) in s.as_bytes().iter().enumerate() {
7802        if b == b'\n' {
7803            r += 1;
7804            if r == row {
7805                return i + 1;
7806            }
7807        }
7808    }
7809    s.len()
7810}
7811
7812fn line_end_from(s: &str, start: usize) -> usize {
7813    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
7814}
7815
7816/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
7817/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
7818/// twig applies writing the mark out, so the toolbar can light the same button
7819/// that made the node.
7820///
7821/// `None` for every other kind, including the inline nodes that aren't marks at
7822/// all (`str`, `link`, `image`, the math and break kinds): they're things a
7823/// caret stands in, not formatting a button toggles.
7824/// Whether a match from an ancestor chain is an inline run whose delimiters
7825/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
7826/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
7827/// span is a [`Kind::Container`], which the kind alone cannot tell from a
7828/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
7829/// answer is the node's own. Every delete and caret step that walks over a
7830/// `**` walks over a span's tags by this test; without it Backspace after
7831/// `</span>` took the `>` and left the paragraph unparseable.
7832fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
7833    inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
7834}
7835
7836fn inline_kind(kind: &Kind) -> Option<InlineKind> {
7837    Some(match kind {
7838        Kind::Strong => InlineKind::Strong,
7839        Kind::Emph => InlineKind::Emph,
7840        Kind::Verbatim => InlineKind::Verbatim,
7841        Kind::Mark => InlineKind::Mark,
7842        Kind::Superscript => InlineKind::Superscript,
7843        Kind::Subscript => InlineKind::Subscript,
7844        Kind::Insert => InlineKind::Insert,
7845        Kind::Delete => InlineKind::Delete,
7846        _ => return None,
7847    })
7848}
7849
7850/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
7851/// a highlight's opening `==`.
7852///
7853/// Two enums for one closed vocabulary, and the duplication is the boundary
7854/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
7855/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
7856/// the editor writes. Spelled as a match rather than routed through the two
7857/// crates' name strings so that a colour added on either side is a compile
7858/// error here, where the pairing is decided, rather than a runtime `None` that
7859/// would read as "clear the colour".
7860fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
7861    match color {
7862        MarkColor::Red => twig::MarkColor::Red,
7863        MarkColor::Orange => twig::MarkColor::Orange,
7864        MarkColor::Yellow => twig::MarkColor::Yellow,
7865        MarkColor::Green => twig::MarkColor::Green,
7866        MarkColor::Blue => twig::MarkColor::Blue,
7867        MarkColor::Purple => twig::MarkColor::Purple,
7868        MarkColor::Brown => twig::MarkColor::Brown,
7869    }
7870}
7871
7872/// Where an offset lands after a splice it didn't make — twig's own rule, from
7873/// [`Change`]: shift anything at or past the replaced range's end by the length
7874/// the replacement gained or lost, and leave anything before it alone.
7875///
7876/// An offset *inside* the replaced range has no text of its own to ride any
7877/// more, and lands at the end of what replaced it: for
7878/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
7879/// the prefix is cleared, which then sits where the highlighted text begins.
7880/// One node's attribute list, twig's own `(key, value)` pairs owned — what
7881/// every presentation gesture reads, edits one key of, and passes back whole.
7882type Attrs = Vec<(String, Option<String>)>;
7883
7884/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
7885/// writes it and the walker draws it, and a frontend that paginates matches a
7886/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
7887/// stated once.
7888pub const PAGE_BREAK: &str = "page-break";
7889
7890/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
7891/// every other attribute kept in its place — the read-edit-write half of twig's
7892/// replace-not-merge contract for a `data-` key.
7893///
7894/// **A key that is already there is rewritten where it stands**, and only a key
7895/// the node did not have goes on the end. That is what makes the proposal's
7896/// worked example true: `class="lead center" id="intro"
7897/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
7898/// data-line-height="1.5"` — the same document with one token changed, and a
7899/// one-line diff. Removing the key and pushing it back would reorder the
7900/// author's attributes on every press, so a document that passed through the
7901/// editor came out shuffled even where nothing about it had changed.
7902///
7903/// A duplicate key — which no format leaf opens can spell, but twig reports
7904/// verbatim — collapses onto the first of its copies, since twig is handed one
7905/// value for one key either way.
7906fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
7907    let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
7908    let mut written = false;
7909    for (k, v) in attrs {
7910        if k != key {
7911            out.push((k.clone(), v.clone()));
7912            continue;
7913        }
7914        if let Some(new) = value.filter(|_| !written) {
7915            out.push((k.clone(), Some(new.to_string())));
7916            written = true;
7917        }
7918    }
7919    if let Some(new) = value.filter(|_| !written) {
7920        out.push((key.to_string(), Some(new.to_string())));
7921    }
7922    out
7923}
7924
7925/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
7926/// `token` added, with the rest of the list kept in order.
7927///
7928/// `class` is a space-separated token list, and leaf owns three of the tokens in
7929/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
7930/// goes out as `class="lead right"`; one whose last owned token goes and which
7931/// carried nothing else loses the key, so a block that has lost its whole
7932/// vocabulary is spelled bare again. `class` itself keeps its place among the
7933/// attributes, because [`with_attr`] does the writing.
7934fn with_class_token(
7935    attrs: &[(String, Option<String>)],
7936    mine: impl Fn(&str) -> bool,
7937    token: Option<&str>,
7938) -> Attrs {
7939    let kept: Vec<&str> = attrs
7940        .iter()
7941        .find(|(k, _)| k == "class")
7942        .and_then(|(_, v)| v.as_deref())
7943        .unwrap_or_default()
7944        .split_whitespace()
7945        .filter(|t| !mine(t))
7946        .collect();
7947    let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
7948    with_attr(
7949        attrs,
7950        "class",
7951        (!class.is_empty()).then_some(class.as_str()),
7952    )
7953}
7954
7955/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
7956///
7957/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
7958/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
7959/// (djot has no bare attribute, so no format reads one back everywhere), and
7960/// `hidden=""` is the same document where `hidden` is; dropping it instead
7961/// would lose what the author wrote, which is the one thing these gestures
7962/// promise not to do.
7963fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
7964    attrs
7965        .iter()
7966        .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
7967        .collect()
7968}
7969
7970fn reanchor(off: usize, change: &Change) -> usize {
7971    if off < change.old.start {
7972        return off;
7973    }
7974    if off < change.old.end {
7975        return change.new.end;
7976    }
7977    (off + change.new.end).saturating_sub(change.old.end)
7978}
7979
7980/// [`reanchor`] for an edit that respells the markup *around* a block and
7981/// leaves the block's own bytes alone — which is every attribute gesture.
7982///
7983/// `block` is that block's content span before and after the splice, so an
7984/// offset standing in the text keeps its distance from the text's start and how
7985/// many bytes twig wrote above it never enters the arithmetic. That is the whole
7986/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
7987/// on the same div lengthens the attribute line, clearing the last one takes the
7988/// div away entirely, and both are the same sum. `None` where the splice named
7989/// no block at either end, which is every djot case — the `{…}` line is written
7990/// above the block, and the block itself only shifts past it.
7991///
7992/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
7993/// shifted by its delta after it, and at the splice's end for an offset that
7994/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
7995/// text to keep.
7996fn reanchor_in_block(
7997    off: usize,
7998    change: &Change,
7999    block: Option<(&Range<usize>, &Range<usize>)>,
8000) -> usize {
8001    if let Some((was, now)) = block
8002        && was.start <= off
8003        && off <= was.end
8004    {
8005        return now.start + (off - was.start).min(now.end - now.start);
8006    }
8007    reanchor(off, change)
8008}
8009
8010/// A watermark for a file's contents (see `Doc::disk_hash`).
8011///
8012/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
8013/// watermark is compared only against one taken by the same process moments
8014/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
8015/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
8016fn hash_bytes(bytes: &[u8]) -> u64 {
8017    use std::hash::{Hash, Hasher};
8018    let mut h = std::collections::hash_map::DefaultHasher::new();
8019    bytes.hash(&mut h);
8020    h.finish()
8021}
8022
8023#[cfg(feature = "fs")]
8024fn detect_format(path: &Path) -> Result<Format> {
8025    let ext = path
8026        .extension()
8027        .and_then(|e| e.to_str())
8028        .unwrap_or("")
8029        .to_ascii_lowercase();
8030    Ok(match ext.as_str() {
8031        "dj" | "djot" => Format::Djot,
8032        "md" | "markdown" => Format::Markdown,
8033        "xml" => Format::Xml,
8034        "html" | "htm" => Format::Html,
8035        other => return Err(anyhow!("unknown document extension: .{other}")),
8036    })
8037}
8038
8039#[cfg(test)]
8040mod tests {
8041    use super::*;
8042    use crate::style::{FontFamily, LineSpacing, SizeStep};
8043
8044    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
8045    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
8046    /// without one is a view no user is ever in.
8047    fn doc_in(view: View, name: &str, body: &str) -> Doc {
8048        // The fixture name doubles as the temp file's, so two tests picking the
8049        // same one raced under the parallel runner and read each other's body —
8050        // a green suite proving the wrong thing. The counter makes that
8051        // unreachable rather than asking every future caller to notice.
8052        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8053        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8054        let mut p = std::env::temp_dir();
8055        p.push(format!("leaf_test_{name}_{seq}.md"));
8056        std::fs::write(&p, body).unwrap();
8057        let mut d = Doc::open(p).unwrap();
8058        d.view = view;
8059        if view == View::Wysiwyg {
8060            d.build_visual(80);
8061        }
8062        d
8063    }
8064
8065    // Source-view document for the source-behaviour tests. `Doc::open` now
8066    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
8067    // `wysiwyg_doc` builds the rich-text variant on top of this.
8068    fn doc_with(name: &str, body: &str) -> Doc {
8069        doc_in(View::Source, name, body)
8070    }
8071
8072    /// Every visual row's drawn text — what the reader actually sees, which is
8073    /// the only thing the reveal preference is supposed to change.
8074    fn drawn_rows(d: &Doc) -> Vec<String> {
8075        d.vmap
8076            .rows
8077            .iter()
8078            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8079            .collect()
8080    }
8081
8082    /// Put the caret at the first byte of `needle` and rebuild, so the row under
8083    /// it becomes the revealed line.
8084    fn caret_at(d: &mut Doc, needle: &str) {
8085        d.caret = d.source.find(needle).expect("needle in source");
8086        d.build_visual(80);
8087    }
8088
8089    #[test]
8090    fn blockquote_after_a_list_is_not_bulleted() {
8091        // twig nests a following top-level block quote under the `bullet_list`
8092        // (a direct child, not a `list_item`). The map must render it de-nested —
8093        // `│ quote`, never `• │ quote` — with a blank separator, like any block
8094        // that follows a list. Regression for the "combined list + blockquote" bug.
8095        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
8096        d.build_visual(80);
8097        let rows: Vec<String> = d
8098            .vmap
8099            .rows
8100            .iter()
8101            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8102            .collect();
8103        assert!(
8104            rows.iter().any(|r| r == "│ quote"),
8105            "block quote should render on its own gutter, got rows: {rows:?}"
8106        );
8107        assert!(
8108            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
8109            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
8110        );
8111    }
8112
8113    // ── the map is built at most once per (revision, wrap) ───────────────────
8114    //
8115    // A frontend repaints for reasons that have nothing to do with the text — a
8116    // blinking caret, a scroll — and rebuilding the map is O(document). These
8117    // pin *that the cache fires*, which a passing suite can't tell you: a cache
8118    // that never hits is invisible to every other test in this file.
8119    //
8120    // The probe is to wreck the built map and ask for it again. A rebuild
8121    // repairs it; a cache hit hands the wreckage straight back. Nothing else
8122    // can distinguish the two from outside.
8123
8124    #[test]
8125    fn a_rebuild_with_nothing_changed_reuses_the_map() {
8126        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
8127        d.build_visual(80);
8128        assert!(!d.vmap.rows.is_empty());
8129        d.vmap.rows.clear(); // wreck it
8130        d.build_visual(80);
8131        assert!(
8132            d.vmap.rows.is_empty(),
8133            "the map was rebuilt though nothing changed — the cache never fired"
8134        );
8135    }
8136
8137    #[test]
8138    fn an_edit_rebuilds_the_map() {
8139        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
8140        d.build_visual(80);
8141        let before = d.revision();
8142        d.vmap.rows.clear();
8143        d.insert("x");
8144        d.build_visual(80);
8145        assert!(d.revision() > before, "an edit must move the revision");
8146        assert!(
8147            !d.vmap.rows.is_empty(),
8148            "an edited document must not paint from a stale map"
8149        );
8150    }
8151
8152    #[test]
8153    fn a_width_change_rebuilds_the_map() {
8154        // The map is a function of the wrap width too, so a resize is a miss
8155        // even though the text is untouched.
8156        let mut d = doc_in(
8157            View::Wysiwyg,
8158            "cache_width",
8159            "one two three four five six\n",
8160        );
8161        d.build_visual(80);
8162        d.vmap.rows.clear();
8163        d.build_visual(12);
8164        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8165        // And the unwrapped map is its own key, not the same as any width.
8166        d.vmap.rows.clear();
8167        d.build_visual_unwrapped();
8168        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8169    }
8170
8171    #[test]
8172    fn a_motion_does_not_rebuild_the_map() {
8173        // The whole point: moving the caret changes nothing the map is built
8174        // from. If a motion bumped the revision, every arrow key would cost a
8175        // full rebuild and the cache would be worthless.
8176        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8177        d.build_visual(80);
8178        let rev = d.revision();
8179        d.move_right(false);
8180        d.move_right(true);
8181        d.move_down(false);
8182        assert_eq!(d.revision(), rev, "a motion must not move the revision");
8183        d.vmap.rows.clear();
8184        d.build_visual(80);
8185        assert!(
8186            d.vmap.rows.is_empty(),
8187            "a motion should not rebuild the map"
8188        );
8189    }
8190
8191    #[test]
8192    fn saving_does_not_rebuild_the_map() {
8193        // Saving changes `dirty`, not the text.
8194        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8195        d.insert("x");
8196        d.build_visual(80);
8197        let rev = d.revision();
8198        d.save();
8199        assert_eq!(d.revision(), rev, "a save must not move the revision");
8200        assert!(!d.dirty, "the save should have cleaned the document");
8201    }
8202
8203    #[test]
8204    fn a_reload_rebuilds_the_map() {
8205        // Reload replaces the text without going through `refresh`, so it has to
8206        // move the revision itself — else the editor paints the old file.
8207        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8208        d.build_visual(80);
8209        let rev = d.revision();
8210        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8211        d.reload();
8212        assert!(d.revision() > rev, "a reload must move the revision");
8213        d.build_visual(80);
8214        let text: String = d
8215            .vmap
8216            .rows
8217            .iter()
8218            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8219            .collect();
8220        assert!(
8221            text.contains("wholly new"),
8222            "the reloaded text should be on screen, got {text:?}"
8223        );
8224    }
8225
8226    // ── golden-case harness ──────────────────────────────────────────────────
8227    // The pattern the whole parity suite can reuse: write a fixture with the
8228    // caret marked by `|`, run one action, and compare the rendered result —
8229    // also caret-marked — against the expected string. One readable line per
8230    // behavior, and it exercises the exact `Doc` ops both frontends call.
8231
8232    /// Split a `|`-marked fixture into `(source, caret_offset)`.
8233    fn parse_caret(marked: &str) -> (String, usize) {
8234        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8235        (marked.replacen('|', "", 1), caret)
8236    }
8237
8238    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8239    /// selection) so a result reads like the fixtures.
8240    fn render_caret(d: &Doc) -> String {
8241        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8242        // so the caret always renders inside its own selection.
8243        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8244        if let Some((s, e)) = d.selection() {
8245            marks.push((s, 0, '['));
8246            marks.push((e, 2, ']'));
8247        }
8248        // Insert right-to-left: descending offset, then descending rank.
8249        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8250        let mut out = d.source.clone();
8251        for (at, _, ch) in marks {
8252            out.insert(at, ch);
8253        }
8254        out
8255    }
8256
8257    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8258    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8259        golden_in(View::Source, name, marked, action)
8260    }
8261
8262    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8263    /// the same fixture has to read the same way in both.
8264    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8265        let (src, caret) = parse_caret(marked);
8266        let mut d = doc_in(view, name, &src);
8267        d.caret = caret;
8268        action(&mut d);
8269        render_caret(&d)
8270    }
8271
8272    #[test]
8273    fn word_motion_walks_word_by_word() {
8274        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8275        assert_eq!(
8276            g("hello wor|ld", |d| d.move_word_left(false)),
8277            "hello |world"
8278        );
8279        assert_eq!(
8280            g("hello| world", |d| d.move_word_left(false)),
8281            "|hello world"
8282        );
8283        assert_eq!(
8284            g("hel|lo world", |d| d.move_word_right(false)),
8285            "hello| world"
8286        );
8287        assert_eq!(
8288            g("hello| world", |d| d.move_word_right(false)),
8289            "hello world|"
8290        );
8291        // Punctuation is its own class, so motion stops at the boundary.
8292        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8293    }
8294
8295    #[test]
8296    fn word_motion_extends_the_selection_when_asked() {
8297        assert_eq!(
8298            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8299            "hello [world|]"
8300        );
8301    }
8302
8303    #[test]
8304    fn delete_word_removes_a_whole_word() {
8305        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8306        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8307        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8308        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8309    }
8310
8311    // ── Home / End ───────────────────────────────────────────────────────────
8312
8313    #[test]
8314    fn home_toggles_between_the_line_s_text_and_its_margin() {
8315        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8316        // indent to the markup it spells everywhere it means one, so the fixture
8317        // with whitespace left to walk is a code block, which is verbatim.
8318        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8319        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
8320        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
8321        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
8322        // A line with no indentation has one place to go, so the toggle is a
8323        // no-op rather than a trip to nowhere.
8324        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8325        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8326
8327        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
8328        let indent = d.source.find("    indented").unwrap();
8329        d.caret = indent + 6; // inside "indented"
8330        d.move_home(false);
8331        assert_eq!(
8332            d.caret,
8333            indent + 4,
8334            "wysiwyg: Home aims at the code line's text"
8335        );
8336        d.move_home(false);
8337        assert_eq!(
8338            d.caret, indent,
8339            "wysiwyg: the second press takes the indent"
8340        );
8341        d.move_home(false);
8342        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8343    }
8344
8345    #[test]
8346    fn end_takes_the_line_the_view_is_showing() {
8347        // The line differs by view for the same document, and that is the point:
8348        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8349        // as a space on one row and the source view as two lines.
8350        let mut d = doc_with("end_src", "one two\nthree\n");
8351        d.caret = 1;
8352        d.move_end(false);
8353        assert_eq!(d.caret, 7, "source: the end of the source line");
8354
8355        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8356        d.caret = 1;
8357        d.move_end(false);
8358        assert_eq!(
8359            d.caret, 13,
8360            "wysiwyg: the end of the row, soft break and all"
8361        );
8362    }
8363
8364    #[test]
8365    fn home_and_end_extend_the_selection_when_asked() {
8366        for (view, tag) in VIEWS {
8367            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
8368            d.caret = 6;
8369            d.move_end(true);
8370            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
8371            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
8372            d.caret = 6;
8373            d.move_home(true);
8374            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
8375        }
8376    }
8377
8378    // ── kill to the line's start / end ───────────────────────────────────────
8379
8380    #[test]
8381    fn kill_to_the_line_start_and_end_in_both_views() {
8382        for (view, tag) in VIEWS {
8383            // The gap that reads as a paragraph break in each view: the source
8384            // view's lines are the renderer's rows only where the source says so.
8385            let gap = if view == View::Source { "\n" } else { "\n\n" };
8386            let mut d = doc_in(
8387                view,
8388                &format!("kill_end_{tag}"),
8389                &format!("one two{gap}three\n"),
8390            );
8391            d.caret = 3;
8392            d.delete_to_line_end();
8393            assert_eq!(
8394                d.source,
8395                format!("one{gap}three\n"),
8396                "{tag}: ^K to the line's end"
8397            );
8398            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
8399
8400            let mut d = doc_in(
8401                view,
8402                &format!("kill_start_{tag}"),
8403                &format!("one two{gap}three\n"),
8404            );
8405            d.caret = 7; // the end of the first line
8406            d.delete_to_line_start();
8407            assert_eq!(
8408                d.source,
8409                format!("{gap}three\n"),
8410                "{tag}: ⌘⌫ to the line's start"
8411            );
8412            assert_eq!(d.caret, 0, "{tag}");
8413        }
8414    }
8415
8416    #[test]
8417    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
8418        // The decision: at the boundary both kills do nothing, rather than
8419        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
8420        // at a soft wrap as often as at a newline, where there is nothing
8421        // written to delete — and a source newline is only half of the blank
8422        // line between two paragraphs, so taking it leaves a soft break rather
8423        // than the join it looks like. Backspace and Delete are the keys for it.
8424        for (view, tag) in VIEWS {
8425            let gap = if view == View::Source { "\n" } else { "\n\n" };
8426            let src = format!("one{gap}three\n");
8427            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
8428            d.caret = 3; // the end of "one"
8429            d.delete_to_line_end();
8430            assert_eq!(
8431                d.source, src,
8432                "{tag}: ^K at the line's end joined it to the next"
8433            );
8434
8435            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
8436            d.caret = 3 + gap.len(); // the start of "three"
8437            d.delete_to_line_start();
8438            assert_eq!(
8439                d.source, src,
8440                "{tag}: ⌘⌫ at the line's start joined it to the last"
8441            );
8442        }
8443    }
8444
8445    #[test]
8446    fn a_kill_takes_the_selection_when_there_is_one() {
8447        // What every other delete here does with one, so these two as well.
8448        for (view, tag) in VIEWS {
8449            for (name, kill) in [
8450                (
8451                    "end",
8452                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
8453                ),
8454                ("start", |d: &mut Doc| d.delete_to_line_start()),
8455            ] {
8456                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
8457                d.anchor = Some(4);
8458                d.caret = 7; // "two"
8459                kill(&mut d);
8460                assert_eq!(
8461                    d.source, "one  three\n",
8462                    "{tag}: {name} ignored the selection"
8463                );
8464                assert_eq!(d.selection(), None, "{tag}: {name}");
8465            }
8466        }
8467    }
8468
8469    #[test]
8470    fn a_kill_takes_the_markup_it_empties_with_it() {
8471        // The same hazard a word-delete has: a WYSIWYG range covers what the
8472        // user can see, which for `**bold**` is the word and never the
8473        // delimiters, so a kill that stopped at the text would leave `a ****` —
8474        // markup wrapped around nothing.
8475        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
8476        d.caret = d.source.find("bold").unwrap();
8477        d.delete_to_line_end();
8478        assert_eq!(d.source, "a \n");
8479    }
8480
8481    #[test]
8482    fn a_kill_is_undone_in_one_step() {
8483        for (view, tag) in VIEWS {
8484            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
8485            d.caret = 3;
8486            d.delete_to_line_end();
8487            assert_eq!(d.source, "one\n", "{tag}");
8488            d.undo();
8489            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
8490        }
8491    }
8492
8493    #[test]
8494    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
8495        // Regression: triple-click used move_home/move_end over visual rows, so
8496        // it only worked on a paragraph's first row (a wrap-boundary offset maps
8497        // to the earlier row). select_block_at reads the AST, so every offset in
8498        // the paragraph selects the whole thing.
8499        let body = "one two three four five six seven eight\n";
8500        let mut d = doc_with("sel_block", body);
8501        d.view = View::Wysiwyg;
8502        d.build_visual(12); // force the paragraph to wrap into several rows
8503        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
8504        let para = (0, "one two three four five six seven eight".len());
8505        for off in [0usize, 8, 19, 28, 38] {
8506            d.caret = 0;
8507            d.anchor = None;
8508            d.select_block_at(off);
8509            assert_eq!(
8510                d.selection(),
8511                Some(para),
8512                "offset {off} should select the paragraph"
8513            );
8514        }
8515    }
8516
8517    #[test]
8518    fn select_block_uses_content_span_for_a_heading() {
8519        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
8520        d.select_block_at(4); // inside "Title"
8521        // content_span excludes the "# " marker.
8522        assert_eq!(d.selected_text(), Some("Title"));
8523        d.select_block_at(10); // inside "body"
8524        assert_eq!(d.selected_text(), Some("body"));
8525    }
8526
8527    #[test]
8528    fn select_all_spans_the_document() {
8529        let mut d = doc_with("sel_all", "abc\n\ndef\n");
8530        d.select_all();
8531        assert_eq!(d.selection(), Some((0, d.source.len())));
8532    }
8533
8534    #[test]
8535    fn select_word_at_picks_the_surrounding_word() {
8536        let mut d = doc_with("sel_word", "hello world\n");
8537        d.select_word_at(8); // inside "world"
8538        assert_eq!(d.selection(), Some((6, 11)));
8539        // Double-clicking at end-of-word still grabs the word to its left.
8540        d.select_word_at(5); // the space between the words
8541        assert_eq!(d.selection(), Some((5, 6)));
8542    }
8543
8544    #[test]
8545    fn word_helpers_respect_utf8_boundaries() {
8546        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
8547        assert_eq!(
8548            golden("utf8", "|café ok", |d| d.move_word_right(false)),
8549            "café| ok"
8550        );
8551        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
8552    }
8553
8554    #[test]
8555    fn typing_inserts_at_the_caret_and_advances_it() {
8556        let mut d = doc_with("type", "hello\n");
8557        d.insert("Hi ");
8558        assert_eq!(d.source, "Hi hello\n");
8559        assert_eq!(d.caret, 3);
8560        assert!(d.dirty);
8561    }
8562
8563    #[test]
8564    fn backspace_deletes_the_char_before_the_caret() {
8565        let mut d = doc_with("bs", "hello\n");
8566        d.caret = 3; // after "hel"
8567        d.backspace();
8568        assert_eq!(d.source, "helo\n");
8569        assert_eq!(d.caret, 2);
8570    }
8571
8572    #[test]
8573    fn typing_replaces_the_selection() {
8574        let mut d = doc_with("replace", "a word b\n");
8575        d.anchor = Some(2);
8576        d.caret = 6; // "word" selected
8577        d.insert("X");
8578        assert_eq!(d.source, "a X b\n");
8579        assert_eq!(d.caret, 3);
8580        assert_eq!(d.anchor, None);
8581    }
8582
8583    #[test]
8584    fn toggle_bold_wraps_then_unwraps_the_selection() {
8585        let mut d = doc_with("bold", "a word b\n");
8586        d.anchor = Some(2);
8587        d.caret = 6;
8588        d.toggle(InlineKind::Strong);
8589        assert_eq!(d.source, "a **word** b\n");
8590        // The toggled region stays selected, so a second toggle reverses it.
8591        d.toggle(InlineKind::Strong);
8592        assert_eq!(d.source, "a word b\n");
8593        d.toggle(InlineKind::Strong);
8594        assert_eq!(d.source, "a **word** b\n");
8595    }
8596
8597    #[test]
8598    fn toggle_code_wraps_then_unwraps_the_selection() {
8599        let mut d = doc_with("code_rt", "a word b\n");
8600        d.anchor = Some(2);
8601        d.caret = 6;
8602        d.toggle(InlineKind::Verbatim);
8603        assert_eq!(d.source, "a `word` b\n");
8604        d.toggle(InlineKind::Verbatim);
8605        assert_eq!(d.source, "a word b\n");
8606    }
8607
8608    #[test]
8609    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
8610        // ⌘b at a bare caret, then type: the text comes out bold with no
8611        // selection ever made — the word-processor "start bold here" gesture.
8612        let mut d = doc_with("sticky_wrap", "xy\n");
8613        d.caret = 1; // between x and y
8614        d.toggle(InlineKind::Strong);
8615        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
8616        d.insert("A");
8617        assert_eq!(d.source, "x**A**y\n");
8618    }
8619
8620    #[test]
8621    fn sticky_bold_lights_the_toolbar_before_any_typing() {
8622        // The button must light the instant ⌘b is pressed, or the mode is
8623        // invisible until the first character lands.
8624        let mut d = doc_with("sticky_light", "xy\n");
8625        d.caret = 1;
8626        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8627        d.toggle(InlineKind::Strong);
8628        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8629    }
8630
8631    #[test]
8632    fn sticky_bold_toggled_off_types_normally_again() {
8633        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
8634        // in the flow of typing, the exact sequence the user described.
8635        let mut d = doc_with("sticky_off", "\n");
8636        d.caret = 0;
8637        d.toggle(InlineKind::Strong);
8638        d.insert("a");
8639        d.insert("b"); // continues inside the run, no re-arming
8640        assert_eq!(d.source, "**ab**\n");
8641        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
8642        d.insert("c");
8643        assert_eq!(d.source, "**ab**c\n");
8644    }
8645
8646    #[test]
8647    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
8648        // Once a mark is realised the caret sits inside the run, so plain typing
8649        // extends it rather than starting a second, adjacent bold span.
8650        let mut d = doc_with("sticky_cont", "\n");
8651        d.caret = 0;
8652        d.toggle(InlineKind::Emph);
8653        d.insert("h");
8654        d.insert("i");
8655        assert_eq!(d.source, "*hi*\n");
8656    }
8657
8658    #[test]
8659    fn moving_the_caret_disarms_a_sticky_mark() {
8660        // Arming a mark and then moving away must not style text elsewhere.
8661        let mut d = doc_with("sticky_disarm", "xy\n");
8662        d.caret = 0;
8663        d.toggle(InlineKind::Strong);
8664        d.move_right(false); // caret 0 → 1, disarms
8665        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8666        d.insert("A");
8667        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
8668    }
8669
8670    #[test]
8671    fn stacked_sticky_marks_apply_together() {
8672        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
8673        let mut d = doc_with("sticky_stack", "\n");
8674        d.caret = 0;
8675        d.toggle(InlineKind::Strong);
8676        d.toggle(InlineKind::Emph);
8677        d.insert("x");
8678        // Land the caret on the styled character and confirm both marks are live.
8679        d.anchor = Some(d.source.find('x').unwrap());
8680        d.caret = d.anchor.unwrap() + 1;
8681        let marks = d.active_inline_marks();
8682        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
8683        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
8684    }
8685
8686    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
8687
8688    #[test]
8689    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
8690        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
8691        // The space inside the run made `**bold **`, which is *not* bold — four
8692        // literal asterisks — so the rich view drew them, correctly and
8693        // uselessly, until the next character happened to close the run again.
8694        let mut d = wysiwyg_doc("edge_typing", "a \n");
8695        d.caret = 2;
8696        d.toggle(InlineKind::Strong);
8697        for c in "bold".chars() {
8698            d.insert(&c.to_string());
8699        }
8700        assert_eq!(d.source, "a **bold**\n");
8701        d.insert(" ");
8702        assert_eq!(
8703            d.source, "a **bold** \n",
8704            "the space belongs outside the run"
8705        );
8706        assert!(
8707            d.active_inline_marks().contains(InlineKind::Strong),
8708            "bold is still what's being typed, so the button stays lit"
8709        );
8710        // What the writer is looking at while all this happens: their words.
8711        d.build_visual(80);
8712        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
8713        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
8714        for c in "hey".chars() {
8715            d.insert(&c.to_string());
8716        }
8717        assert_eq!(
8718            d.source, "a **bold hey**\n",
8719            "one bold phrase, not two runs"
8720        );
8721    }
8722
8723    #[test]
8724    fn typing_past_a_space_can_still_leave_the_bold_behind() {
8725        // The other half: the marks stay armed across the space, so ⌘b turns
8726        // them off again there and the next word is plain — the run isn't
8727        // rejoined by a caret that was told not to.
8728        let mut d = wysiwyg_doc("edge_shed", "\n");
8729        d.caret = 0;
8730        d.toggle(InlineKind::Strong);
8731        for c in "bold ".chars() {
8732            d.insert(&c.to_string());
8733        }
8734        assert_eq!(d.source, "**bold** \n");
8735        d.toggle(InlineKind::Strong);
8736        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
8737        d.insert("x");
8738        assert_eq!(d.source, "**bold** x\n");
8739    }
8740
8741    #[test]
8742    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
8743        // ⌘b and then a space before any word: the space is not marked (nothing
8744        // is), and the word after it is.
8745        let mut d = wysiwyg_doc("edge_space_first", "a\n");
8746        d.caret = 1;
8747        d.toggle(InlineKind::Strong);
8748        d.insert(" ");
8749        assert_eq!(d.source, "a \n");
8750        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8751        d.insert("b");
8752        assert_eq!(d.source, "a **b**\n");
8753    }
8754
8755    #[test]
8756    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
8757        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
8758        d.caret = 8; // the caret's home at the end of the run's text
8759        d.insert(" ");
8760        assert_eq!(
8761            d.source, "x **bold** \n",
8762            "the space lands past the delimiters"
8763        );
8764        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
8765
8766        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
8767        d.caret = 4; // in front of the "b"
8768        d.insert(" ");
8769        assert_eq!(d.source, "x  **bold** y\n");
8770        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
8771    }
8772
8773    #[test]
8774    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
8775        // Backspace over the last letter of a bold phrase.
8776        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
8777        d.caret = 10; // past the "h"
8778        d.backspace();
8779        assert_eq!(d.source, "a **bold** \n");
8780        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
8781        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8782        d.insert("x");
8783        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
8784    }
8785
8786    #[test]
8787    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
8788        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
8789        // mark, which is only text. The marks live on in the caret instead.
8790        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
8791        d.caret = 5;
8792        d.backspace();
8793        assert_eq!(d.source, "a  c\n");
8794        assert!(d.active_inline_marks().contains(InlineKind::Strong));
8795        d.insert("x");
8796        assert_eq!(d.source, "a **x** c\n");
8797    }
8798
8799    #[test]
8800    fn typing_over_a_whole_bold_word_keeps_it_bold() {
8801        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
8802        d.anchor = Some(4);
8803        d.caret = 8; // the word, not its delimiters
8804        d.insert("x");
8805        assert_eq!(d.source, "a **x** c\n");
8806    }
8807
8808    #[test]
8809    fn a_code_span_keeps_the_space_it_is_given() {
8810        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
8811        // is still verbatim, so nothing is re-spelt. The repair asks the parser
8812        // rather than a table of kinds, and this is the answer it gets.
8813        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
8814        d.caret = 7;
8815        d.insert(" ");
8816        assert_eq!(d.source, "a `code ` c\n");
8817    }
8818
8819    #[test]
8820    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
8821        // A run's closing delimiter has a caret home on each side of it, one
8822        // column apart on screen — and a plain ← off the space after a bold word
8823        // lands on the outer one. The character drawn behind the caret there is
8824        // still the last letter of the phrase, so that is what Backspace takes;
8825        // the byte behind it is a `*` nobody can see.
8826        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
8827        d.caret = 9;
8828        d.move_left(false);
8829        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
8830        d.backspace();
8831        assert_eq!(
8832            d.source, "**bol** x\n",
8833            "a letter of the phrase, not its `*`"
8834        );
8835        assert_eq!(d.caret, 5);
8836
8837        // And the mirror in front of the opening delimiter, where Delete's
8838        // character is the first letter of the run.
8839        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
8840        d.caret = 1;
8841        d.delete_forward();
8842        assert_eq!(d.source, "x**old**\n");
8843        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
8844    }
8845
8846    #[test]
8847    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
8848        // The byte beside the caret at either edge of a bold word is a `*` the
8849        // rich view draws nothing for. Taking it is not the character delete the
8850        // key was pressed for — it unspells the run and puts a literal asterisk
8851        // on screen (`a *bold** c`). The visible character is the one that goes.
8852        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
8853        d.caret = 4; // in front of the "b"
8854        d.backspace();
8855        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
8856
8857        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
8858        d.caret = 8; // past the "d"
8859        d.delete_forward();
8860        assert_eq!(d.source, "a **bold**c\n");
8861        assert_eq!(d.caret, 8, "and the caret stays inside the run");
8862        d.insert("x");
8863        assert_eq!(d.source, "a **boldx**c\n");
8864
8865        // A code span's backticks are hidden the same way, so they are covered
8866        // by the same rule and not by a list of kinds.
8867        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
8868        d.caret = 3;
8869        d.backspace();
8870        assert_eq!(d.source, "a`code` c\n");
8871    }
8872
8873    #[test]
8874    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
8875        // The asterisks are on the screen there and the caret can stand between
8876        // them, so a delete takes exactly the byte it is aimed at.
8877        let mut d = doc_with("edge_open_src", "a **bold** c\n");
8878        d.caret = 4;
8879        d.backspace();
8880        assert_eq!(d.source, "a *bold** c\n");
8881
8882        let mut d = doc_with("edge_close_src", "a **bold** c\n");
8883        d.caret = 8;
8884        d.delete_forward();
8885        assert_eq!(d.source, "a **bold* c\n");
8886    }
8887
8888    #[test]
8889    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
8890        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
8891        // The space had stepped outside the run (the mark-edge rule), taking the
8892        // caret with it, so the delete put it back down on the far side of the
8893        // closing `**` — one place on screen, and the wrong side of it. Typing
8894        // came out plain and the toolbar went dark, with nothing to see.
8895        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
8896        d.caret = 0;
8897        d.toggle(InlineKind::Strong);
8898        for c in "bold".chars() {
8899            d.insert(&c.to_string());
8900        }
8901        d.insert(" ");
8902        assert_eq!(d.source, "**bold** \n");
8903        d.backspace();
8904        assert_eq!(
8905            d.source, "**bold**\n",
8906            "the space goes, the delimiters stay"
8907        );
8908        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
8909        assert!(
8910            d.active_inline_marks().contains(InlineKind::Strong),
8911            "so the button is still lit"
8912        );
8913        d.insert("x");
8914        assert_eq!(
8915            d.source, "**boldx**\n",
8916            "and the next character is still bold"
8917        );
8918    }
8919
8920    #[test]
8921    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
8922        // What the stranded caret did next: the byte behind it was the closing
8923        // `*`, so a second press took that instead of a letter — `**bold*`, the
8924        // styling gone and an asterisk on the screen where the word had been.
8925        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
8926        d.caret = 0;
8927        d.toggle(InlineKind::Strong);
8928        for c in "bold ".chars() {
8929            d.insert(&c.to_string());
8930        }
8931        assert_eq!(d.source, "**bold** \n");
8932        d.backspace();
8933        d.backspace();
8934        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
8935        assert_eq!(d.caret, 5);
8936    }
8937
8938    #[test]
8939    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
8940        // `***both***` closes two runs with one stack of asterisks: the caret has
8941        // to walk in through all of them, or it lands between the emph and the
8942        // strong and types half-marked.
8943        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
8944        d.caret = 11;
8945        d.backspace();
8946        assert_eq!(d.source, "***both***\n");
8947        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
8948        d.insert("x");
8949        assert_eq!(d.source, "***bothx***\n");
8950    }
8951
8952    #[test]
8953    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
8954        // The settle only moves a caret a run actually closed over. Ordinary
8955        // deletes — inside a run, or in plain prose — are untouched.
8956        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
8957        d.caret = 8;
8958        d.backspace();
8959        assert_eq!(d.source, "a **bol** c\n");
8960        assert_eq!(d.caret, 7);
8961
8962        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
8963        d.caret = 5;
8964        d.backspace();
8965        assert_eq!(d.source, "plai\n");
8966        assert_eq!(d.caret, 4);
8967    }
8968
8969    #[test]
8970    fn the_source_view_leaves_a_delete_where_it_landed() {
8971        // The delimiters are on the screen there, so the offset past them is a
8972        // place the caret can be seen to be — nothing to settle.
8973        let mut d = doc_with("edge_bksp_src", "**bold** \n");
8974        d.caret = 9;
8975        d.backspace();
8976        assert_eq!(d.source, "**bold**\n");
8977        assert_eq!(d.caret, 8);
8978    }
8979
8980    #[test]
8981    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
8982        // `***both***` closes two runs with one stack of asterisks; a space that
8983        // clears only the inner one lands against the outer's and breaks that
8984        // instead.
8985        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
8986        d.caret = 9;
8987        d.insert(" ");
8988        assert_eq!(d.source, "a ***both*** \n");
8989        assert_eq!(d.caret, 13);
8990        d.insert("x");
8991        assert_eq!(d.source, "a ***both x***\n");
8992    }
8993
8994    #[test]
8995    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
8996        // The delimiter shuffle is not an edit the writer made, so it is not a
8997        // step they have to undo past.
8998        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
8999        d.caret = 8;
9000        d.insert(" ");
9001        assert_eq!(d.source, "a **bold** \n");
9002        d.undo();
9003        assert_eq!(d.source, "a **bold**\n");
9004    }
9005
9006    #[test]
9007    fn the_source_view_types_the_space_where_it_was_asked_to() {
9008        // The rule is a rich-view courtesy. In the source view the delimiters are
9009        // on the screen and the user is editing the bytes they can see.
9010        let mut d = doc_with("edge_src", "a **bold** c\n");
9011        d.caret = 8;
9012        d.insert(" ");
9013        assert_eq!(d.source, "a **bold ** c\n");
9014    }
9015
9016    #[test]
9017    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
9018        // Double-clicking a word takes the space after it; bolding that must not
9019        // spell `**word **`, which is not bold at all.
9020        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
9021        d.anchor = Some(2);
9022        d.caret = 7; // "word "
9023        d.toggle(InlineKind::Strong);
9024        assert_eq!(d.source, "a **word** b\n");
9025        d.toggle(InlineKind::Strong);
9026        assert_eq!(d.source, "a word b\n");
9027        d.toggle(InlineKind::Strong);
9028        assert_eq!(
9029            d.source, "a **word** b\n",
9030            "reapplying the mark must not wrap stale delimiter offsets"
9031        );
9032        // And a selection of nothing but whitespace has no word to mark.
9033        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
9034        d.anchor = Some(6);
9035        d.caret = 7;
9036        d.toggle(InlineKind::Strong);
9037        assert_eq!(d.source, "a word b\n");
9038        assert!(d.status.is_some());
9039    }
9040
9041    #[test]
9042    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
9043        let mut d = doc_with("head_set", "hello\n");
9044        d.caret = 2; // caret inside the paragraph, no selection
9045        d.set_block(BlockKind::Heading(1));
9046        assert_eq!(d.source, "# hello\n");
9047    }
9048
9049    #[test]
9050    fn set_block_heading_works_in_wysiwyg_view() {
9051        // The app defaults to WYSIWYG; the caret is a source offset either way.
9052        let mut d = wysiwyg_doc("head_wys", "hello\n");
9053        d.caret = 2;
9054        d.set_block(BlockKind::Heading(1));
9055        assert_eq!(d.source, "# hello\n");
9056    }
9057
9058    #[test]
9059    fn toggle_heading_applies_switches_and_reverts() {
9060        let mut d = doc_with("head_toggle", "hello\n");
9061        d.caret = 2;
9062        d.toggle_heading(1);
9063        assert_eq!(d.source, "# hello\n"); // paragraph → H1
9064        d.toggle_heading(2);
9065        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
9066        d.toggle_heading(2);
9067        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
9068    }
9069
9070    #[test]
9071    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
9072        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
9073        // the blank line but the caret rendered on the *next* line, because the
9074        // separator was a non-navigable decoration row. In Preserve flow that
9075        // blank line is a real caret home — the caret must resolve onto it, and
9076        // typing there makes the soft break that continues the paragraph.
9077        let src = "line one:\nsecond line\n";
9078        let mut d = wysiwyg_doc("pre_enter_lineend", src);
9079        d.set_line_flow(LineFlow::Preserve);
9080        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
9081        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
9082        d.newline();
9083        d.build_visual_unwrapped();
9084        assert_eq!(d.source, "line one:\n\nsecond line\n");
9085        assert_eq!(
9086            d.caret, 10,
9087            "caret sits on the new blank line, not the next line"
9088        );
9089        // The blank line is row 1, and the caret resolves onto it — not row 2.
9090        assert_eq!(
9091            d.vmap.pos_of_offset(10),
9092            (1, 0),
9093            "caret renders on the blank row"
9094        );
9095        assert!(
9096            !d.vmap.rows[1].decoration,
9097            "the blank line is navigable in Preserve"
9098        );
9099        // Typing there makes a soft break: one paragraph, three lines.
9100        d.insert("new clause,");
9101        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
9102    }
9103
9104    #[test]
9105    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
9106        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
9107        // that keeps it one paragraph — where Fold would open a second paragraph.
9108        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
9109        d.set_line_flow(LineFlow::Preserve);
9110        d.caret = 3;
9111        d.newline();
9112        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
9113
9114        // End-of-paragraph: Enter then typing continues the same paragraph on a
9115        // new line (a soft break), not a fresh paragraph.
9116        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
9117        d.set_line_flow(LineFlow::Preserve);
9118        d.caret = 3;
9119        d.newline();
9120        d.insert("def");
9121        assert_eq!(
9122            d.source, "abc\ndef\n",
9123            "end-of-line Enter + typing is a soft break"
9124        );
9125    }
9126
9127    #[test]
9128    fn preserve_double_enter_still_makes_a_paragraph() {
9129        // Two Enters in a row promote to a real paragraph break: the second lands
9130        // on the blank line the first opened and takes the empty-line branch.
9131        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
9132        d.set_line_flow(LineFlow::Preserve);
9133        d.caret = 3;
9134        d.newline();
9135        d.newline();
9136        d.insert("def");
9137        assert_eq!(
9138            d.source, "abc\n\ndef\n",
9139            "double Enter is a paragraph break"
9140        );
9141    }
9142
9143    #[test]
9144    fn preserve_backspace_joins_across_a_soft_break() {
9145        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
9146        // soft break it deletes the single newline and joins the two lines.
9147        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
9148        d.set_line_flow(LineFlow::Preserve);
9149        d.build_visual(80);
9150        d.caret = 4; // start of "def", just past the soft break
9151        d.backspace();
9152        assert_eq!(
9153            d.source, "abcdef\n",
9154            "Backspace joins across the soft break"
9155        );
9156        assert_eq!(d.caret, 3, "caret lands where the lines meet");
9157    }
9158
9159    #[test]
9160    fn fold_enter_still_starts_a_new_paragraph() {
9161        // The default flow is unchanged: a lone `\n` would render as an invisible
9162        // space, so Enter keeps opening the paragraph break that actually shows.
9163        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9164        d.caret = 3;
9165        d.newline();
9166        assert_eq!(
9167            d.source, "abc\n\ndef\n",
9168            "Fold mid-line Enter is a paragraph break"
9169        );
9170    }
9171
9172    #[test]
9173    fn wysiwyg_one_enter_starts_a_new_paragraph() {
9174        // Regression: one Enter left the caret between the two newlines, so typing
9175        // made a soft break (one paragraph) and you needed a second Enter.
9176        let mut d = wysiwyg_doc("wys_enter", "abc\n");
9177        d.caret = 3;
9178        d.newline();
9179        d.insert("def");
9180        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9181    }
9182
9183    #[test]
9184    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9185        // Regression: Enter at the caret's natural End-of-line resting place
9186        // after a bold run with nothing following it (on screen: right after
9187        // "bold", before the hidden closing "**") spliced the paragraph break
9188        // at that very byte offset — which sits *before* the closing "**" in
9189        // the source, since the delimiter is hidden and emits no glyph of its
9190        // own for `push_row`'s "end of row" fallback to count. That severed the
9191        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9192        // "**" alone on the new line instead of leaving "**bold**" intact with
9193        // a fresh empty paragraph after it.
9194        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9195        d.move_end(false); // the WYSIWYG End key, from caret 0
9196        assert_eq!(
9197            d.caret, 6,
9198            "caret rests right after \"bold\", before the hidden \"**\""
9199        );
9200        d.newline();
9201        assert!(
9202            d.source.starts_with("**bold**"),
9203            "the closing ** must stay attached to \"bold\": got {:?}",
9204            d.source
9205        );
9206        assert_eq!(
9207            d.source, "**bold**\n\n\n",
9208            "a fresh empty paragraph follows the still-intact bold run"
9209        );
9210    }
9211
9212    #[test]
9213    fn source_view_enter_is_a_single_newline() {
9214        let mut d = doc_with("src_enter", "abc\n");
9215        d.caret = 3;
9216        d.newline();
9217        assert_eq!(d.source, "abc\n\n");
9218    }
9219
9220    #[test]
9221    fn heading_applies_at_the_end_of_a_paragraph() {
9222        // The caret at a line end sits at the doc level; set_block must still find
9223        // the block on that line.
9224        let mut d = doc_with("head_end", "abc\n");
9225        d.caret = 3; // end of "abc"
9226        d.toggle_heading(1);
9227        assert_eq!(d.source, "# abc\n");
9228    }
9229
9230    #[test]
9231    fn heading_on_an_empty_new_paragraph_creates_one() {
9232        let mut d = wysiwyg_doc("head_empty", "abc\n");
9233        d.caret = 3;
9234        d.newline(); // caret now on a fresh, empty paragraph
9235        d.toggle_heading(1);
9236        d.insert("Title");
9237        assert!(d.source.contains("# Title"), "got {:?}", d.source);
9238    }
9239
9240    #[test]
9241    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9242        // The reported bug, end to end: click a blank line with another one under
9243        // it, press H1, type. The text landed in the heading and the caret's
9244        // offset was right (the source view drew it there), but the rich view
9245        // drew it two rows lower, on the trailing blank line — the empty `# `
9246        // heading had left every row below it short by the marker's two bytes,
9247        // and the blank line ended up claiming the heading's own end offset.
9248        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9249        d.build_visual_unwrapped();
9250        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9251        d.toggle_heading(1);
9252        for c in "title".chars() {
9253            d.insert(&c.to_string());
9254            d.build_visual_unwrapped(); // as a frontend does, one frame per key
9255        }
9256        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9257        assert_eq!(
9258            d.caret_pos(),
9259            (4, 5),
9260            "the caret draws at the end of the heading"
9261        );
9262    }
9263
9264    #[test]
9265    fn clicking_an_empty_heading_types_after_its_marker() {
9266        // The same anchor from the other side: the empty heading's row is its own
9267        // caret home, so a click on it must land past the hidden `# `. Landing in
9268        // front of the hashes made the first keystroke un-heading the line.
9269        let mut d = wysiwyg_doc("head_click", "# \n");
9270        d.build_visual_unwrapped();
9271        d.caret = d.vmap.offset_of_pos(0, 0);
9272        d.insert("x");
9273        assert_eq!(d.source, "# x\n");
9274    }
9275
9276    #[test]
9277    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9278        let mut d = wysiwyg_doc("head_enter", "# Title\n");
9279        d.caret = 7; // end of the heading
9280        d.newline();
9281        d.insert("body");
9282        assert_eq!(d.source, "# Title\n\nbody\n");
9283    }
9284
9285    #[test]
9286    fn wysiwyg_enter_continues_a_bullet_list() {
9287        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9288        d.caret = 6; // end of "item"
9289        d.newline();
9290        d.insert("two");
9291        assert_eq!(d.source, "- item\n- two\n");
9292    }
9293
9294    #[test]
9295    fn wysiwyg_enter_increments_an_ordered_list() {
9296        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9297        d.caret = 6; // end of "one"
9298        d.newline();
9299        d.insert("two");
9300        assert_eq!(d.source, "1. one\n2. two\n");
9301    }
9302
9303    #[test]
9304    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9305        // Regression for the "extra newline" left between a list and the paragraph
9306        // below it. Enter, Enter leaves the list on a fresh empty paragraph
9307        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9308        // Backspace should then take the caret cleanly back to the end of the list
9309        // item, `- item\n\nnext`, not delete a single newline and strand it on the
9310        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9311        // no caret can land on. The map is rebuilt between keystrokes exactly as a
9312        // frontend does, since Backspace reads the stop table to place the delete.
9313        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9314        d.caret = 6; // end of "item"
9315        d.newline();
9316        d.build_visual(80);
9317        d.newline(); // leave the list onto a fresh empty paragraph
9318        d.build_visual(80);
9319        assert_eq!(
9320            d.source, "- item\n\n\n\nnext\n",
9321            "double-Enter opens the empty paragraph"
9322        );
9323        d.backspace();
9324        assert_eq!(
9325            d.source, "- item\n\nnext\n",
9326            "one Backspace collapses the whole gap"
9327        );
9328        assert_eq!(
9329            d.caret, 6,
9330            "and lands the caret back at the end of the list item"
9331        );
9332    }
9333
9334    #[test]
9335    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9336        // The stop-wise delete must not over-reach when there is no block boundary
9337        // to cross: two blank lines in a row are one caret stop apart, so pressing
9338        // Enter on an empty line and then Backspace removes exactly the one newline
9339        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9340        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9341        d.caret = 5; // the empty paragraph the first Enter already opened
9342        d.build_visual(80);
9343        d.newline();
9344        d.build_visual(80);
9345        assert_eq!(
9346            d.source, "abc\n\n\n\n",
9347            "Enter on the blank line adds one newline"
9348        );
9349        d.backspace();
9350        assert_eq!(
9351            d.source, "abc\n\n\n",
9352            "Backspace takes back exactly that one newline"
9353        );
9354    }
9355
9356    #[test]
9357    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9358        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9359        d.caret = 6; // end of the empty "- " item
9360        d.newline();
9361        d.insert("p");
9362        assert_eq!(d.source, "- a\n\np\n");
9363    }
9364
9365    #[test]
9366    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
9367        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
9368        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
9369        // not take the list-exit path (which would splice the `- ` away as if
9370        // leaving an empty item); the AST guard sends it to a normal break and
9371        // leaves the underline intact.
9372        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
9373        assert!(
9374            d.nodes().iter().any(|n| n.kind == Kind::Heading),
9375            "precondition: twig parses this as a heading, not a list",
9376        );
9377        d.caret = 7; // on the `- ` underline line
9378        d.newline();
9379        assert!(
9380            d.source.contains("- "),
9381            "the setext underline survives, not spliced away as a list item: {:?}",
9382            d.source,
9383        );
9384    }
9385
9386    #[test]
9387    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
9388        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
9389        d.caret = 7; // end of "abc" inside the fence
9390        d.newline();
9391        d.insert("def");
9392        assert_eq!(d.source, "```\nabc\ndef\n```\n");
9393    }
9394
9395    #[test]
9396    fn wysiwyg_enter_continues_a_block_quote() {
9397        // Enter opens a new *paragraph* inside the quote, not a second line of
9398        // the same one. `> quote\n> more` is a soft break, which under
9399        // `LineFlow::Fold` renders as a space — the keystroke would look like it
9400        // did nothing. The quoted blank line is what makes the break visible, and
9401        // it's the same thing Enter does in running prose.
9402        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
9403        d.caret = 7; // end of "quote"
9404        d.newline();
9405        d.insert("more");
9406        assert_eq!(d.source, "> quote\n>\n> more\n");
9407        // Still one quote, now holding two paragraphs — not a quote and a stray
9408        // line that fell out of it.
9409        let quotes = d
9410            .nodes()
9411            .iter()
9412            .filter(|n| n.kind == Kind::BlockQuote)
9413            .count();
9414        assert_eq!(quotes, 1);
9415    }
9416
9417    #[test]
9418    fn set_block_makes_a_heading_at_the_caret() {
9419        let mut d = doc_with("head", "Title\n\nbody\n");
9420        d.caret = 0;
9421        d.set_block(BlockKind::Heading(2));
9422        assert_eq!(d.source, "## Title\n\nbody\n");
9423        d.set_block(BlockKind::Paragraph);
9424        assert_eq!(d.source, "Title\n\nbody\n");
9425    }
9426
9427    // ── block containers (quote / list) ──────────────────────────────────────
9428
9429    #[test]
9430    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
9431        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
9432        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
9433        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
9434        // A caret at a line end sits at the doc level; the block is still found.
9435        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
9436    }
9437
9438    #[test]
9439    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
9440        // Every source line of the paragraph gets its own `> `, so a caret left
9441        // on its old byte offset falls one prefix per line above it too far
9442        // back — inside the markup it just asked for rather than in its word.
9443        assert_eq!(
9444            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
9445            "> aaa\n> b|bb\n> ccc\n"
9446        );
9447    }
9448
9449    #[test]
9450    fn toggle_blockquote_works_in_wysiwyg_view() {
9451        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9452        assert_eq!(
9453            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
9454            "> hel|lo\n"
9455        );
9456        assert_eq!(
9457            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
9458            "hel|lo\n"
9459        );
9460    }
9461
9462    #[test]
9463    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
9464        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
9465        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9466        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9467        // The *other* kind converts in place instead of nesting, which is what
9468        // makes the two buttons one three-state control.
9469        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
9470        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
9471        // Its own kind, over the only item the list holds, takes it off.
9472        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
9473    }
9474
9475    #[test]
9476    fn toggle_list_works_in_wysiwyg_view() {
9477        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
9478        assert_eq!(
9479            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
9480            "1. hel|lo\n"
9481        );
9482        assert_eq!(
9483            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
9484            "- hel|lo\n"
9485        );
9486        assert_eq!(
9487            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
9488            "hel|lo\n"
9489        );
9490    }
9491
9492    #[test]
9493    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
9494        // The selection has to grow with the markup: twig takes a container off
9495        // only a range covering every block it holds, so the second press can
9496        // reverse the first only if the result is what's selected.
9497        let mut d = doc_with("list_sel", "abc\n\ndef\n");
9498        d.select_all();
9499        d.toggle_list(true);
9500        assert_eq!(d.source, "1. abc\n\n2. def\n");
9501        assert_eq!(d.selection(), Some((0, d.source.len())));
9502        d.toggle_list(true);
9503        assert_eq!(d.source, "abc\n\ndef\n");
9504    }
9505
9506    #[test]
9507    fn toggle_blockquote_nests_a_partly_covered_quote() {
9508        // twig's rule: covering only some of a container's blocks nests, because
9509        // taking the quote off would drag its uncovered siblings out with it.
9510        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
9511        d.caret = 2; // in the first quoted paragraph only
9512        d.toggle_blockquote();
9513        assert_eq!(d.source, "> > a\n>\n> b\n");
9514    }
9515
9516    #[test]
9517    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
9518        // A blank line used to be no block for twig to wrap —
9519        // `toggle_block_container` answered `NotFound` — so Quote and the list
9520        // buttons did nothing on the very line the H1 button works on, and leaf
9521        // lent twig a scratch paragraph to wrap and took it back out again.
9522        // twig 3.2.0 opens an empty container there itself, so what is left here
9523        // is where the caret lands: inside the marker that was just written.
9524        let mut d = doc_with("quote_blank", "\nabc\n");
9525        d.caret = 0;
9526        d.toggle_blockquote();
9527        assert_eq!(d.source, "> \nabc\n");
9528        assert_eq!(
9529            d.caret, 2,
9530            "the caret belongs inside the quote it just opened"
9531        );
9532        assert!(d.status.is_none(), "{:?}", d.status);
9533        assert!(d.dirty);
9534
9535        // And the paragraph below is still its own block: an empty container one
9536        // soft break from `abc` would take that paragraph into the quote with it.
9537        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
9538        d.caret = 0;
9539        d.toggle_blockquote();
9540        d.build_visual(80);
9541        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
9542
9543        // The same from the other side: a blank line directly under a paragraph
9544        // earns the blank line an empty block needs, rather than being read as a
9545        // soft break inside that paragraph.
9546        let mut d = doc_with("list_blank_below", "abc\n");
9547        d.caret = 4;
9548        d.toggle_list(false);
9549        assert_eq!(d.source, "abc\n\n- ");
9550        assert_eq!(d.caret, 7);
9551    }
9552
9553    #[test]
9554    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
9555        // The gesture the rendering fix is for. `newline` inside a quote already
9556        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
9557        // spelling — but the two marker lines it adds belonged to no node until
9558        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
9559        // just made drew as plain prose under the quote.
9560        let mut d = wysiwyg_doc("quote_enter", "> a\n");
9561        d.caret = 3; // past `a`, at the end of the quoted line
9562        d.newline();
9563        assert_eq!(d.source, "> a\n>\n> \n");
9564        d.build_visual(80);
9565        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
9566        // And the caret is on the new line, not stranded on the old one.
9567        assert_eq!(d.caret, 8);
9568    }
9569
9570    #[test]
9571    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
9572        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
9573        // now it is twig's single edit. Either way one ⌘z has to put the blank
9574        // line back rather than undoing into a half-built document.
9575        for open in [
9576            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
9577            &|d: &mut Doc| d.toggle_list(false),
9578            &|d: &mut Doc| d.toggle_list(true),
9579        ] {
9580            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
9581            d.caret = 3;
9582            open(&mut d);
9583            assert_ne!(d.source, "a\n\n\n\nb\n");
9584            d.undo();
9585            assert_eq!(d.source, "a\n\n\n\nb\n");
9586        }
9587    }
9588
9589    #[test]
9590    fn a_container_toggle_is_one_undo_step() {
9591        let mut d = doc_with("quote_undo", "hello\n");
9592        d.caret = 3;
9593        d.insert("X"); // a typing run the structural edit must not fold into
9594        d.toggle_blockquote();
9595        assert_eq!(d.source, "> helXlo\n");
9596        d.undo();
9597        assert_eq!(d.source, "helXlo\n");
9598    }
9599
9600    // ── links ────────────────────────────────────────────────────────────────
9601
9602    #[test]
9603    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
9604        let mut d = doc_with("link_sel", "word here\n");
9605        d.anchor = Some(0);
9606        d.caret = 4;
9607        d.insert_link("http://x.dev");
9608        assert_eq!(d.source, "[word](http://x.dev) here\n");
9609        // The text, not the destination — so a second press re-points the link
9610        // the first one made rather than nesting one inside it.
9611        assert_eq!(d.selected_text(), Some("word"));
9612        d.insert_link("http://y.dev");
9613        assert_eq!(d.source, "[word](http://y.dev) here\n");
9614        assert_eq!(d.selected_text(), Some("word"));
9615    }
9616
9617    #[test]
9618    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
9619        let mut d = doc_with("img_caret", "before after\n");
9620        d.caret = 7; // between "before " and "after"
9621        d.insert_image("cat.png", "a cat");
9622        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
9623        // The caret sits just past the inserted image, nothing selected.
9624        assert_eq!(d.selection(), None);
9625        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
9626    }
9627
9628    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
9629    /// destination at the first space, so the `format!` this used to be wrote
9630    /// something that was not an image at all — and the reader saw the markup as
9631    /// text. twig owns the spelling now, and moves it into the angle form.
9632    #[test]
9633    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
9634        let mut d = doc_with("img_space", "x\n");
9635        d.caret = 0;
9636        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
9637        assert_eq!(
9638            d.source,
9639            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
9640        );
9641        // And it reads back as an image pointing at the unescaped path — the angle
9642        // brackets are spelling, not part of the destination.
9643        d.caret = 2;
9644        assert_eq!(
9645            d.image_destination_at_caret(),
9646            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
9647        );
9648    }
9649
9650    /// A `)` in a caption or a filename must not close the image early.
9651    #[test]
9652    fn insert_image_escapes_a_paren_in_either_half() {
9653        let mut d = doc_with("img_paren", "x\n");
9654        d.caret = 0;
9655        d.insert_image("a)b.png", "");
9656        assert_eq!(d.source, "![](a\\)b.png)x\n");
9657        d.caret = 2;
9658        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
9659    }
9660
9661    #[test]
9662    fn insert_image_uses_the_selection_as_alt_text() {
9663        let mut d = doc_with("img_sel", "caption here\n");
9664        d.anchor = Some(0);
9665        d.caret = 7; // "caption"
9666        d.insert_image("p.png", "ignored fallback");
9667        assert_eq!(d.source, "![caption](p.png) here\n");
9668    }
9669
9670    #[test]
9671    fn insert_image_with_no_alt_leaves_empty_brackets() {
9672        let mut d = doc_with("img_noalt", "\n");
9673        d.caret = 0;
9674        d.insert_image("logo.svg", "");
9675        assert_eq!(d.source, "![](logo.svg)\n");
9676    }
9677
9678    #[test]
9679    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
9680        // The round trip is the point: it's no use writing markup the reader
9681        // can't pick up again. This is the pair that only holds from twig 2.5.1
9682        // on — before it, the one-line form went in fine and came back as a
9683        // paragraph of raw tags, publishing no media at all.
9684        let mut d = doc_with("vid_rt", "\n");
9685        d.caret = 0;
9686        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
9687        assert_eq!(
9688            d.source,
9689            "<video src=\"clip.mp4\" controls>a clip</video>\n"
9690        );
9691
9692        d.build_visual(80);
9693        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
9694        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
9695        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
9696        assert_eq!(d.vmap.media[0].alt, "a clip");
9697    }
9698
9699    #[test]
9700    fn insert_media_spells_audio_with_its_own_tag() {
9701        let mut d = doc_with("aud_rt", "\n");
9702        d.caret = 0;
9703        d.insert_media(MediaKind::Audio, "take.mp3", "");
9704        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
9705        d.build_visual(80);
9706        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
9707    }
9708
9709    #[test]
9710    fn insert_media_uses_the_selection_as_fallback_text() {
9711        // The same courtesy `insert_image` does with alt: select a caption,
9712        // insert, and the caption labels the thing rather than being replaced.
9713        let mut d = doc_with("vid_sel", "the talk here\n");
9714        d.anchor = Some(0);
9715        d.caret = 8; // "the talk"
9716        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
9717        assert_eq!(
9718            d.source,
9719            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
9720        );
9721    }
9722
9723    #[test]
9724    fn insert_media_with_an_image_kind_is_just_insert_image() {
9725        let mut d = doc_with("img_via_media", "\n");
9726        d.caret = 0;
9727        d.insert_media(MediaKind::Image, "logo.svg", "x");
9728        assert_eq!(d.source, "![x](logo.svg)\n");
9729    }
9730
9731    // ── thematic breaks ─────────────────────────────────────────────────────
9732
9733    /// The node the source parses as at `caret` — what confirms an inserted
9734    /// `---` actually reads back as a rule, not stray text or a setext heading.
9735    ///
9736    /// The *narrowest* node covering the offset. Every ancestor covers it too,
9737    /// and since twig 2.8 that includes the `doc` root, which now carries a real
9738    /// span (it reported none before, so taking the first match used to land on
9739    /// the block by luck and now always answers `"doc"`).
9740    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
9741        d.nodes()
9742            .into_iter()
9743            .filter(|n| n.span.start <= caret && caret < n.span.end)
9744            .min_by_key(|n| n.span.end - n.span.start)
9745            .map(|n| n.kind)
9746    }
9747
9748    #[test]
9749    fn a_task_box_toggles_at_the_caret_and_reads_back() {
9750        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
9751        d.caret = 8; // inside "todo"
9752        assert_eq!(d.task_checked_at_caret(), Some(false));
9753        d.toggle_task_checked();
9754        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
9755        assert_eq!(d.task_checked_at_caret(), Some(true));
9756        d.toggle_task_checked();
9757        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
9758    }
9759
9760    #[test]
9761    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
9762        // The whole reason `toggle_task_at` exists apart from the caret form:
9763        // ticking a box elsewhere must not move the cursor out of what's being
9764        // typed.
9765        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
9766        d.caret = 8; // inside "first"
9767        let second = d.source.find("second").unwrap();
9768        d.toggle_task_at(second);
9769        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
9770        assert_eq!(d.caret, 8, "the caret stayed in the first item");
9771    }
9772
9773    #[test]
9774    fn a_plain_item_gains_and_loses_a_box() {
9775        let mut d = doc_with("task_mint", "- plain\n");
9776        d.caret = 4;
9777        assert_eq!(d.task_checked_at_caret(), None);
9778        d.toggle_task_item();
9779        assert_eq!(d.source, "- [ ] plain\n");
9780        assert_eq!(
9781            d.task_checked_at_caret(),
9782            Some(false),
9783            "a new box arrives unticked"
9784        );
9785        d.toggle_task_item();
9786        assert_eq!(d.source, "- plain\n");
9787    }
9788
9789    #[test]
9790    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
9791        // `set checked` must not silently convert a bullet into a task — that is
9792        // `toggle_task_item`'s job, and twig refuses it here.
9793        let mut d = doc_with("task_none", "- plain\n");
9794        d.caret = 4;
9795        d.toggle_task_checked();
9796        assert_eq!(d.source, "- plain\n", "nothing written");
9797        assert!(
9798            d.status.is_some(),
9799            "the refusal should reach the status line"
9800        );
9801    }
9802
9803    #[test]
9804    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
9805        let mut d = doc_with("task_quote", "> - [ ] nested\n");
9806        d.caret = d.source.find("nested").unwrap();
9807        assert_eq!(d.task_checked_at_caret(), Some(false));
9808        d.toggle_task_checked();
9809        assert_eq!(d.source, "> - [x] nested\n");
9810    }
9811
9812    #[test]
9813    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
9814        // A rule is a block, so twig's `insert_thematic_break` alone lands it
9815        // after the whole paragraph. `split_block` parts the paragraph first and
9816        // the rule is aimed at the *first* half, which is what a rule button is
9817        // understood to do — and what leaf spelled by hand until twig grew both
9818        // halves of the gesture.
9819        let mut d = doc_with("hr_mid", "before after\n");
9820        d.caret = 7; // between "before " and "after"
9821        d.insert_thematic_break();
9822        assert_eq!(d.source, "before \n\n---\n\nafter\n");
9823        assert_eq!(d.selection(), None);
9824        assert_eq!(
9825            kind_at(&mut d, "before \n\n".len()),
9826            Some(Kind::ThematicBreak)
9827        );
9828    }
9829
9830    #[test]
9831    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
9832        // At the end there is nothing to part, and a split there writes the
9833        // separator anyway — a blank line and the empty slot the next paragraph
9834        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
9835        // two blank lines nothing fills. Now the rule lands after the paragraph,
9836        // where the split-and-aim was sending it regardless. Both formats, and
9837        // both shapes of a last line — terminated, and still being typed —
9838        // because the two reach the split through different doors: Markdown's
9839        // paragraph span stops before its newline, so `para\n` at 4 never split
9840        // there, but `para` at 4 did.
9841        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9842            for src in ["para\n", "para"] {
9843                let mut d = Doc::from_source(src.into(), fmt).unwrap();
9844                d.caret = 4;
9845                d.insert_thematic_break();
9846                assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
9847                assert_eq!(d.caret, d.source.len());
9848            }
9849            // Mid-document the slot sat between the rule and the next block.
9850            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
9851            d.caret = 4;
9852            d.insert_thematic_break();
9853            assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
9854            // Trailing whitespace is nothing to part either.
9855            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
9856            d.caret = 4;
9857            d.insert_thematic_break();
9858            assert_eq!(d.source, format!("para  \n\n{rule}\n"), "{fmt:?}");
9859        }
9860    }
9861
9862    #[test]
9863    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
9864        // The split at the start parts nothing, but it is kept on purpose:
9865        // `|para` becomes `\npara` with the caret on a blank line, and twig
9866        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
9867        // way "before the paragraph" is reachable through a gesture that only
9868        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
9869        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
9870            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
9871            d.caret = 0;
9872            d.insert_thematic_break();
9873            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
9874            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
9875            d.caret = 6;
9876            d.insert_thematic_break();
9877            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
9878        }
9879    }
9880
9881    #[test]
9882    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
9883        // The gap between two blocks is where a click lands the caret; the
9884        // rule goes on the blank, one blank each side.
9885        let mut d = doc_with("hr_gap", "a\n\nb\n");
9886        d.caret = 2;
9887        d.insert_thematic_break();
9888        assert_eq!(d.source, "a\n\n---\n\nb\n");
9889    }
9890
9891    #[test]
9892    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
9893        // The same door as the rule's, through the placement they share.
9894        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9895        d.caret = 4;
9896        d.insert_table(1, 1);
9897        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
9898        let mut d = doc_with("table_end_typed", "para");
9899        d.caret = 4;
9900        d.insert_table(1, 1);
9901        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
9902        assert!(d.caret_in_table());
9903    }
9904
9905    #[test]
9906    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
9907        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
9908        // and leaf wrote the first into both until twig started spelling it.
9909        let mut md = doc_with("hr_md", "para\n");
9910        md.caret = 2;
9911        md.insert_thematic_break();
9912        assert_eq!(md.source, "pa\n\n---\n\nra\n");
9913
9914        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9915        dj.caret = 2;
9916        dj.insert_thematic_break();
9917        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
9918    }
9919
9920    #[test]
9921    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
9922        // The table goes *at* the caret the way the rule does: the paragraph is
9923        // parted first, and twig writes the grid after its first half. The
9924        // caret then sits in the first header cell — selected, as Tab would
9925        // leave it — so the next keystroke is the heading.
9926        let mut d = doc_with("table_mid", "before after\n");
9927        d.caret = 7;
9928        d.insert_table(2, 3);
9929        assert_eq!(
9930            d.source,
9931            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
9932        );
9933        assert!(d.caret_in_table());
9934        let first_bar = d.source.find('|').unwrap();
9935        assert!(
9936            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
9937            "caret {} is not in the header row",
9938            d.caret
9939        );
9940        d.insert("Name");
9941        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
9942        // And the grid the table was written into is one the table keys walk
9943        // (over the map a frontend rebuilds after every edit).
9944        d.build_visual(80);
9945        assert!(d.cell_tab(true));
9946        d.insert("Qty");
9947        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
9948    }
9949
9950    #[test]
9951    fn insert_table_spells_the_grid_the_format_s_own_way() {
9952        // Djot's delimiter row is unpadded, and leaf never has to know that.
9953        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
9954        dj.caret = 2;
9955        dj.insert_table(1, 2);
9956        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
9957        assert!(dj.caret_in_table());
9958    }
9959
9960    #[test]
9961    fn insert_table_refuses_where_the_format_spells_no_table() {
9962        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
9963        d.caret = 4;
9964        d.insert_table(1, 1);
9965        assert_eq!(d.source, "<p>ab</p>\n");
9966        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
9967        assert!(!d.capabilities().table);
9968    }
9969
9970    #[test]
9971    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
9972        let mut d = doc_with("table_zero", "para\n");
9973        d.caret = 2;
9974        d.insert_table(0, 2);
9975        assert_eq!(d.source, "para\n");
9976        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
9977    }
9978
9979    #[test]
9980    fn clicking_below_a_final_thematic_break_can_type_after_it() {
9981        let mut d = wysiwyg_doc("hr_final_click", "---\n");
9982        d.build_visual(80);
9983        d.click(d.vmap.num_rows() + 2, 0, false);
9984        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
9985        d.insert("after");
9986        assert_eq!(d.source, "---\nafter");
9987    }
9988
9989    #[test]
9990    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
9991        // The same bytes are two documents. In Markdown `  - b` is a nested item
9992        // and the next one belongs beside it, at its indent. In Djot a list
9993        // marker can't interrupt a paragraph, so those bytes are literal text in
9994        // item `a` and there is only one item — writing `  - ` under it would add
9995        // no item at all, just more text, and the new sibling has to go to
9996        // column zero. Both spellings come out of the *enclosing item's* line.
9997        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
9998        md.caret = "- a\n  - b".len();
9999        md.newline();
10000        assert_eq!(md.source, "- a\n  - b\n  - \n");
10001        assert_eq!(list_items(&mut md), 3);
10002
10003        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
10004        dj.view = View::Wysiwyg;
10005        dj.build_visual(80);
10006        dj.caret = "- a\n  - b".len();
10007        dj.newline();
10008        assert_eq!(dj.source, "- a\n  - b\n- \n");
10009        assert_eq!(list_items(&mut dj), 2);
10010
10011        // Where Djot's nesting is real — opened by a blank line — the indent is
10012        // reproduced there too, and the two formats agree again.
10013        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
10014        dj.view = View::Wysiwyg;
10015        dj.build_visual(80);
10016        dj.caret = "- a\n\n  - b".len();
10017        dj.newline();
10018        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
10019        assert_eq!(list_items(&mut dj), 3);
10020    }
10021
10022    #[test]
10023    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
10024        // Tab replaces the line's whole prefix with the one twig spells, so the
10025        // quote markers, the parent's indent and an ordered marker's extra
10026        // column are all its answer rather than leaf's arithmetic.
10027        for (name, body, caret, want) in [
10028            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
10029            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
10030            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
10031            // A checkbox is markup the item's own text wraps past, but a nested
10032            // list may only open at the *list* marker's column — four in from
10033            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
10034            // parses as one item, not two.
10035            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
10036            (
10037                "quoted task",
10038                "> - [ ] a\n> - [ ] b\n",
10039                18,
10040                "> - [ ] a\n>   - [ ] b\n",
10041            ),
10042        ] {
10043            let mut doc = wysiwyg_doc(name, body);
10044            doc.caret = caret;
10045            doc.indent();
10046            assert_eq!(doc.source, want, "{name}");
10047            // The nesting is real, not just indented text.
10048            assert_eq!(list_items(&mut doc), 2, "{name}");
10049        }
10050    }
10051
10052    #[test]
10053    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
10054        // The same bytes, the two formats disagreeing, and a gesture that used
10055        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
10056        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
10057        // so those bytes are literal text inside item `a` — there is nothing to
10058        // outdent, and treating them as a marker turned one item into two, a
10059        // structural edit from a keystroke that should delete one character.
10060        //
10061        // twig's `line_prefix` is what tells them apart: it reports the marker
10062        // on the Markdown line and nothing on the Djot one, which is a
10063        // continuation. No byte scan can reach that answer.
10064        let src = "- a\n  - b\n";
10065        let at = "- a\n  - ".len();
10066
10067        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
10068        md.view = View::Wysiwyg;
10069        md.build_visual(80);
10070        md.caret = at;
10071        md.backspace();
10072        assert_eq!(md.source, "- a\n- b\n");
10073        assert_eq!(list_items(&mut md), 2);
10074
10075        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
10076        dj.view = View::Wysiwyg;
10077        dj.build_visual(80);
10078        dj.caret = at;
10079        dj.backspace();
10080        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
10081        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
10082    }
10083
10084    #[test]
10085    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
10086        // Leaf used to spell the next item from the marker bytes it scanned, and
10087        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
10088        // and dropped out of the checklist. twig reproduces the whole
10089        // continuation, and a fresh item is always unticked however the one above
10090        // it stands.
10091        for (name, body, want) in [
10092            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
10093            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
10094        ] {
10095            let mut doc = wysiwyg_doc(name, body);
10096            doc.caret = body.trim_end_matches('\n').len();
10097            doc.newline();
10098            assert_eq!(doc.source, want, "{name}");
10099            // Both items are checklist items — the new one is a box, not the
10100            // plain bullet the old marker scan left behind — and it is unticked
10101            // whichever way the one above it faces.
10102            let boxes: Vec<Option<bool>> = doc
10103                .nodes()
10104                .iter()
10105                .filter(|n| n.kind == Kind::TaskListItem)
10106                .map(|n| n.checked)
10107                .collect();
10108            assert_eq!(boxes.len(), 2, "{name}");
10109            assert_eq!(boxes[1], Some(false), "{name}");
10110        }
10111    }
10112
10113    #[test]
10114    fn a_split_takes_the_space_the_caret_was_in_front_of() {
10115        // Splicing a break at the caret strands the space the words were parted
10116        // at on the head of the second block, where it reads as an indent nobody
10117        // typed. twig's split consumes it.
10118        for (name, body, caret, want) in [
10119            ("para", "one two\n", 3, "one\n\ntwo\n"),
10120            ("item", "- one two\n", 5, "- one\n- two\n"),
10121            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
10122            // A heading takes leaf's own path, which has to match.
10123            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
10124        ] {
10125            let mut doc = wysiwyg_doc(name, body);
10126            doc.caret = caret;
10127            doc.newline();
10128            assert_eq!(doc.source, want, "{name}");
10129        }
10130    }
10131
10132    #[test]
10133    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
10134        // The one place leaf keeps its own break: `split_block` repeats the `#`,
10135        // and Enter after a title is how the body under it is asked for.
10136        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
10137        doc.caret = "# Title".len();
10138        doc.newline();
10139        doc.insert("body");
10140        assert_eq!(doc.source, "# Title\n\nbody\n");
10141        assert_eq!(
10142            doc.nodes()
10143                .iter()
10144                .filter(|n| n.kind == Kind::Heading)
10145                .count(),
10146            1
10147        );
10148    }
10149
10150    #[test]
10151    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
10152        // A quoted item's marker doesn't open its line, so a scan that starts at
10153        // column zero finds a `>` where it wanted a bullet, calls the line "not a
10154        // list" and hands Enter to the plain-quote branch — which writes `> ` and
10155        // drops the list. The next item has to carry the whole prefix.
10156        for (name, body, want) in [
10157            ("flat", "> - a\n", "> - a\n> - \n"),
10158            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
10159            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
10160            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
10161            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
10162        ] {
10163            let mut doc = wysiwyg_doc(name, body);
10164            doc.caret = body.trim_end_matches('\n').len();
10165            doc.newline();
10166            assert_eq!(doc.source, want, "{name}");
10167            // The marker isn't just spelled right, it parses as an item.
10168            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
10169        }
10170    }
10171
10172    #[test]
10173    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
10174        // Double-Enter exits the list. Unquoted that means a blank line, but a
10175        // *bare* blank line would end the quote too and drop the caret out of it,
10176        // so the separator keeps its `>` and the caret's line keeps its `> `.
10177        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
10178        doc.caret = "> - a\n> - ".len();
10179        doc.newline();
10180        assert_eq!(doc.source, "> - a\n>\n> \n");
10181        assert_eq!(list_items(&mut doc), 1);
10182        // What "still in the quote" means for the next keystroke: the caret sits
10183        // behind the prefix, and what's typed there lands inside the quote as a
10184        // paragraph of its own — not as more of item `a`.
10185        doc.insert("x");
10186        assert_eq!(doc.source, "> - a\n>\n> x\n");
10187        assert!(
10188            doc.editor
10189                .ancestors_at(doc.caret - 1)
10190                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
10191        );
10192    }
10193
10194    #[test]
10195    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
10196        // The marker is hidden block markup, so Backspace over it is structural —
10197        // but only the marker is the list's. Splicing from the line start would
10198        // take the `>` with it and silently unquote the line.
10199        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
10200        doc.caret = "> - ".len();
10201        doc.backspace();
10202        assert_eq!(doc.source, "> a\n");
10203        assert_eq!(list_items(&mut doc), 0);
10204
10205        // A nested one outdents instead, moving the bullet within the quote
10206        // rather than moving the quote.
10207        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
10208        doc.caret = "> - a\n>   - ".len();
10209        doc.backspace();
10210        assert_eq!(doc.source, "> - a\n> - b\n");
10211        assert_eq!(list_items(&mut doc), 2);
10212    }
10213
10214    #[test]
10215    fn only_a_bare_paragraph_is_parted_around_the_caret() {
10216        // The split is deliberately narrow. Parting a fenced block would leave
10217        // two fences with a rule between them, and parting a list item would
10218        // mint an item nobody asked for on the way to a rule that lands after
10219        // the list either way — so both keep the whole block intact and take the
10220        // rule after it. A caret in a quote is likewise left alone.
10221        for (name, body, caret, want) in [
10222            (
10223                "code",
10224                "```\nfn x() {}\n```\n",
10225                8,
10226                "```\nfn x() {}\n```\n\n---\n",
10227            ),
10228            ("list", "- one two\n", 6, "- one two\n\n---\n"),
10229            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
10230        ] {
10231            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
10232            d.caret = caret;
10233            d.insert_thematic_break();
10234            assert_eq!(d.source, want, "{name}: the block should stay whole");
10235        }
10236    }
10237
10238    #[test]
10239    fn insert_thematic_break_replaces_the_selection() {
10240        // Now that the rule lands *at* the caret again, replacing the selection
10241        // is coherent once more: the text goes, and the rule takes its place.
10242        // The space the deletion left leading the second half is consumed by the
10243        // split rather than opening the new paragraph with it.
10244        let mut d = doc_with("hr_sel", "one two three\n");
10245        d.anchor = Some(4);
10246        d.caret = 7; // "two"
10247        d.insert_thematic_break();
10248        assert_eq!(d.source, "one \n\n---\n\nthree\n");
10249        assert_eq!(d.selection(), None);
10250    }
10251
10252    #[test]
10253    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
10254        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
10255        // because writing `---` into one is code, not a rule — twig now walks out
10256        // to the block that owns the caret's line, so there is nothing to refuse.
10257        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
10258        code.caret = 5; // inside the fenced code
10259        code.insert_thematic_break();
10260        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
10261        assert_eq!(code.status, None, "no refusal to report any more");
10262
10263        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
10264        table.caret = 3; // in the header row
10265        table.insert_thematic_break();
10266        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
10267    }
10268
10269    #[test]
10270    fn insert_thematic_break_in_a_list_item_ends_the_list() {
10271        // The un-indented rule cannot continue the list, so it closes the list
10272        // and lands at the top level rather than nested inside it.
10273        let mut d = doc_with("hr_list", "- one\n- two\n");
10274        d.caret = "- one\n- tw".len(); // mid "two"
10275        d.insert_thematic_break();
10276        d.build_visual(80);
10277        let rule_at = d.source.find("---").unwrap();
10278        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10279        assert!(
10280            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
10281                && n.span.start <= rule_at
10282                && rule_at < n.span.end),
10283            "the rule must not be nested inside the list"
10284        );
10285    }
10286
10287    #[test]
10288    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
10289        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
10290        // which is the document the gesture was actually asked for.
10291        let mut d = doc_with("hr_quote", "> hello\n");
10292        d.caret = 4; // inside the quoted text
10293        d.insert_thematic_break();
10294        assert_eq!(d.source, "> hello\n>\n> ---\n");
10295        d.build_visual(80);
10296        let rule_at = d.source.find("---").unwrap();
10297        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
10298        assert!(
10299            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
10300                && n.span.start <= rule_at
10301                && rule_at < n.span.end),
10302            "the rule belongs to the quote it was asked for"
10303        );
10304    }
10305
10306    // ── typing against a block picture ────────────────────────────────────────
10307
10308    /// A rendered-view document with the caret parked on one of the picture's two
10309    /// stops, and the map already built — the state a frontend is in between
10310    /// drawing a frame and the next keystroke.
10311    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
10312        let mut d = doc_in(View::Wysiwyg, name, src);
10313        d.build_visual_unwrapped();
10314        let start = src.find("![").unwrap();
10315        d.caret = match side {
10316            MediaStop::Before => start,
10317            MediaStop::After => start + "![](p.png)".len(),
10318        };
10319        d
10320    }
10321
10322    /// The block media the map publishes, after rebuilding it — "is this still a
10323    /// picture, or has it become a line of text with an image in it?"
10324    fn media_count(d: &mut Doc) -> usize {
10325        d.build_visual_unwrapped();
10326        d.vmap.media.len()
10327    }
10328
10329    #[test]
10330    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
10331        // The accident this prevents: tap the blank page under a photo (which
10332        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
10333        // paragraph with an *inline* image — the photo stops being drawn.
10334        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
10335        d.insert("xy");
10336        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
10337        assert_eq!(media_count(&mut d), 1, "still a picture");
10338    }
10339
10340    #[test]
10341    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
10342        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
10343        d.insert("xy");
10344        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
10345        assert_eq!(media_count(&mut d), 1);
10346    }
10347
10348    #[test]
10349    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
10350        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
10351        d.insert("x");
10352        assert_eq!(d.source, "x\n\n![](p.png)\n");
10353        assert_eq!(media_count(&mut d), 1);
10354    }
10355
10356    #[test]
10357    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
10358        // The opened paragraph is part of the keystroke, not an edit the writer
10359        // made — so it undoes with the character, not a step later.
10360        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
10361        d.insert("x");
10362        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
10363        d.undo();
10364        assert_eq!(d.source, "hi\n\n![](p.png)\n");
10365    }
10366
10367    #[test]
10368    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
10369        // ⌘V dissolves the picture exactly as a keystroke does.
10370        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
10371        d.paste("pasted");
10372        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
10373        assert_eq!(media_count(&mut d), 1);
10374    }
10375
10376    #[test]
10377    fn typing_beside_an_inline_image_is_ordinary_editing() {
10378        // An inline image has no placeholder row and no stops of its own. Opening
10379        // a paragraph mid-sentence would be the bug, not the fix.
10380        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
10381        d.build_visual_unwrapped();
10382        d.caret = "see ![](p.png)".len();
10383        d.insert("!");
10384        assert_eq!(d.source, "see ![](p.png)! here\n");
10385    }
10386
10387    #[test]
10388    fn source_view_types_raw_markup_against_an_image_untouched() {
10389        // Source view is for writing the markup itself; a break inserted behind
10390        // the writer's back there would be the editor arguing with them.
10391        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
10392        d.caret = "![](p.png)".len();
10393        d.insert("x");
10394        assert_eq!(d.source, "![](p.png)x\n");
10395    }
10396
10397    #[test]
10398    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
10399        // A selection is replaced, not joined into, so there is nothing to
10400        // protect: the range takes the picture with it.
10401        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
10402        d.anchor = Some(d.caret);
10403        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
10404        d.insert("x");
10405        assert_eq!(d.source, "hi\n\nx\n");
10406    }
10407
10408    #[test]
10409    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
10410        // What this actually cost: a real vault's photo, to one stray Backspace.
10411        // The caret past `![](p.png)` was deleting the closing paren — invisible
10412        // in the rendered view — and the photo became the text `![](p.png`.
10413        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
10414        d.backspace();
10415        assert_eq!(d.source, "hi\n");
10416        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
10417        d.undo();
10418        assert_eq!(
10419            d.source, "hi\n\n![](p.png)\n",
10420            "and comes back in one piece"
10421        );
10422    }
10423
10424    #[test]
10425    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
10426        // Deleting the break here would join the picture to the paragraph above,
10427        // where it is an *inline* image and stops being drawn. Step over the
10428        // boundary; the next press deletes in the paragraph the caret reached.
10429        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
10430        d.backspace();
10431        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
10432        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
10433        d.backspace();
10434        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
10435        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
10436    }
10437
10438    #[test]
10439    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
10440        // The mirror. A byte-step here eats the `!` and leaves a link.
10441        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
10442        d.delete_forward();
10443        assert_eq!(d.source, "hi\n\nbye\n");
10444        assert_eq!(media_count(&mut d), 0);
10445    }
10446
10447    #[test]
10448    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
10449        let mut d = doc_at_picture(
10450            "pic_del_after",
10451            "hi\n\n![](p.png)\n\nbye\n",
10452            MediaStop::After,
10453        );
10454        d.delete_forward();
10455        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
10456        assert_eq!(
10457            d.caret,
10458            d.source.find("bye").unwrap(),
10459            "the caret stepped down to `bye`"
10460        );
10461    }
10462
10463    #[test]
10464    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
10465        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
10466        d.backspace();
10467        assert_eq!(d.source, "\n");
10468        assert_eq!(media_count(&mut d), 0);
10469    }
10470
10471    #[test]
10472    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
10473        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
10474        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
10475        d.delete_word_back();
10476        assert_eq!(d.source, "hi there\n");
10477
10478        // And in front of one it runs *through* the paragraph break into the
10479        // prose above, which merges the picture inline — so it steps out first,
10480        // and the second press deletes the word it was aimed at.
10481        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
10482        d.delete_word_back();
10483        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
10484        d.delete_word_back();
10485        assert_eq!(
10486            d.source, "hi \n\n![](p.png)\n",
10487            "the word above went, the picture stayed"
10488        );
10489        assert_eq!(media_count(&mut d), 1);
10490    }
10491
10492    #[test]
10493    fn source_view_deletes_raw_markup_against_an_image_untouched() {
10494        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
10495        d.caret = "![](p.png)".len();
10496        d.backspace();
10497        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
10498    }
10499
10500    #[test]
10501    fn image_destination_at_caret_reads_the_image_under_the_caret() {
10502        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
10503        d.caret = 3; // inside the image markup
10504        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
10505        // Past the image, the caret is in no image.
10506        d.caret = "![a cat](cat.png)".len();
10507        assert_eq!(d.image_destination_at_caret(), None);
10508    }
10509
10510    #[test]
10511    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
10512        // The image is one placeholder row by default, and `set_media_rows` grows
10513        // it to the height the frontend measured: the label row plus blank
10514        // `decoration` fillers that hold the vertical space a raster is drawn into.
10515        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
10516        assert_eq!(d.vmap.media.len(), 1);
10517        let img_row = d.vmap.media[0].rows_span.start;
10518        assert_eq!(
10519            d.vmap.media[0].rows_span,
10520            img_row..img_row + 1,
10521            "default is one row"
10522        );
10523
10524        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
10525        d.build_visual(80);
10526        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
10527        let span = d.vmap.media[0].rows_span.clone();
10528        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
10529        // The label row carries the mark and its glyphs; the three below are blank
10530        // decoration — drawn, but no caret and no text.
10531        assert!(
10532            d.vmap.rows[span.start].media.is_some(),
10533            "mark rides the first row"
10534        );
10535        for r in (span.start + 1)..span.end {
10536            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
10537            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
10538            assert!(
10539                d.vmap.rows[r].media.is_none(),
10540                "only the first row is marked"
10541            );
10542        }
10543    }
10544
10545    #[test]
10546    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
10547        // The extra rows are pure spacers: the caret's only homes stay the stop in
10548        // front of the image and the one just past it, so walking the document top
10549        // to bottom visits the same offsets whether the image is 1 row or 5.
10550        let body = "ab\n\n![x](p.png)\n\ncd\n";
10551        let stops_at = |rows: usize| -> Vec<usize> {
10552            let mut d = wysiwyg_doc("img_stops", body);
10553            if rows > 1 {
10554                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
10555                d.build_visual(80);
10556            }
10557            d.caret = 0;
10558            let mut seen = vec![d.caret];
10559            loop {
10560                d.move_right(false);
10561                if *seen.last().unwrap() == d.caret {
10562                    break;
10563                }
10564                seen.push(d.caret);
10565            }
10566            seen
10567        };
10568        assert_eq!(
10569            stops_at(1),
10570            stops_at(5),
10571            "reserving rows must not add stops"
10572        );
10573    }
10574
10575    #[test]
10576    fn insert_link_repoints_the_link_at_a_bare_caret() {
10577        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
10578        d.caret = 3; // in the link's text, nothing selected
10579        d.insert_link("http://y.dev");
10580        assert_eq!(d.source, "[word](http://y.dev)\n");
10581        assert_eq!(d.selected_text(), Some("word"));
10582    }
10583
10584    #[test]
10585    fn insert_link_on_an_empty_range_autolinks_a_url() {
10586        // A link with no text of its own is an autolink, and twig spells it —
10587        // `<…>` is the canonical form and needs no text typed into it, so the
10588        // caret lands after it rather than selecting a finished link.
10589        let mut d = doc_with("link_empty", "\n");
10590        d.caret = 0;
10591        d.insert_link("http://x.dev");
10592        assert_eq!(d.source, "<http://x.dev>\n");
10593        assert_eq!(d.selection(), None);
10594        assert_eq!(d.caret, 14);
10595    }
10596
10597    #[test]
10598    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
10599        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
10600        // in Markdown, so a destination that can't autolink doubles as the text
10601        // instead — which is then selected, ready to be typed over.
10602        let mut d = doc_with("link_rel", "\n");
10603        d.caret = 0;
10604        d.insert_link("./notes.md");
10605        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
10606        assert_eq!(d.selection(), Some((1, 11)));
10607        d.insert("Notes");
10608        assert_eq!(d.source, "[Notes](./notes.md)\n");
10609    }
10610
10611    #[test]
10612    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
10613        // The autolink's text is its URL, so re-pointing replaces the whole
10614        // node — the caret must not splice a second link inside the first.
10615        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
10616        d.caret = 10;
10617        d.insert_link("https://y.dev");
10618        assert_eq!(d.source, "see <https://y.dev> ok\n");
10619    }
10620
10621    #[test]
10622    fn code_language_reads_and_edits_through_the_fence() {
10623        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
10624        d.caret = 10; // inside the code body
10625        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10626        assert!(d.caret_in_fenced_code());
10627
10628        d.set_code_language("python");
10629        assert!(
10630            d.source.starts_with("```python\n"),
10631            "source: {:?}",
10632            d.source
10633        );
10634        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
10635
10636        // Clearing it leaves a bare fence and no label.
10637        d.set_code_language("");
10638        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
10639        assert_eq!(d.code_language_at_caret(), None);
10640
10641        // A caret outside any code block edits nothing.
10642        let mut p = doc_with("code_lang_none", "just prose\n");
10643        assert!(!p.caret_in_fenced_code());
10644        p.set_code_language("rust");
10645        assert_eq!(p.source, "just prose\n");
10646    }
10647
10648    #[test]
10649    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
10650        // Markdown's info string ends at whitespace, so `two words` would write
10651        // a fence that reads back with a different language than the one asked
10652        // for. twig refuses it; leaf reports that and leaves the source alone.
10653        // The old splice trimmed the ends and wrote whatever was left.
10654        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
10655        d.caret = 10;
10656        d.set_code_language("two words");
10657        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
10658        assert!(d.status.is_some(), "the refusal should be reported");
10659        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
10660    }
10661
10662    #[test]
10663    fn link_destination_at_caret_reads_both_spellings() {
10664        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
10665        d.caret = 5;
10666        assert_eq!(
10667            d.link_destination_at_caret().as_deref(),
10668            Some("https://x.dev")
10669        );
10670        d.caret = 0;
10671        assert_eq!(d.link_destination_at_caret(), None);
10672
10673        // An autolink has no `destination`; its text is the URL.
10674        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
10675        a.caret = 10;
10676        assert_eq!(
10677            a.link_destination_at_caret().as_deref(),
10678            Some("https://x.dev")
10679        );
10680        a.caret = 21;
10681        assert_eq!(a.link_destination_at_caret(), None);
10682    }
10683
10684    #[test]
10685    fn locate_finds_the_block_a_declared_id_names() {
10686        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
10687        // verse. The locator has to land on the *verse*, which is the whole
10688        // reason a link carries one.
10689        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
10690                   {#v2}\nYea, I make a record in the language of my father.\n";
10691        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10692        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
10693        assert_eq!(
10694            d.source[v2.start..v2.end].trim_end(),
10695            "Yea, I make a record in the language of my father."
10696        );
10697        // The attribute line is not part of it: `start` is a place to put a
10698        // caret, and `{#v2}` is markup the caret has no business landing in.
10699        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
10700        assert_eq!(d.locate("v99"), None);
10701    }
10702
10703    #[test]
10704    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
10705        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
10706        // Markdown heading is literal text. So `#the-second-part` can only be
10707        // the heading's own words, which is the rule every Markdown renderer
10708        // already follows and therefore the one a link was authored against.
10709        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
10710        let mut d = doc_with("locate_md", src);
10711        let hit = d.locate("the-second-part").expect("the heading's slug");
10712        assert!(d.source[hit.start..].starts_with("## The Second Part"));
10713        // Bounded by the next heading that isn't under it, so a peek shows the
10714        // section rather than only its title.
10715        assert_eq!(
10716            &d.source[hit.start..hit.end],
10717            "## The Second Part\n\nbody\n\n"
10718        );
10719
10720        // A subsection does not end its parent: `# Title` runs to `## Third`'s
10721        // sibling only because there is no other `#`, so it covers the lot.
10722        let title = d.locate("title").expect("the top heading");
10723        assert_eq!(title.end, d.source.len());
10724    }
10725
10726    #[test]
10727    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
10728        // djot mints `Some-Heading-Here`; a link to it is written
10729        // `#some-heading-here` by nearly everything that writes links. Both
10730        // spellings are one question.
10731        let src = "## Some Heading Here\n\nbody\n";
10732        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10733        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
10734        let slugged = d.locate("some-heading-here").expect("the link's spelling");
10735        assert_eq!(exact, slugged);
10736        // The section, not the heading line — there is more to show than a title.
10737        assert_eq!(&d.source[exact.start..exact.end], src);
10738    }
10739
10740    #[test]
10741    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
10742        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
10743        assert_eq!(d.locate(""), None);
10744        assert_eq!(d.locate("   "), None);
10745        // All punctuation: it names nothing, and must not be read as "match the
10746        // first heading whose slug is also empty".
10747        assert_eq!(d.locate("!!!"), None);
10748    }
10749
10750    #[test]
10751    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
10752        // The document's mistake, and the answer every other anchor
10753        // implementation gives — the alternative is for a link to mean whichever
10754        // of the two a walk happened to reach first.
10755        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
10756        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10757        let hit = d.locate("dup").expect("the first `{#dup}`");
10758        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
10759    }
10760
10761    #[test]
10762    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
10763        // The button's whole job: a reference where the caret was, a definition
10764        // to give it meaning, and the caret waiting in the empty note so the
10765        // next keystroke is the note's first word.
10766        let mut d = doc_with("fn_insert", "A claim and more.\n");
10767        d.caret = 7; // just past "A claim"
10768        d.insert_footnote();
10769        assert!(
10770            d.source.starts_with("A claim[^1] and more."),
10771            "{:?}",
10772            d.source
10773        );
10774        assert!(
10775            d.source.contains("[^1]:"),
10776            "the definition too: {:?}",
10777            d.source
10778        );
10779        assert_eq!(d.status, None);
10780
10781        let reference = d.source.find("[^1]").unwrap();
10782        let note = d
10783            .footnote_at(reference + 2)
10784            .expect("the reference just written");
10785        assert_eq!(note.label, "1");
10786        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
10787        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
10788        // …and typing there is typing into the note, not near it.
10789        d.insert("the note");
10790        assert_eq!(
10791            d.footnote_at(reference + 2).and_then(|f| f.text),
10792            Some("the note".to_string())
10793        );
10794    }
10795
10796    #[test]
10797    fn insert_footnote_numbers_past_the_notes_already_written() {
10798        // A second press must not hand back a label somebody else is using: twig
10799        // reuses a defined label rather than appending a rival definition, so a
10800        // repeat of `1` would quietly point the new reference at the old note.
10801        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
10802        d.caret = 7; // past `[^1]`, before " two."
10803        d.insert_footnote();
10804        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
10805        assert_eq!(d.source.matches("[^2]:").count(), 1);
10806    }
10807
10808    #[test]
10809    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
10810        // `[^2]` with no definition is still a 2 that means something to whoever
10811        // wrote it — stepping over it would mint a note for their reference. A
10812        // word label takes no number, so it blocks none.
10813        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
10814        d.caret = d.source.find(" c").unwrap();
10815        d.insert_footnote();
10816        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
10817        assert!(
10818            d.source.starts_with("a[^2] b[^why][^1] c"),
10819            "{:?}",
10820            d.source
10821        );
10822    }
10823
10824    #[test]
10825    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
10826        // A reference annotates the words before it. Consuming the selection —
10827        // which is what an insert normally does — would delete the very claim
10828        // the author selected in order to footnote.
10829        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
10830        d.anchor = Some(2);
10831        d.caret = 7; // "claim" selected
10832        d.insert_footnote();
10833        assert!(
10834            d.source.starts_with("A claim[^1] and more."),
10835            "{:?}",
10836            d.source
10837        );
10838    }
10839
10840    #[test]
10841    fn a_note_just_written_still_knows_where_its_reference_is() {
10842        // The authoring loop in one test: press the button, type the note, ask to
10843        // go back. The caret ends at the note's last byte — which is the *end* of
10844        // the definition's span, the one offset the query used to exclude — so
10845        // this is where the round trip either works or doesn't.
10846        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
10847        d.caret = 7;
10848        d.insert_footnote();
10849        d.insert("the note");
10850        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
10851        let back = d
10852            .footnote_definition_at_caret()
10853            .expect("still in the note we just typed");
10854        assert_eq!(back.label, "1");
10855        // …and following it lands on the reference's label, where a reader's
10856        // return leg lands.
10857        assert_eq!(back.offset, Some(9));
10858        assert_eq!(&d.source[9..10], "1");
10859    }
10860
10861    #[test]
10862    fn insert_footnote_takes_one_undo_for_both_halves() {
10863        // twig writes the pair as a single edit; the point of that is here.
10864        let before = "A claim and more.\n";
10865        let mut d = doc_with("fn_insert_undo", before);
10866        d.caret = 7;
10867        d.insert_footnote();
10868        assert_ne!(d.source, before);
10869        d.undo();
10870        assert_eq!(d.source, before, "one undo takes back both halves");
10871    }
10872
10873    #[test]
10874    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
10875        // HTML is authorable — it spells the inline marks — and has no footnote.
10876        // The refusal says so rather than writing brackets that would render as
10877        // brackets.
10878        let src = "<p>A claim.</p>\n";
10879        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
10880        assert!(!Capabilities::of(Format::Html).footnote);
10881        d.caret = 5;
10882        d.insert_footnote();
10883        assert_eq!(d.source, src, "nothing written");
10884        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
10885    }
10886
10887    #[test]
10888    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
10889        // The empty body is the one place this could go wrong: the definition
10890        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
10891        // byte early would draw up in the paragraph above the note it belongs to.
10892        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
10893        d.place_caret(7, false);
10894        d.insert_footnote();
10895        d.build_visual(80); // the frame a frontend draws after the edit
10896        assert_eq!(
10897            d.vmap.snap_to_stop(d.caret),
10898            d.caret,
10899            "the caret sits on a stop"
10900        );
10901        let (row, _) = d.caret_pos();
10902        assert!(
10903            drawn_rows(&d)[row].contains("[1]"),
10904            "the caret is on the note's row, not above it: {:?}",
10905            drawn_rows(&d)
10906        );
10907    }
10908
10909    #[test]
10910    fn footnote_at_caret_resolves_a_reference_to_its_note() {
10911        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
10912        // blank line, as one has to.
10913        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
10914        d.caret = 9;
10915        let f = d
10916            .footnote_at_caret()
10917            .expect("the caret stands in a reference");
10918        assert_eq!(f.label, "1");
10919        assert_eq!(f.text.as_deref(), Some("the note"));
10920        // The offset points at the note's first word, not at the definition's
10921        // `[` — the marker is decoration with no caret stop on it.
10922        assert_eq!(f.offset, Some(29));
10923        assert_eq!(&d.source[29..37], "the note");
10924        // …and `end` closes the range, so a frontend can ask which rendered rows
10925        // the note occupies rather than re-deriving them from the text.
10926        assert_eq!(f.end, Some(37));
10927        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
10928    }
10929
10930    /// Two definitions in a row: each is its own note, and neither reaches into
10931    /// the other.
10932    ///
10933    /// A djot definition's span used to run past the blank line into the first
10934    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
10935    /// offsets named the *next* note's rows too, showing a reader two footnotes
10936    /// when they had asked about one. twig 3.1 ends the span after the block's
10937    /// own last line; the test outlives the workaround leaf carried for it.
10938    #[test]
10939    fn footnote_at_stops_a_note_at_the_definition_after_it() {
10940        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
10941        for format in [Format::Markdown, Format::Djot] {
10942            let mut d = Doc::from_source(src.to_string(), format).unwrap();
10943            d.caret = 7;
10944            let f = d.footnote_at_caret().expect("a reference");
10945            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
10946            assert_eq!(
10947                &src[f.offset.unwrap()..f.end.unwrap()],
10948                "first note.",
10949                "in {format:?}"
10950            );
10951        }
10952    }
10953
10954    /// The other side of that boundary: a blank line *inside* a definition is
10955    /// interior to it, and the note keeps its second paragraph.
10956    ///
10957    /// This is what the old body scan cost. It stopped at the first line not
10958    /// indented under the note — a blank line is not — so a two-paragraph note
10959    /// came back as its first paragraph, and "go to note" framed half of it.
10960    /// Reading the span twig gives is both simpler and right.
10961    #[test]
10962    fn footnote_at_keeps_a_notes_second_paragraph() {
10963        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
10964        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
10965        d.caret = 7;
10966        let f = d.footnote_at_caret().expect("a reference");
10967        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
10968        // And it stops there — `After.` is the next block, not more note.
10969        assert_eq!(
10970            &src[f.offset.unwrap()..f.end.unwrap()],
10971            f.text.as_deref().unwrap()
10972        );
10973        assert!(!f.text.as_deref().unwrap().contains("After"));
10974    }
10975
10976    #[test]
10977    fn footnote_at_bounds_a_note_whose_body_is_empty() {
10978        // `[^1]:` with nothing after it. The range is empty rather than
10979        // inverted, and still points inside the definition — which is what keeps
10980        // a frontend's row lookup from walking off into the block above.
10981        let src = "A claim[^1].\n\n[^1]:\n";
10982        let mut d = doc_with("fn_empty_body", src);
10983        d.caret = 9;
10984        let f = d.footnote_at_caret().expect("a reference");
10985        assert_eq!(f.text.as_deref(), Some(""));
10986        assert_eq!(f.offset, f.end, "an empty note is an empty range");
10987        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
10988    }
10989
10990    #[test]
10991    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
10992        let mut d = doc_with(
10993            "fn_at_caret_none",
10994            "A claim[^1] and more.\n\n[^1]: the note\n",
10995        );
10996        d.caret = 2; // in the prose
10997        assert_eq!(d.footnote_at_caret(), None);
10998    }
10999
11000    #[test]
11001    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
11002        // The two are deliberately separate: a reference names a note in this
11003        // document, a link names somewhere to leave for, and answering one with
11004        // the other is what made a reference click do nothing at all.
11005        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
11006        d.caret = 3; // the `1` of `[^1]`
11007        assert!(d.footnote_at_caret().is_some());
11008        assert_eq!(
11009            d.link_destination_at_caret(),
11010            None,
11011            "a reference is not a link"
11012        );
11013
11014        d.caret = 10; // inside the link's label
11015        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
11016        assert_eq!(
11017            d.link_destination_at_caret().as_deref(),
11018            Some("https://x.dev")
11019        );
11020    }
11021
11022    #[test]
11023    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
11024        // A `[^99]` the document never defines is a real state — a note deleted
11025        // out from under its reference — and the label is what lets a frontend
11026        // say so. `None` here would be indistinguishable from "not on a
11027        // reference", which is the wrong thing to tell a reader.
11028        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
11029        d.caret = 9;
11030        let f = d
11031            .footnote_at_caret()
11032            .expect("the reference is still a reference");
11033        assert_eq!(f.label, "99");
11034        assert_eq!(f.text, None);
11035        assert_eq!(f.offset, None);
11036    }
11037
11038    #[test]
11039    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
11040        // Labels are not always numbers, and a note's body runs past its first
11041        // line — the indented continuation belongs to the note, so it comes back
11042        // with it (source bytes, verbatim, as documented).
11043        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
11044        let mut d = doc_with("fn_word_label", src);
11045        d.caret = 6;
11046        let f = d
11047            .footnote_at_caret()
11048            .expect("the caret stands in a reference");
11049        assert_eq!(f.label, "note");
11050        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
11051    }
11052
11053    #[test]
11054    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
11055        // The point of the offset form: a pointer hovering a reference asks what
11056        // note it names, and must not drag the caret along to ask.
11057        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
11058        d.caret = 0;
11059        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
11060        assert_eq!(f.label, "1");
11061        assert_eq!(f.text.as_deref(), Some("the note"));
11062        assert_eq!(d.caret, 0, "asking must not move the caret");
11063        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
11064    }
11065
11066    #[test]
11067    fn footnote_definition_at_caret_points_back_at_the_reference() {
11068        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
11069        // the caret can rest on — is at 9.
11070        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
11071        d.caret = 30; // inside the note's body
11072        let f = d
11073            .footnote_definition_at_caret()
11074            .expect("the caret stands in a definition");
11075        assert_eq!(f.label, "1");
11076        assert_eq!(f.offset, Some(9));
11077        assert_eq!(&d.source[7..11], "[^1]");
11078    }
11079
11080    #[test]
11081    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
11082        // The two legs have to meet: wherever `footnote_at` sends the caret, the
11083        // definition query must answer for — otherwise arriving at a note leaves
11084        // the reader somewhere the way back isn't offered.
11085        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
11086        let mut d = doc_with("fn_def_marker", src);
11087        let landed = d.footnote_at(9).unwrap().offset.unwrap();
11088        assert_eq!(
11089            d.footnote_definition_at(landed).and_then(|f| f.offset),
11090            Some(9),
11091            "the note a reference sends you to offers the way back"
11092        );
11093    }
11094
11095    #[test]
11096    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
11097        // The two queries answer for disjoint places, which is what lets one
11098        // gesture mean "down to the note" in one and "back up" in the other
11099        // without either having to remember which way the reader is going.
11100        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
11101        d.caret = 2; // prose
11102        assert_eq!(d.footnote_definition_at_caret(), None);
11103        d.caret = 9; // the reference
11104        assert_eq!(d.footnote_definition_at_caret(), None);
11105        assert!(
11106            d.footnote_at_caret().is_some(),
11107            "which is the reference's own query"
11108        );
11109    }
11110
11111    #[test]
11112    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
11113        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
11114        // note", which is false and leaves a frontend unable to explain why the
11115        // way back is missing.
11116        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
11117        let mut d = doc_with("fn_def_orphan", src);
11118        d.caret = src.find("orphan").unwrap();
11119        let f = d
11120            .footnote_definition_at_caret()
11121            .expect("an orphan is still a definition");
11122        assert_eq!(f.label, "2");
11123        assert_eq!(f.offset, None);
11124    }
11125
11126    #[test]
11127    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
11128        // One label, cited twice. The first is where the reader most likely came
11129        // from, and the only answer that doesn't depend on how they got here.
11130        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
11131        let mut d = doc_with("fn_def_repeat", src);
11132        d.caret = src.find("the note").unwrap();
11133        let f = d.footnote_definition_at_caret().expect("a definition");
11134        assert_eq!(
11135            f.offset,
11136            Some(5),
11137            "the first `[^a]`'s label, not the second's"
11138        );
11139        assert_eq!(&src[3..7], "[^a]");
11140    }
11141
11142    #[test]
11143    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
11144        // Down and back up, each leg found from the document rather than from a
11145        // memory of the other — so it still works for a reader who scrolled to
11146        // the notes instead of jumping there.
11147        //
11148        // `place_caret` rather than assigning `caret`, because that is what a
11149        // frontend calls: it snaps to a real caret stop, and a jump that lands
11150        // on a byte the caret can't rest on would arrive somewhere the return
11151        // leg no longer answers for. `build_map` first, since snapping is a
11152        // no-op until the map exists — which is exactly how this went unnoticed
11153        // when the offsets pointed at the `[^` markers.
11154        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
11155        d.build_map(None);
11156        d.place_caret(9, false);
11157        let down = d
11158            .footnote_at_caret()
11159            .expect("a reference")
11160            .offset
11161            .expect("a note");
11162        d.place_caret(down, false);
11163        let up = d
11164            .footnote_definition_at_caret()
11165            .expect("a definition")
11166            .offset
11167            .expect("a reference");
11168        d.place_caret(up, false);
11169        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
11170        assert_eq!(
11171            d.footnote_at_caret().expect("back on the reference").label,
11172            "1"
11173        );
11174    }
11175
11176    #[test]
11177    fn insert_link_hands_the_destination_to_twig_raw() {
11178        // Escaping is twig's, and format-specific: Markdown ends a destination
11179        // at the first space and needs the `<…>` form, where djot would read
11180        // those angle brackets as part of the URL.
11181        let mut d = doc_with("link_space", "word\n");
11182        d.anchor = Some(0);
11183        d.caret = 4;
11184        d.insert_link("a b");
11185        assert_eq!(d.source, "[word](<a b>)\n");
11186    }
11187
11188    #[test]
11189    fn insert_link_reports_a_destination_no_format_can_carry() {
11190        let mut d = doc_with("link_bad", "word\n");
11191        d.anchor = Some(0);
11192        d.caret = 4;
11193        d.insert_link("a\nb");
11194        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
11195        assert!(
11196            d.status.is_some(),
11197            "InvalidArgument should reach the status line"
11198        );
11199        assert!(!d.dirty);
11200    }
11201
11202    #[test]
11203    fn insert_link_works_in_wysiwyg_view() {
11204        let mut d = wysiwyg_doc("link_wys", "word here\n");
11205        d.anchor = Some(0);
11206        d.caret = 4;
11207        d.insert_link("http://x.dev");
11208        assert_eq!(d.source, "[word](http://x.dev) here\n");
11209        assert_eq!(d.selected_text(), Some("word"));
11210        // The map the caret has to keep riding is rebuilt each frame; motion
11211        // over the fresh one must still land on a real stop (the debug_assert).
11212        d.build_visual(80);
11213        d.move_right(false);
11214        d.move_left(false);
11215    }
11216
11217    #[test]
11218    fn click_maps_a_row_col_to_a_byte_offset() {
11219        let mut d = doc_with("click", "ab\ncd\n");
11220        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
11221        assert_eq!(d.caret, 4);
11222    }
11223
11224    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
11225    // stop just as the `(row, col)` click path does, so the caret can never come
11226    // to rest in the blank gap between two paragraphs — where it would draw in one
11227    // place and type in another.
11228    #[test]
11229    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
11230        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
11231        // caret stop (stops are 0,1,3,4).
11232        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
11233        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
11234        d.place_caret(2, false);
11235        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11236        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
11237    }
11238
11239    #[test]
11240    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
11241        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
11242        d.place_caret(0, false); // anchor at the start of "A"
11243        d.place_caret(2, true); // drag into the gap
11244        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
11245        let (s, e) = d.selection().expect("a selection");
11246        assert!(
11247            d.vmap.is_stop(s) && d.vmap.is_stop(e),
11248            "selection {s}..{e} off a stop"
11249        );
11250    }
11251
11252    #[test]
11253    fn place_caret_on_a_real_stop_is_left_untouched() {
11254        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
11255        d.place_caret(3, false); // the start of "B" — a genuine stop
11256        assert_eq!(d.caret, 3);
11257    }
11258
11259    // An *empty paragraph* (two blank lines, an intentional blank line the user
11260    // opened) is a real caret stop, unlike the gap — a click into it must stay.
11261    #[test]
11262    fn place_caret_rests_in_an_empty_paragraph() {
11263        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
11264        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
11265        assert!(d.vmap.is_stop(empty));
11266        d.place_caret(empty, false);
11267        assert_eq!(d.caret, empty);
11268    }
11269
11270    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
11271    // a drag over the word `bold` ends there, and a caret placed there stays.
11272    #[test]
11273    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
11274        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
11275        let mut d = wysiwyg_doc("place_mark_end", src);
11276        let start = src.find("bold").unwrap();
11277        d.place_caret(start, false);
11278        d.place_caret(start + 4, true);
11279        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
11280        d.toggle(InlineKind::Strong);
11281        assert_eq!(d.source, src.replace("**bold**", "bold"));
11282    }
11283
11284    #[test]
11285    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
11286        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
11287        d.caret = 7; // before the `d`
11288        d.move_right(false);
11289        assert_eq!(d.caret, 8, "onto the end of the bold");
11290        assert!(d.active_inline_marks().contains(InlineKind::Strong));
11291        d.move_right(false);
11292        assert_eq!(d.caret, 10, "past the closing `**`");
11293        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
11294        d.move_left(false);
11295        assert_eq!(d.caret, 8);
11296        d.move_left(false);
11297        assert_eq!(d.caret, 7);
11298        // Typing at the inner home extends the bold.
11299        d.caret = 8;
11300        d.insert("!");
11301        assert_eq!(d.source, "a **bold!** b");
11302    }
11303
11304    #[test]
11305    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
11306        // The splice path shifts the home with the block it is in, and the
11307        // re-rendered block finds its own again.
11308        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
11309        d.build_visual_unwrapped();
11310        d.edit(0, 0, "zz");
11311        d.build_visual_unwrapped();
11312        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
11313        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11314        let at = d.source.find("bold").unwrap();
11315        d.edit(at, at, "very ");
11316        d.build_visual_unwrapped();
11317        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
11318        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
11319    }
11320
11321    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
11322        doc_in(View::Wysiwyg, name, body)
11323    }
11324
11325    /// How many list items the source actually parses into — the check that a
11326    /// marker Leaf wrote is a marker the format agrees is one.
11327    fn list_items(doc: &mut Doc) -> usize {
11328        doc.editor
11329            .nodes()
11330            .unwrap()
11331            .iter()
11332            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
11333            .count()
11334    }
11335
11336    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
11337    /// incremental (`build_spliced` / `build_cached`) path must always match.
11338    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
11339        reference_map_revealing(source, None)
11340    }
11341
11342    /// [`reference_map`] with a reveal line — the ground truth for the
11343    /// `MarkupMode::Full` builds, where the map is a function of the caret's
11344    /// line as well as the text.
11345    fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
11346        // The same parse `Doc` uses. With twig's plain defaults instead, the two
11347        // sides disagree on what the *document* is before the renderer is even
11348        // reached — a bare `:word` is a text directive to one and prose to the
11349        // other — and the mismatch reads as a splice bug that isn't one.
11350        let mut ed =
11351            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
11352        let nodes = ed.nodes().unwrap();
11353        crate::wysiwyg::build(
11354            &nodes,
11355            source,
11356            None,
11357            false,
11358            &wysiwyg::Surface::default(),
11359            reveal,
11360        )
11361    }
11362
11363    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
11364        if a.rows.len() != b.rows.len() {
11365            return true;
11366        }
11367        for (ra, rb) in a.rows.iter().zip(&b.rows) {
11368            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
11369                return true;
11370            }
11371            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
11372                if ga.ch != gb.ch || ga.src != gb.src {
11373                    return true;
11374                }
11375            }
11376        }
11377        false
11378    }
11379
11380    #[test]
11381    fn incremental_build_matches_a_fresh_build_across_edits() {
11382        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
11383        // fast path, gated on twig's `dirty_range`) or falls back to
11384        // `build_cached`. After each edit the map must be byte-identical to a
11385        // from-scratch build — this is the correctness net under the splice.
11386        let docs = [
11387            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
11388            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
11389            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
11390            // A footnote definition is a root beside `doc`, merged back into the
11391            // top-level list by `wysiwyg::top_blocks`. The random edits below
11392            // make and unmake definitions as they go (a deleted `:` turns one
11393            // back into a paragraph, and vice versa), which is exactly the
11394            // structural churn the splice path has to notice and bail out of.
11395            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
11396            // A comment is a top-level block that draws no rows — a layout entry
11397            // at zero rows either side of blocks that do. The edits below type
11398            // into the blocks around it (a splice past a hidden block), and
11399            // break the comment open into prose and back (a structural change).
11400            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
11401            // Link reference definitions: a hidden block that an edit can turn
11402            // into a paragraph (a deleted `:`) and back, and whose own bytes an
11403            // edit can land in.
11404            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
11405        ];
11406        // A deterministic mix: mostly single characters (which stay inside one
11407        // block → splice), plus edits that reshape structure (a paragraph break,
11408        // a heading marker, a code fence → fallback), so both paths are exercised.
11409        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
11410        for src in docs {
11411            let mut d = wysiwyg_doc("diff", src);
11412            d.build_visual_unwrapped();
11413            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
11414
11415            for step in 0..60usize {
11416                let len = d.source.len();
11417                let raw = (step * 13 + 5) % (len + 1);
11418                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11419                let pre = d.source.clone();
11420                let action;
11421                if step % 3 == 0 && pos < len {
11422                    let end = (pos + 1..=len)
11423                        .find(|&i| d.source.is_char_boundary(i))
11424                        .unwrap();
11425                    action = format!("delete [{pos},{end})");
11426                    d.edit(pos, end, "");
11427                } else {
11428                    let ins = inserts[step % inserts.len()];
11429                    action = format!("insert {ins:?} @ {pos}");
11430                    d.edit(pos, pos, ins);
11431                }
11432                d.build_visual_unwrapped();
11433                if maps_differ(&d.vmap, &reference_map(&d.source)) {
11434                    panic!(
11435                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
11436                        d.source
11437                    );
11438                }
11439            }
11440        }
11441    }
11442
11443    /// A frontend is handed [`Doc::vmap`] and may present it differently:
11444    /// leaf-ratatui splices blank filler rows under an oversized heading so the
11445    /// raster it paints there has somewhere to stand, and leaves them in the map
11446    /// because the caret and the mouse both read it between frames. The splice
11447    /// path addresses that map by *row index*, against the block layout the last
11448    /// build recorded — so handed a map with rows in it that no block owns, it
11449    /// laid the re-rendered block over one of the fillers and carried the rows
11450    /// the block really occupied into the suffix. One stranded copy of the
11451    /// edited line, and everything below it a row further down, per keystroke.
11452    ///
11453    /// A map that isn't the one the layout describes is a map this path can't
11454    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
11455    #[test]
11456    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
11457        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
11458        d.build_visual_unwrapped();
11459
11460        // Stand in for the heading filler rows: two blank rows past the heading
11461        // that no block accounts for. Cloning a real row keeps every field
11462        // plausible — it is the row *count* the splice can't survive.
11463        let filler = d.vmap.rows[0].clone();
11464        d.vmap.rows.insert(1, filler.clone());
11465        d.vmap.rows.insert(1, filler);
11466
11467        // An edit inside the last block: the single-block case the splice path
11468        // is for, and the one the frontend hits on every keystroke.
11469        let at = d.source.len() - 1;
11470        d.edit(at, at, "!");
11471        d.build_visual_unwrapped();
11472
11473        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
11474    }
11475
11476    /// A glyph's [`FaceId`] has to mean the same thing however its row was
11477    /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
11478    /// cloned at a shifted offset, and a previous map's rows a splice kept
11479    /// untouched — and only the first of those walks a `data-font` at all. An
11480    /// index into a per-build table would have had the same glyph naming two
11481    /// families the moment a second one appeared; the id is the name's own
11482    /// hash, so nothing is remapped and the table is merged rather than rebuilt.
11483    ///
11484    /// Two families, because one cannot tell a wrong id from a right one.
11485    ///
11486    /// [`FaceId`]: crate::style::FaceId
11487    /// [`BlockCache`]: crate::wysiwyg::BlockCache
11488    #[test]
11489    fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
11490        use crate::style::{FaceId, FaceRef};
11491        let garamond = FaceId::of("Garamond");
11492        let futura = FaceId::of("Futura");
11493        let mut d = wysiwyg_doc(
11494            "two_faces",
11495            "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
11496        );
11497        d.build_visual_unwrapped();
11498
11499        // What the map has to keep saying, whichever path built it.
11500        let check = |d: &Doc, ctx: &str| {
11501            let face_of = |ch: char| {
11502                d.vmap
11503                    .rows
11504                    .iter()
11505                    .flat_map(|r| r.glyphs.iter())
11506                    .find(|g| g.ch == ch)
11507                    .map(|g| g.style.font)
11508            };
11509            assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
11510            assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
11511            assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
11512            assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
11513            assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
11514        };
11515        check(&d, "fresh");
11516
11517        // An edit inside the second block: the single-block case the splice
11518        // path is for. The first block's rows are carried over untouched, so
11519        // its glyphs' ids are the previous build's and the table has to be too.
11520        let at = d.source.find("beta").unwrap();
11521        d.edit(at, at, "z");
11522        d.build_visual_unwrapped();
11523        check(&d, "after an edit in the second block");
11524        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
11525
11526        // And the other way round, so the block that was kept is the one that
11527        // is now re-rendered.
11528        let at = d.source.find("alpha").unwrap();
11529        d.edit(at, at, "z");
11530        d.build_visual_unwrapped();
11531        check(&d, "after an edit in the first block");
11532        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
11533
11534        // A structural edit is one the splice bails out of, so the map is
11535        // reassembled by `build_cached` — where an untouched block is a *cache
11536        // hit* and its rows are cloned without a `data-font` being walked
11537        // again. The names the entry stored are what keeps the table honest
11538        // there.
11539        let at = d.source.find("\n\ny ").unwrap();
11540        d.edit(at, at, "\n\nmiddle");
11541        d.build_visual_unwrapped();
11542        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
11543        // Twice, because that first `build_cached` is what stores the entries:
11544        // this one is the build where the Garamond block is a *hit*, its rows
11545        // cloned with their ids and no attribute walked to explain them.
11546        let at = d.source.len() - 1;
11547        d.edit(at, at, "\n\ntail");
11548        d.build_visual_unwrapped();
11549        check(&d, "after a structural edit, through the block cache");
11550        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
11551
11552        // A family the edit took the last glyph of leaves the glyphs with no
11553        // face and the table with a name nothing asks for — harmless, and the
11554        // price of not walking the rows the splice exists to avoid walking.
11555        let span = d.source.find("<span data-font=\"Futura\">").unwrap();
11556        let end = d.source.rfind("</span>").unwrap() + "</span>".len();
11557        d.edit(span, end, "beta");
11558        d.build_visual_unwrapped();
11559        assert!(!d.source.contains("Futura"), "{:?}", d.source);
11560        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
11561    }
11562
11563    #[test]
11564    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
11565        // The same correctness net as `incremental_build_matches_a_fresh_build_
11566        // across_edits`, under `MarkupMode::Full` — where the map depends on
11567        // the caret's *line* as well as the text, so the two caches have a new
11568        // way to be wrong. Both are exercised: the block cache can hand back
11569        // rows built for a line that is no longer the revealed one, and the
11570        // splice path can reuse a suffix that still has yesterday's line raw.
11571        //
11572        // Caret motion is interleaved with the edits deliberately, because a
11573        // caret that only ever moved with the edit would never cross a line
11574        // without also dirtying it — the case where a stale reveal survives.
11575        let docs = [
11576            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
11577            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
11578        ];
11579        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
11580        for src in docs {
11581            let mut d = wysiwyg_doc("reveal_diff", src);
11582            d.set_markup_mode(MarkupMode::Full);
11583
11584            for step in 0..60usize {
11585                let len = d.source.len();
11586                let raw = (step * 13 + 5) % (len + 1);
11587                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
11588                let pre = d.source.clone();
11589                let action;
11590                if step % 3 == 0 && pos < len {
11591                    let end = (pos + 1..=len)
11592                        .find(|&i| d.source.is_char_boundary(i))
11593                        .unwrap();
11594                    action = format!("delete [{pos},{end})");
11595                    d.edit(pos, end, "");
11596                } else {
11597                    let ins = inserts[step % inserts.len()];
11598                    action = format!("insert {ins:?} @ {pos}");
11599                    d.edit(pos, pos, ins);
11600                }
11601                // Walk the caret somewhere else in the document, independently
11602                // of where the edit landed.
11603                let want = (step * 29 + 11) % (d.source.len() + 1);
11604                d.caret = (want..=d.source.len())
11605                    .find(|&i| d.source.is_char_boundary(i))
11606                    .unwrap();
11607                d.build_visual_unwrapped();
11608
11609                let want = reference_map_revealing(&d.source, d.reveal_line());
11610                if maps_differ(&d.vmap, &want) {
11611                    panic!(
11612                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
11613                        d.caret, d.source
11614                    );
11615                }
11616            }
11617        }
11618    }
11619
11620    #[test]
11621    fn caret_motion_across_lines_rebuilds_only_under_full() {
11622        // The cache-key change has to earn its keep in both directions: `Full`
11623        // must rebuild when the caret changes line (or the reveal would never
11624        // move), and the hidden modes must *not* (or every arrow key would pay
11625        // for a feature they don't use). The existing `cache_motion` test pins
11626        // the second for the default mode; this pins the pair against a mode
11627        // change alone.
11628        let body = "*one* here\n\n*two* there\n";
11629
11630        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
11631        full.set_markup_mode(MarkupMode::Full);
11632        caret_at(&mut full, "one");
11633        let before = full.revision();
11634        caret_at(&mut full, "two");
11635        assert_eq!(full.revision(), before, "motion is not an edit");
11636        assert!(
11637            drawn_rows(&full).iter().any(|r| r == "*two* there"),
11638            "the map followed the caret: {:?}",
11639            drawn_rows(&full)
11640        );
11641
11642        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
11643        caret_at(&mut hidden, "one");
11644        let key = hidden.vmap_key.clone();
11645        caret_at(&mut hidden, "two");
11646        assert_eq!(
11647            hidden.vmap_key, key,
11648            "a hidden mode rebuilds nothing on motion"
11649        );
11650    }
11651
11652    #[test]
11653    fn wysiwyg_down_crosses_a_paragraph_boundary() {
11654        // Regression: the blank separator row used to share the previous
11655        // paragraph's end offset, so Down got pinned at the boundary (while Up
11656        // still crossed). Both directions must step through it symmetrically.
11657        //
11658        // It's now stepped *over* rather than onto: the blank line between two
11659        // paragraphs is the boundary being drawn, not a line of the document, so
11660        // one press of Down crosses it. The goal column survives the crossing —
11661        // col 3 at the end of "abc" is col 3 at the end of "def".
11662        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
11663        d.caret = 3; // end of "abc" (row 0)
11664        d.move_down(false);
11665        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
11666        assert_eq!(d.caret, 8); // end of "def", col 3 kept
11667        d.move_up(false);
11668        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
11669        assert_eq!(d.caret, 3);
11670    }
11671
11672    #[test]
11673    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
11674        // The second Up and the second Down here run off the ends of the
11675        // document, which is no longer a place a press is swallowed: they carry
11676        // the caret to the start and the end of the text. The claim in the
11677        // middle — that a Down retraces the Up that crossed the paragraph gap —
11678        // is the one this test is for, and it is asserted where it is made.
11679        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
11680        d.caret = 5; // start of "def"
11681        let start = d.caret_pos();
11682        d.move_up(false);
11683        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
11684        d.move_up(false);
11685        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
11686        d.move_down(false);
11687        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
11688        d.move_down(false);
11689        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
11690    }
11691
11692    #[test]
11693    fn wysiwyg_new_paragraph_shows_before_typing() {
11694        // Regression: two Enters at the end of a paragraph produced trailing
11695        // newlines with no AST node, so the caret appeared stuck on the old line
11696        // until a character was typed. It must ride down onto the new line now.
11697        let mut d = doc_with("wys_newpara", "abc\n");
11698        d.view = View::Wysiwyg;
11699        d.caret = 3;
11700        d.insert("\n");
11701        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
11702        assert_eq!(d.source, "abc\n\n\n");
11703        d.build_visual(80);
11704        let (row, _) = d.caret_pos();
11705        assert!(
11706            row >= 2,
11707            "caret should have moved down to the new line, got row {row}"
11708        );
11709        assert!(
11710            d.vmap.num_rows() >= 3,
11711            "the blank lines should render as rows"
11712        );
11713    }
11714
11715    #[test]
11716    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
11717        // The reported bug: Enter at the end of a paragraph that has another
11718        // paragraph below put the caret at the *start of the next paragraph* —
11719        // the empty paragraph it opened had no row, so the caret snapped onto
11720        // "World". It must now sit on its own empty line, with a blank spacer
11721        // above it (the paragraph gap).
11722        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
11723        d.caret = 5; // end of "Hello"
11724        d.newline();
11725        d.build_visual(80);
11726        let (row, col) = d.caret_pos();
11727        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
11728        assert_eq!(
11729            d.vmap.row_width(row),
11730            0,
11731            "caret's row must be empty, not 'World'"
11732        );
11733        assert!(
11734            row >= 2,
11735            "a blank spacer row should sit above the caret, got row {row}"
11736        );
11737        // The row above the caret is a real (empty) gap, and "Hello" stays put.
11738        assert_eq!(
11739            d.vmap.row_width(row - 1),
11740            0,
11741            "the row above the caret is a gap"
11742        );
11743        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
11744        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
11745    }
11746
11747    #[test]
11748    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
11749        // At the document end a single Enter must also show the paragraph gap —
11750        // a blank spacer row above the caret — so the layout already matches how
11751        // it will look once the new paragraph has text.
11752        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
11753        d.caret = 5; // end of "Hello", no trailing newline
11754        d.newline(); // source becomes "Hello\n\n"
11755        d.build_visual(80);
11756        let (row, col) = d.caret_pos();
11757        assert_eq!(col, 0);
11758        assert!(
11759            row >= 2,
11760            "caret should sit below a blank spacer, got row {row}"
11761        );
11762        assert_eq!(
11763            d.vmap.row_width(row - 1),
11764            0,
11765            "the row above the caret is a gap"
11766        );
11767    }
11768
11769    #[test]
11770    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
11771        // The spacer is view-only: typing the new paragraph must not reflow the
11772        // caret onto a different row — the transient view already matched the
11773        // settled one.
11774        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
11775        d.caret = 5;
11776        d.newline();
11777        d.build_visual(80);
11778        let before = d.caret_pos();
11779        d.insert("New");
11780        d.build_visual(80);
11781        let after = d.caret_pos();
11782        assert_eq!(
11783            after.0, before.0,
11784            "typing must not move the caret to another row ({before:?} -> {after:?})"
11785        );
11786    }
11787
11788    #[test]
11789    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
11790        // Return at the end of the block's last line writes an empty line the
11791        // map used to drop, so the caret landed on `after` and the next
11792        // keystroke went into the paragraph below instead of into the code.
11793        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
11794        d.caret = d.source.find("beta").unwrap() + "beta".len();
11795        d.build_visual(80);
11796        let before = d.caret_pos().0;
11797
11798        d.newline();
11799        d.build_visual(80);
11800        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
11801
11802        let (row, col) = d.caret_pos();
11803        assert_eq!(row, before + 1, "the caret moves down one row");
11804        assert_eq!(col, 0, "onto the head of the empty line");
11805        let span = d.vmap.code_blocks[0].rows_span.clone();
11806        assert!(
11807            span.contains(&row),
11808            "caret row {row} is outside the block's rows {span:?}"
11809        );
11810
11811        // The whole point: what is typed next is code.
11812        d.insert("gamma");
11813        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
11814    }
11815
11816    #[test]
11817    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
11818        let fm = "---\ntitle: hi\n---\n";
11819        let body = format!("{fm}# leaf\n\nbody\n");
11820        let mut d = wysiwyg_doc("wys_fm", &body);
11821        // Opening lifts the caret out of the now-hidden frontmatter.
11822        assert_eq!(
11823            d.caret,
11824            fm.len(),
11825            "caret should start at the first real block"
11826        );
11827        // Left at the content start can't step back into frontmatter.
11828        d.move_left(false);
11829        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
11830        // Doc-start lands on the content floor, not offset 0.
11831        d.move_doc_start(false);
11832        assert_eq!(d.caret, fm.len());
11833        // Select-all + copy never include the frontmatter bytes.
11834        d.select_all();
11835        let sel = d.selected_text().unwrap().to_string();
11836        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
11837        assert!(
11838            sel.starts_with("# leaf"),
11839            "selection should begin at content: {sel:?}"
11840        );
11841    }
11842
11843    #[test]
11844    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
11845        // A fresh note is frontmatter and nothing else. With no rendered block
11846        // to floor the caret it opened at offset 0 — before the opening `---` —
11847        // so the first keystroke wrote itself in front of the metadata and the
11848        // file came out as `This---\ntitle: …`.
11849        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
11850        let mut d = wysiwyg_doc("wys_fm_only", fm);
11851        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
11852        // Nothing is rendered, so the caret draws at the origin of an empty view
11853        // — the same place an empty document puts it.
11854        assert_eq!(d.caret_pos(), (0, 0));
11855        d.insert("This");
11856        assert_eq!(d.source, format!("{fm}This"));
11857    }
11858
11859    /// `select_range` is the verb for a range a host already knows the bytes of,
11860    /// so it must not snap — and must still hold every invariant `place_caret`
11861    /// holds, the frontmatter floor above all.
11862    #[test]
11863    fn select_range_takes_the_range_as_given_but_still_floors_it() {
11864        let fm = "---\ntitle: foo\n---\n\n";
11865        let body = format!("{fm}body foo here\n");
11866        let mut d = wysiwyg_doc("wys_select_range", &body);
11867
11868        // The `foo` in the body: taken exactly, not snapped to a caret stop.
11869        let at = body.rfind("foo").unwrap();
11870        d.select_range(at, at + 3);
11871        assert_eq!(d.selection(), Some((at, at + 3)));
11872        assert_eq!(d.selected_text(), Some("foo"));
11873
11874        // The `foo` in the hidden frontmatter: below the floor, so both ends
11875        // come up to it rather than parking the caret in the metadata, where a
11876        // later keystroke would rewrite `title:`.
11877        let hidden = body.find("foo").unwrap();
11878        assert!(hidden < d.vmap.content_start);
11879        d.select_range(hidden, hidden + 3);
11880        assert!(
11881            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
11882            "a range under the floor must not leave the caret in the frontmatter"
11883        );
11884
11885        // Past the end, and mid-character, are both brought back to something
11886        // sliceable rather than panicking the next reader of the range.
11887        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
11888        let mut d = multi;
11889        d.select_range(2, 9_999);
11890        assert_eq!(d.caret, d.source.len());
11891        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
11892        assert!(d.source.is_char_boundary(d.caret));
11893    }
11894
11895    /// The bug `select_range` exists for: a match butting up against a hidden
11896    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
11897    /// the one before the `**`.
11898    #[test]
11899    fn select_range_does_not_snap_off_a_hidden_delimiter() {
11900        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
11901        let at = d.source.find("needle").unwrap();
11902        d.select_range(at, at + 6);
11903        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
11904    }
11905
11906    #[test]
11907    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
11908        // Backspace deletes `prev_boundary..caret` directly; at the first real
11909        // block that boundary is inside the hidden frontmatter, so it must be a
11910        // no-op rather than eating the closing `---`.
11911        let fm = "---\ntitle: hi\n---\n";
11912        let body = format!("{fm}leaf\n");
11913        let mut d = wysiwyg_doc("wys_fm_bs", &body);
11914        assert_eq!(d.caret, fm.len());
11915        d.backspace();
11916        assert_eq!(d.source, body, "backspace must not touch frontmatter");
11917        d.delete_word_back();
11918        assert_eq!(
11919            d.source, body,
11920            "word-delete must not touch frontmatter either"
11921        );
11922    }
11923
11924    #[test]
11925    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
11926        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
11927        // fenced div, really, since core parses these on for every document
11928        // now (`parse_extensions`). The container is a `directive` node, an
11929        // `is_block_container` kind like `block_quote`, so the caret works
11930        // inside its child paragraph exactly as it would inside a quote: typing
11931        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
11932        // untouched.
11933        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
11934        let mut d = wysiwyg_doc("wys_vis", body);
11935        d.caret = body.find("hello").unwrap() + "hello".len();
11936        d.insert("!");
11937        assert_eq!(
11938            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
11939            "typing inside the block edits its content in place"
11940        );
11941        assert!(
11942            d.source.contains(":::vis{.public .family}"),
11943            "opening fence survives"
11944        );
11945        assert!(d.source.contains(":::\nafter"), "closing fence survives");
11946    }
11947
11948    #[test]
11949    fn source_view_still_reaches_frontmatter() {
11950        // The metadata is only *hidden*, never lost: the source view edits and
11951        // selects it in full, and it's always preserved on save.
11952        let fm = "---\ntitle: hi\n---\n";
11953        let body = format!("{fm}# leaf\n");
11954        let mut d = doc_with("src_fm", &body);
11955        d.select_all();
11956        let sel = d.selected_text().unwrap();
11957        assert!(
11958            sel.contains("title"),
11959            "source view should select everything"
11960        );
11961        d.move_doc_start(false);
11962        assert_eq!(d.caret, 0, "source view can reach offset 0");
11963    }
11964
11965    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
11966
11967    #[test]
11968    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
11969        // The border and padding between two cells all share one source offset,
11970        // so a column-stepping caret would sit on `│` and then stall there
11971        // forever. Right must step: end of "Name" -> start of "Qty".
11972        let mut d = wysiwyg_doc("tbl_right", TABLE);
11973        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
11974        d.move_right(false);
11975        assert_eq!(
11976            d.caret,
11977            TABLE.find("Qty").unwrap(),
11978            "should land in the next cell"
11979        );
11980        let (r, c) = d.caret_pos();
11981        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
11982    }
11983
11984    #[test]
11985    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
11986        let mut d = wysiwyg_doc("tbl_left", TABLE);
11987        d.caret = TABLE.find("Qty").unwrap();
11988        d.move_left(false);
11989        assert_eq!(
11990            d.caret,
11991            TABLE.find("Name").unwrap() + 4,
11992            "end of the previous cell"
11993        );
11994    }
11995
11996    #[test]
11997    fn wysiwyg_down_steps_over_a_table_rule() {
11998        // Between the header and the first body row sits a `├───┼───┤` rule.
11999        // It's drawn but holds no caret, so one Down must reach "Pear".
12000        let mut d = wysiwyg_doc("tbl_down", TABLE);
12001        d.caret = TABLE.find("Name").unwrap();
12002        d.move_down(false);
12003        assert_eq!(
12004            d.caret,
12005            TABLE.find("Pear").unwrap(),
12006            "one Down reaches the body row"
12007        );
12008        d.move_down(false);
12009        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
12010    }
12011
12012    #[test]
12013    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
12014        let mut d = wysiwyg_doc("tbl_tab", TABLE);
12015        d.caret = TABLE.find("Name").unwrap();
12016        // A hop lands with the destination cell's whole content selected, the
12017        // caret at its end — so typing replaces the cell like a form field.
12018        assert!(d.cell_hop(true));
12019        assert_eq!(
12020            d.selected_text(),
12021            Some("Qty"),
12022            "the target cell comes up selected"
12023        );
12024        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
12025        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
12026        assert_eq!(d.selected_text(), Some("Pear"));
12027        assert!(d.cell_hop(false));
12028        assert_eq!(d.selected_text(), Some("Qty"));
12029    }
12030
12031    #[test]
12032    fn tab_outside_a_table_is_not_a_cell_hop() {
12033        // `cell_hop` reports false so the frontend can indent as usual.
12034        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
12035        d.caret = 4;
12036        assert!(!d.cell_hop(true));
12037        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
12038    }
12039
12040    #[test]
12041    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
12042        let mut d = wysiwyg_doc("tbl_edge", TABLE);
12043        d.caret = TABLE.rfind("12").unwrap(); // the final cell
12044        assert!(!d.cell_hop(true), "no cell after the last one");
12045        d.caret = TABLE.find("Name").unwrap();
12046        assert!(!d.cell_hop(false), "no cell before the first one");
12047    }
12048
12049    #[test]
12050    fn wysiwyg_vertical_cell_motion_holds_the_column() {
12051        // Down/Up step to the cell above/below in the *same column*, not back to
12052        // the top-left the way a naive row/col motion over the picture would.
12053        let mut d = wysiwyg_doc("tbl_vert", TABLE);
12054        d.caret = TABLE.find("Qty").unwrap();
12055        // Each vertical hop selects the destination cell, holding the column.
12056        assert!(d.cell_move_vertical(true));
12057        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
12058        assert!(d.cell_move_vertical(true));
12059        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
12060        assert!(!d.cell_move_vertical(true), "no row below the last");
12061        assert!(d.cell_move_vertical(false));
12062        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
12063        assert!(d.cell_move_vertical(false));
12064        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
12065        assert!(!d.cell_move_vertical(false), "no row above the header");
12066    }
12067
12068    #[test]
12069    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
12070        let mut d = wysiwyg_doc("tbl_grow", TABLE);
12071        d.caret = TABLE.rfind("12").unwrap();
12072        let rows_before = d.source.matches('\n').count();
12073        assert!(d.cell_tab(true), "acts as a table key");
12074        assert_eq!(
12075            d.source.matches('\n').count(),
12076            rows_before + 1,
12077            "a fresh row was appended"
12078        );
12079        assert!(d.caret_in_table(), "the caret entered the new row");
12080        // The caret sits in the new row's first cell — past the old last cell.
12081        assert!(d.caret > TABLE.rfind("12").unwrap());
12082    }
12083
12084    #[test]
12085    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
12086        let mut d = wysiwyg_doc("tbl_ret", TABLE);
12087        d.caret = TABLE.find("Name").unwrap();
12088        assert!(d.cell_return(), "acts as a table key");
12089        assert_eq!(
12090            d.selected_text(),
12091            Some("Pear"),
12092            "Return drops one cell, selecting it"
12093        );
12094        // From the last row, Return appends a row and enters it.
12095        d.caret = TABLE.rfind("Fig").unwrap();
12096        let rows_before = d.source.matches('\n').count();
12097        assert!(d.cell_return());
12098        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
12099        assert!(d.caret_in_table());
12100    }
12101
12102    #[test]
12103    fn return_and_tab_outside_a_table_decline() {
12104        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
12105        d.caret = 4;
12106        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
12107        assert!(!d.cell_tab(true), "no table: the frontend indents");
12108        assert!(
12109            !d.cell_line_break(),
12110            "no table: the frontend breaks the line"
12111        );
12112    }
12113
12114    #[test]
12115    fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
12116        // A document that ends in a table used to end *inside* it: nothing
12117        // past the last cell was a caret stop, so a click in the blank space
12118        // under the grid snapped back into the table and there was no way to
12119        // write a line after it. The bottom border's end is that stop now.
12120        let mut d = wysiwyg_doc("tbl_trail", TABLE);
12121        let rows = d.vmap.num_rows();
12122        d.click(rows + 3, 0, false);
12123        let end = TABLE.trim_end_matches('\n').len();
12124        assert_eq!(d.caret, end, "the caret stands just past the table");
12125        assert!(!d.caret_in_table(), "past the table is outside it");
12126        assert!(!d.cell_return(), "Return there is the frontend's newline");
12127        d.newline();
12128        d.insert("after");
12129        assert_eq!(
12130            d.source,
12131            format!("{TABLE}\nafter\n"),
12132            "Enter opens a paragraph under the table"
12133        );
12134    }
12135
12136    #[test]
12137    fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
12138        // The stop sits at the end of the table's last source line, and a
12139        // line glued under a table is a row of it — `| Fig | 12 |x` would be a
12140        // three-cell row. So the text gets a paragraph of its own, as it does
12141        // beside a block picture.
12142        let mut d = wysiwyg_doc("tbl_type", TABLE);
12143        d.caret = TABLE.trim_end_matches('\n').len();
12144        d.insert("x");
12145        assert_eq!(d.source, format!("{TABLE}\nx\n"));
12146        assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
12147        // And a paste, which joins the block exactly as typing would.
12148        let mut d = wysiwyg_doc("tbl_paste", TABLE);
12149        d.caret = TABLE.trim_end_matches('\n').len();
12150        d.paste("pasted");
12151        assert_eq!(d.source, format!("{TABLE}\npasted\n"));
12152    }
12153
12154    #[test]
12155    fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
12156        let mut d = wysiwyg_doc("tbl_edge", TABLE);
12157        let last_cell_end = TABLE.rfind("12").unwrap() + 2;
12158        let end = TABLE.trim_end_matches('\n').len();
12159        d.caret = last_cell_end;
12160        d.move_right(false);
12161        assert_eq!(d.caret, end, "Right from the last cell leaves the table");
12162        // Backspace there takes no byte: the one behind the caret is the row's
12163        // closing `|`, which the rich view never drew. It steps back instead.
12164        d.backspace();
12165        assert_eq!(d.source, TABLE, "nothing deleted");
12166        assert_eq!(d.caret, last_cell_end, "back into the last cell");
12167        // Down from the last row lands on the same stop, and Up returns.
12168        d.move_down(false);
12169        assert_eq!(d.caret, end, "Down from the last row leaves the table");
12170        d.move_up(false);
12171        assert_eq!(d.caret, last_cell_end);
12172    }
12173
12174    #[test]
12175    fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
12176        // With prose under the table, the stop is one hop between the last
12177        // cell and the paragraph — the shape a block picture's second stop has.
12178        let src = format!("{TABLE}\nafter\n");
12179        let mut d = wysiwyg_doc("tbl_mid", &src);
12180        d.caret = TABLE.rfind("12").unwrap() + 2;
12181        d.move_right(false);
12182        assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
12183        d.move_right(false);
12184        assert_eq!(d.caret, src.find("after").unwrap());
12185        // Typing at the stop still opens a paragraph, and the text below keeps
12186        // its own.
12187        d.move_left(false);
12188        d.insert("x");
12189        assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
12190    }
12191
12192    #[test]
12193    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
12194        let mut d = wysiwyg_doc("tbl_break", TABLE);
12195        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
12196        assert!(d.cell_line_break(), "acts as a table key");
12197        assert!(
12198            d.source.contains("Pear<br>"),
12199            "spelled as an inline <br>: {}",
12200            d.source
12201        );
12202        assert!(d.caret_in_table(), "still in the cell, past the break");
12203        // The break renders as a real line: the "Pear" cell now draws two lines,
12204        // so the table's picture is one row taller than a single-line table.
12205        d.build_visual(80);
12206        let table = &d.vmap.tables[0];
12207        let cell = &table.grid[1].cells[0]; // first body row, first column
12208        assert!(
12209            cell.glyphs.iter().any(|g| g.ch == '\n'),
12210            "the cell carries the break as a newline glyph for the frontend to split"
12211        );
12212    }
12213
12214    #[test]
12215    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
12216        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
12217        // back as structure — the whole point of routing through insert_line_break
12218        // instead of splicing raw `<br>` bytes.
12219        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
12220        d.caret = TABLE.find("Pear").unwrap() + 4;
12221        assert!(d.cell_line_break());
12222        let kinds: Vec<Kind> = d
12223            .editor
12224            .nodes()
12225            .unwrap()
12226            .iter()
12227            .map(|n| n.kind.clone())
12228            .collect();
12229        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
12230        assert!(
12231            !kinds.contains(&Kind::RawInline),
12232            "still raw HTML: {kinds:?}"
12233        );
12234    }
12235
12236    #[test]
12237    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
12238        // The `<br>` draws as one newline glyph, so Backspace over it must take
12239        // all four bytes — a one-byte delete would strand a visible `<br` in the
12240        // cell (the reported bug).
12241        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
12242        d.caret = TABLE.find("Pear").unwrap() + 4;
12243        assert!(d.cell_line_break());
12244        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
12245        d.backspace(); // caret sits just past the break
12246        assert!(
12247            !d.source.contains("<br"),
12248            "no half-deleted <br left: {}",
12249            d.source
12250        );
12251        assert!(
12252            d.source.contains("| Pear |"),
12253            "the cell is back to one line: {}",
12254            d.source
12255        );
12256    }
12257
12258    #[test]
12259    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
12260        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
12261        d.caret = TABLE.find("Pear").unwrap() + 4;
12262        assert!(d.cell_line_break());
12263        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
12264        d.delete_forward();
12265        assert!(
12266            !d.source.contains("<br"),
12267            "no half-deleted <br: {}",
12268            d.source
12269        );
12270        assert!(
12271            d.source.contains("| Pear |"),
12272            "cell back to one line: {}",
12273            d.source
12274        );
12275    }
12276
12277    #[test]
12278    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
12279        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
12280        // still consumed (a real newline would split the one-line row), but the
12281        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
12282        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
12283        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
12284        d.caret = src.find("Pear").unwrap() + 4;
12285        assert!(d.caret_in_table(), "caret should be inside the djot table");
12286        assert!(
12287            d.cell_line_break(),
12288            "the key is consumed, not passed to the frontend"
12289        );
12290        assert_eq!(d.source, src, "the djot cell is left untouched");
12291        assert!(
12292            !d.source.contains("<br>"),
12293            "no non-idiomatic <br> spliced into djot"
12294        );
12295        assert!(
12296            d.status.is_some(),
12297            "the refusal is surfaced on the status line"
12298        );
12299    }
12300
12301    #[test]
12302    fn typing_in_a_cell_edits_that_cell() {
12303        // Editing comes free once offsets map correctly: the caret is a source
12304        // offset, so a normal splice lands inside the pipe table.
12305        let mut d = wysiwyg_doc("tbl_type", TABLE);
12306        d.caret = TABLE.find("Pear").unwrap() + 4;
12307        d.insert("s");
12308        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
12309    }
12310
12311    #[test]
12312    fn motion_and_delete_treat_an_emoji_as_one_character() {
12313        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
12314        // bytes, several codepoints. Right-arrow must clear it in one step, and
12315        // backspace must remove the whole cluster, not a stray joiner.
12316        let family = "👨‍👩‍👧";
12317        let mut d = doc_with("emoji", &format!("a{family}b\n"));
12318        d.caret = 1; // just after 'a', before the emoji
12319        d.move_right(false);
12320        assert_eq!(
12321            d.caret,
12322            1 + family.len(),
12323            "one step clears the whole cluster"
12324        );
12325        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
12326
12327        d.backspace(); // delete the emoji as a unit
12328        assert_eq!(d.source, "ab\n");
12329        assert_eq!(d.caret, 1);
12330    }
12331
12332    #[test]
12333    fn motion_handles_a_combining_accent_as_one_character() {
12334        // "e" + U+0301 (combining acute) renders as one é.
12335        let mut d = doc_with("combining", "e\u{0301}x\n");
12336        d.caret = 0;
12337        d.move_right(false);
12338        assert_eq!(
12339            d.caret,
12340            "e\u{0301}".len(),
12341            "steps past base + combining mark"
12342        );
12343    }
12344
12345    #[test]
12346    fn undo_then_redo_round_trips_an_edit() {
12347        let mut d = doc_with("undo", "hello\n");
12348        d.caret = 5;
12349        d.insert("!");
12350        assert_eq!(d.source, "hello!\n");
12351        d.undo();
12352        assert_eq!(d.source, "hello\n");
12353        assert_eq!(d.caret, 5, "undo restores the caret");
12354        d.redo();
12355        assert_eq!(d.source, "hello!\n");
12356    }
12357
12358    #[test]
12359    fn a_run_of_typing_undoes_as_one_step() {
12360        let mut d = doc_with("coalesce", "\n");
12361        d.caret = 0;
12362        d.insert("a");
12363        d.insert("b");
12364        d.insert("c");
12365        assert_eq!(d.source, "abc\n");
12366        d.undo(); // the whole typed run, not just "c"
12367        assert_eq!(d.source, "\n");
12368        d.undo(); // nothing left — the run was one step
12369        assert_eq!(d.source, "\n");
12370        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12371    }
12372
12373    // ── IME composition ──────────────────────────────────────────────────────
12374
12375    #[test]
12376    fn a_composition_run_undoes_as_one_step() {
12377        let mut d = doc_with("compose", "\n");
12378        d.caret = 0;
12379        // What an IME does: each step replaces the last one's provisional bytes.
12380        d.edit_composing(0, 0, "k");
12381        d.edit_composing(0, 1, "か");
12382        d.edit_composing(0, 3, "かん");
12383        d.edit_composing(0, 6, "感"); // the commit
12384        d.end_composition();
12385        assert_eq!(d.source, "感\n");
12386        d.undo(); // the whole composition, not its last keystroke
12387        assert_eq!(d.source, "\n");
12388        assert_eq!(d.status.as_deref(), None, "the run was a single step");
12389    }
12390
12391    #[test]
12392    fn two_compositions_are_two_undo_steps() {
12393        let mut d = doc_with("compose_two", "\n");
12394        d.caret = 0;
12395        d.edit_composing(0, 0, "か");
12396        d.edit_composing(0, 3, "蚊");
12397        d.end_composition();
12398        d.edit_composing(3, 3, "き");
12399        d.edit_composing(3, 6, "木");
12400        d.end_composition();
12401        assert_eq!(d.source, "蚊木\n");
12402        d.undo();
12403        assert_eq!(d.source, "蚊\n", "only the second composition");
12404        d.undo();
12405        assert_eq!(d.source, "\n");
12406    }
12407
12408    #[test]
12409    fn a_composition_does_not_fold_into_the_typing_around_it() {
12410        let mut d = doc_with("compose_typing", "\n");
12411        d.caret = 0;
12412        d.insert("a");
12413        d.insert("b");
12414        d.edit_composing(2, 2, "か");
12415        d.edit_composing(2, 5, "蚊");
12416        d.end_composition();
12417        d.insert("c");
12418        assert_eq!(d.source, "ab蚊c\n");
12419        d.undo();
12420        assert_eq!(d.source, "ab蚊\n");
12421        d.undo();
12422        assert_eq!(d.source, "ab\n");
12423        d.undo();
12424        assert_eq!(d.source, "\n");
12425    }
12426
12427    #[test]
12428    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
12429        let mut d = doc_with("compose_spurious", "\n");
12430        d.caret = 0;
12431        d.insert("a");
12432        d.end_composition(); // an IME unmarking unprompted
12433        d.insert("b");
12434        assert_eq!(d.source, "ab\n");
12435        d.undo();
12436        assert_eq!(d.source, "\n", "still one typed run");
12437    }
12438
12439    // ── the clipboard's rich flavor ──────────────────────────────────────────
12440
12441    #[test]
12442    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
12443        let mut d = doc_with("sel_inline", "a **bold** c\n");
12444        d.anchor = Some(2);
12445        d.caret = 10; // `**bold**`, inside the paragraph
12446        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
12447    }
12448
12449    #[test]
12450    fn a_whole_block_selection_keeps_its_paragraph() {
12451        let mut d = doc_with("sel_block", "a **bold** c\n");
12452        d.anchor = Some(0);
12453        d.caret = 12; // the entire paragraph
12454        assert_eq!(
12455            d.selection_html().as_deref(),
12456            Some("<p>a <strong>bold</strong> c</p>")
12457        );
12458    }
12459
12460    #[test]
12461    fn a_multi_block_selection_keeps_its_structure() {
12462        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
12463        d.select_all();
12464        let html = d.selection_html().expect("renders");
12465        assert!(html.contains("<p>para</p>"), "{html:?}");
12466        assert!(html.contains("<li>one</li>"), "{html:?}");
12467    }
12468
12469    #[test]
12470    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
12471        // The fragment `Head` is a paragraph standalone; the *document* says it
12472        // sits inside one block, so the wrapper is an artifact either way.
12473        let mut d = doc_with("sel_heading", "# Head line\n");
12474        d.anchor = Some(2);
12475        d.caret = 6;
12476        assert_eq!(d.selection_html().as_deref(), Some("Head"));
12477    }
12478
12479    #[test]
12480    fn no_selection_publishes_no_html() {
12481        let mut d = doc_with("sel_none", "a b\n");
12482        d.caret = 1;
12483        assert_eq!(d.selection_html(), None);
12484    }
12485
12486    #[test]
12487    fn pasting_html_converts_it_and_is_one_undo_step() {
12488        let mut d = doc_with("paste_html", "x\n");
12489        d.caret = 1;
12490        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
12491        assert_eq!(d.source, "xa **b** c\n");
12492        d.undo();
12493        assert_eq!(d.source, "x\n", "the whole paste, in one step");
12494    }
12495
12496    #[test]
12497    fn pasting_html_replaces_the_selection() {
12498        let mut d = doc_with("paste_html_sel", "keep drop\n");
12499        d.anchor = Some(5);
12500        d.caret = 9;
12501        assert!(d.paste_html("<em>new</em>"));
12502        assert_eq!(d.source, "keep *new*\n");
12503    }
12504
12505    #[test]
12506    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
12507        let mut d = doc_with("paste_html_bad", "x\n");
12508        d.caret = 1;
12509        // twig builds no table from HTML; raw `<table>` in prose is worse than
12510        // the plain flavor the caller still holds.
12511        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
12512        assert_eq!(d.source, "x\n", "declined edits nothing");
12513    }
12514
12515    #[test]
12516    fn copy_then_paste_round_trips_through_the_html_flavor() {
12517        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
12518        d.select_all();
12519        let html = d.selection_html().expect("renders");
12520        let mut into = doc_with("clip_round_dst", "\n");
12521        into.caret = 0;
12522        assert!(into.paste_html(&html));
12523        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
12524    }
12525
12526    #[test]
12527    fn moving_the_caret_starts_a_new_undo_group() {
12528        let mut d = doc_with("break", "\n");
12529        d.caret = 0;
12530        d.insert("a");
12531        d.insert("b"); // "ab\n", caret at 2
12532        d.move_left(false); // breaks the run
12533        d.insert("X"); // "aXb\n"
12534        assert_eq!(d.source, "aXb\n");
12535        d.undo();
12536        assert_eq!(
12537            d.source, "ab\n",
12538            "first undo removes only the post-move insert"
12539        );
12540        d.undo();
12541        assert_eq!(d.source, "\n", "second undo removes the earlier run");
12542    }
12543
12544    #[test]
12545    fn undo_reverses_a_format_toggle() {
12546        let mut d = doc_with("fmt_undo", "a word b\n");
12547        d.anchor = Some(2);
12548        d.caret = 6;
12549        d.toggle(InlineKind::Strong);
12550        assert_eq!(d.source, "a **word** b\n");
12551        d.undo();
12552        assert_eq!(d.source, "a word b\n");
12553    }
12554
12555    #[test]
12556    fn undo_back_to_the_saved_state_clears_dirty() {
12557        let mut d = doc_with("dirty_undo", "hello\n");
12558        assert!(!d.dirty);
12559        d.caret = 5;
12560        d.insert("!");
12561        assert!(d.dirty);
12562        d.undo();
12563        assert!(
12564            !d.dirty,
12565            "undoing to the saved source is not a modification"
12566        );
12567    }
12568
12569    #[test]
12570    fn a_new_edit_invalidates_redo() {
12571        let mut d = doc_with("redo_inv", "\n");
12572        d.caret = 0;
12573        d.insert("a");
12574        d.undo();
12575        d.insert("b"); // diverges — the redo of "a" is now gone
12576        d.redo();
12577        assert_eq!(d.source, "b\n");
12578    }
12579
12580    #[test]
12581    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
12582        let mut d = doc_with("can_undo", "hello\n");
12583        assert!(
12584            !d.can_undo() && !d.can_redo(),
12585            "a fresh document has no history"
12586        );
12587        d.caret = 5;
12588        d.insert("!");
12589        assert!(
12590            d.can_undo() && !d.can_redo(),
12591            "an edit is a step to take back"
12592        );
12593        d.undo();
12594        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
12595        d.redo();
12596        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
12597        d.undo();
12598        d.insert("?");
12599        assert!(
12600            d.can_undo() && !d.can_redo(),
12601            "a fresh edit ends the redo chain"
12602        );
12603        // A coalesced run over-counts steps — the bound is what a menu needs,
12604        // and it reconciles the moment twig reports the history empty.
12605        d.insert("a");
12606        d.insert("b");
12607        while d.can_undo() {
12608            d.undo();
12609        }
12610        assert_eq!(d.source, "hello\n");
12611        assert!(!d.can_undo());
12612        // A reading surface has nothing to undo, whatever the history holds.
12613        d.redo();
12614        d.set_read_only(true);
12615        assert!(!d.can_undo() && !d.can_redo());
12616    }
12617
12618    #[test]
12619    fn undo_on_empty_history_is_a_no_op() {
12620        let mut d = doc_with("undo_empty", "hi\n");
12621        d.undo();
12622        assert_eq!(d.source, "hi\n");
12623        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
12624    }
12625
12626    #[test]
12627    fn a_one_character_paste_is_its_own_undo_step() {
12628        for view in [View::Source, View::Wysiwyg] {
12629            let mut d = doc_in(view, "paste_step", "ab\n");
12630            d.caret = 0;
12631            d.insert("x");
12632            d.insert("y"); // a run of typing
12633            d.paste("z"); // one character, but pasted — not part of that run
12634            assert_eq!(d.source, "xyzab\n");
12635            d.undo();
12636            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
12637            assert_eq!(d.caret, 2, "and hands back the caret it found");
12638            d.undo();
12639            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
12640        }
12641    }
12642
12643    #[test]
12644    fn the_same_character_typed_still_joins_the_run() {
12645        // The other half of the pair: `z` is a keystroke here and a paste above,
12646        // and the two undo differently. Nothing about the *string* says which —
12647        // which is why provenance has to come from the door the caller uses.
12648        for view in [View::Source, View::Wysiwyg] {
12649            let mut d = doc_in(view, "typed_run", "ab\n");
12650            d.caret = 0;
12651            d.insert("x");
12652            d.insert("y");
12653            d.insert("z");
12654            d.undo();
12655            assert_eq!(d.source, "ab\n", "one run, one step");
12656        }
12657    }
12658
12659    #[test]
12660    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
12661        for view in [View::Source, View::Wysiwyg] {
12662            let mut d = doc_in(view, "undo_caret", "hello world\n");
12663            d.caret = 11; // standing at the end of "world", away from the edit
12664            d.edit(0, 5, "goodbye");
12665            assert_eq!(d.source, "goodbye world\n");
12666            d.undo();
12667            assert_eq!(d.source, "hello world\n");
12668            // The undone edit ends at offset 5; the user was at 11.
12669            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
12670        }
12671    }
12672
12673    #[test]
12674    fn undo_restores_the_selection_the_edit_replaced() {
12675        for view in [View::Source, View::Wysiwyg] {
12676            let mut d = doc_in(view, "undo_sel", "a word b\n");
12677            d.anchor = Some(2);
12678            d.caret = 6; // "word" selected
12679            d.insert("X");
12680            assert_eq!(d.source, "a X b\n");
12681            d.undo();
12682            assert_eq!(d.source, "a word b\n");
12683            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
12684        }
12685    }
12686
12687    #[test]
12688    fn redo_restores_the_caret_the_edit_left_behind() {
12689        for view in [View::Source, View::Wysiwyg] {
12690            let mut d = doc_in(view, "redo_caret", "hello world\n");
12691            d.caret = 11;
12692            d.edit(0, 5, "goodbye");
12693            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
12694            d.undo();
12695            d.redo();
12696            assert_eq!(d.source, "goodbye world\n");
12697            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
12698        }
12699    }
12700
12701    #[test]
12702    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
12703        for view in [View::Source, View::Wysiwyg] {
12704            let mut d = doc_in(view, "run_caret", "hi\n");
12705            d.caret = 2;
12706            d.insert("a");
12707            d.insert("b");
12708            d.insert("c");
12709            assert_eq!(d.source, "hiabc\n");
12710            d.undo();
12711            assert_eq!(d.source, "hi\n");
12712            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
12713            d.redo();
12714            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
12715        }
12716    }
12717
12718    #[test]
12719    fn undo_restores_the_caret_across_a_format_toggle() {
12720        // A toggle reaches twig without going through `splice`, so it has to
12721        // record its own step — miss it and every stack depth below it is off by
12722        // one, and undo starts handing back another edit's caret.
12723        for view in [View::Source, View::Wysiwyg] {
12724            let mut d = doc_in(view, "fmt_caret", "a word b\n");
12725            d.caret = 8;
12726            d.anchor = Some(2);
12727            d.caret = 6;
12728            d.toggle(InlineKind::Strong);
12729            assert_eq!(d.source, "a **word** b\n");
12730            d.undo();
12731            assert_eq!(d.source, "a word b\n");
12732            assert_eq!(
12733                d.selection(),
12734                Some((2, 6)),
12735                "the toggled selection comes back"
12736            );
12737        }
12738    }
12739
12740    #[test]
12741    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
12742        // The drift that would never announce itself: twig drops its redo stack
12743        // on any fresh edit, so a leaf redo entry that outlives it would restore
12744        // a caret from the timeline that edit abandoned.
12745        for view in [View::Source, View::Wysiwyg] {
12746            let mut d = doc_in(view, "redo_trunc", "hello world\n");
12747            d.caret = 11;
12748            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
12749            d.undo();
12750            assert_eq!(d.caret, 11);
12751            d.caret = 0;
12752            d.insert("X"); // diverges: A's redo is gone from twig
12753            assert_eq!(d.source, "Xhello world\n");
12754
12755            d.redo();
12756            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
12757            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
12758            d.undo();
12759            assert_eq!(d.source, "hello world\n");
12760            assert_eq!(
12761                d.caret, 0,
12762                "the surviving step's caret, not the dropped one"
12763            );
12764        }
12765    }
12766
12767    #[test]
12768    fn indent_and_outdent_move_the_caret_line_with_its_text() {
12769        for view in [View::Source, View::Wysiwyg] {
12770            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
12771            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
12772            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
12773            // Indentation the caret is standing *in* collapses to the line start
12774            // rather than dragging the caret into the text.
12775            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
12776            // A line with none to give back is left exactly as it was.
12777            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
12778            // Less than a full level gives back what it has.
12779            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
12780            // A tab is one level however many spaces it isn't.
12781            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
12782        }
12783    }
12784
12785    #[test]
12786    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
12787        // Why the level is two spaces and not the four both frontends type
12788        // today. Four is markdown's indented-code-block marker, so a Tab on a
12789        // paragraph would silently restyle it as code — a width that changes
12790        // what the document *means* isn't an indent. Pinned because the number
12791        // is the kind of thing a later list-aware pass would reach for.
12792        let mut d = doc_with("indent_kind", "hello\n");
12793        d.caret = 2;
12794        d.indent();
12795        assert_eq!(d.source, "  hello\n");
12796        assert!(
12797            d.nodes().iter().any(|n| n.kind == Kind::Para),
12798            "still prose after a Tab"
12799        );
12800        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
12801
12802        // The four-space level this replaces, for contrast: same text, and twig
12803        // reparses the paragraph into a code block.
12804        let mut wide = doc_with("indent_kind_4", "    hello\n");
12805        wide.build_visual(80);
12806        assert!(
12807            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
12808            "four spaces is a code block, not an indented paragraph"
12809        );
12810    }
12811
12812    #[test]
12813    fn indent_nests_a_list_item_under_its_parent() {
12814        // Tab indents a list item by its own marker width, landing its marker at
12815        // the parent's content column so twig reparses it as a nested list.
12816        for view in [View::Source, View::Wysiwyg] {
12817            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
12818            d.caret = 6; // on the second item
12819            d.indent();
12820            assert_eq!(d.source, "- a\n  - b\n");
12821            let lists = d
12822                .nodes()
12823                .iter()
12824                .filter(|n| n.kind == Kind::BulletList)
12825                .count();
12826            assert_eq!(lists, 2, "the indented item is a nested list");
12827        }
12828    }
12829
12830    #[test]
12831    fn indent_nests_an_ordered_item_at_its_marker_width() {
12832        // An ordered marker `1. ` is three columns wide, so a two-space step
12833        // (which nests a bullet) leaves it flat. Regression: Tab must use the
12834        // marker width, three, so the item actually nests — and the source
12835        // renumbers so the sub-list restarts at 1 and the outer list resumes.
12836        for view in [View::Source, View::Wysiwyg] {
12837            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
12838            d.caret = d.source.find('b').unwrap();
12839            d.indent();
12840            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
12841            let lists = d
12842                .nodes()
12843                .iter()
12844                .filter(|n| n.kind == Kind::OrderedList)
12845                .count();
12846            assert_eq!(lists, 2, "the indented item is a nested ordered list");
12847        }
12848    }
12849
12850    #[test]
12851    fn indent_leaves_a_lists_first_item_put() {
12852        // The first item of a list has no sibling above it to nest under, so Tab
12853        // is a no-op there — the marker stays at column zero rather than being
12854        // shoved into indentation twig can't read as a sub-list.
12855        for view in [View::Source, View::Wysiwyg] {
12856            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
12857            d.caret = 1; // on the FIRST item
12858            d.indent();
12859            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
12860            // The sibling below still nests, proving the guard is per-item.
12861            d.caret = d.source.find('b').unwrap();
12862            d.indent();
12863            assert_eq!(d.source, "- a\n  - b\n");
12864        }
12865    }
12866
12867    #[test]
12868    fn hidden_mode_keeps_typed_markup_literal() {
12869        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
12870        // twig escapes what would open markup, so the source is `\*hi\*` and the
12871        // AST is a plain string. Formatting is the commands' job in this mode.
12872        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
12873        d.insert("*hi*");
12874        assert_eq!(d.source, "\\*hi\\*");
12875        assert!(
12876            d.nodes()
12877                .iter()
12878                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
12879        );
12880    }
12881
12882    #[test]
12883    fn hidden_mode_escapes_a_line_start_block_marker() {
12884        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
12885        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
12886        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
12887        d.insert("# hi");
12888        assert_eq!(d.source, "\\# hi");
12889        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
12890    }
12891
12892    #[test]
12893    fn authoring_modes_keep_typed_markup_live() {
12894        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
12895        // (no escape), the same as source view — escaping is `None`'s alone, and
12896        // it's the axis, not the reveal, that decides.
12897        for (view, mode) in [
12898            (View::Wysiwyg, MarkupMode::Shortcuts),
12899            (View::Wysiwyg, MarkupMode::Full),
12900            (View::Source, MarkupMode::None),
12901        ] {
12902            let mut d = doc_in(view, "live_markup", "");
12903            d.set_markup_mode(mode);
12904            d.insert("*hi*");
12905            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
12906        }
12907    }
12908
12909    #[test]
12910    fn hidden_mode_overwrite_undoes_in_one_step() {
12911        // Typing over a selection escapes the replacement *and* stays a single
12912        // undo — the selection-delete and the literal insert fold together, so
12913        // one undo brings the whole selection back, like a plain overwrite.
12914        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
12915        d.anchor = Some(2);
12916        d.caret = 6; // "word"
12917        d.insert("*");
12918        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
12919        d.undo();
12920        assert_eq!(d.source, "a word b\n");
12921        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
12922    }
12923
12924    #[test]
12925    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
12926        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
12927        // the whole visual character, never stranding the hidden `\`.
12928        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
12929        d.insert("*");
12930        assert_eq!(d.source, "\\*");
12931        d.backspace();
12932        assert_eq!(d.source, "", "the escape backslash went with the *");
12933        // A *literal* backslash (source view, no escape) is an ordinary char.
12934        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
12935        s.caret = 3; // after `b`
12936        s.backspace();
12937        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
12938    }
12939
12940    #[test]
12941    fn hidden_mode_leaves_structural_markup_alone() {
12942        // Enter continues a bullet list by writing a real `- ` marker (an
12943        // `insert_raw`, not the typing path), so Hidden mode's escaping never
12944        // touches it — the list keeps working.
12945        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
12946        d.caret = 6;
12947        d.newline();
12948        d.insert("two");
12949        assert_eq!(d.source, "- item\n- two\n");
12950    }
12951
12952    #[test]
12953    fn markup_mode_defaults_to_none_and_round_trips() {
12954        // Diaryx's default is the clean `None` surface; a markup-fluent
12955        // frontend can climb the ladder, and the choice sticks.
12956        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
12957        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
12958        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
12959            d.set_markup_mode(mode);
12960            assert_eq!(d.markup_mode(), mode);
12961        }
12962    }
12963
12964    #[test]
12965    fn full_mode_reveals_only_the_caret_line() {
12966        // The mode's whole claim: the caret's line shows its raw delimiters and
12967        // every other line stays resolved. Two paragraphs with identical markup
12968        // so the only difference between the rows is where the caret is.
12969        let mut d = doc_in(
12970            View::Wysiwyg,
12971            "reveal_caret_line",
12972            "*one* here\n\n*two* there\n",
12973        );
12974        d.set_markup_mode(MarkupMode::Full);
12975
12976        caret_at(&mut d, "one");
12977        let rows = drawn_rows(&d);
12978        assert!(
12979            rows.iter().any(|r| r == "*one* here"),
12980            "caret's line raw: {rows:?}"
12981        );
12982        assert!(
12983            rows.iter().any(|r| r == "two there"),
12984            "other line resolved: {rows:?}"
12985        );
12986
12987        // Move to the other paragraph: the reveal follows, and the line just
12988        // left goes back to being resolved.
12989        caret_at(&mut d, "two");
12990        let rows = drawn_rows(&d);
12991        assert!(
12992            rows.iter().any(|r| r == "*two* there"),
12993            "caret's line raw: {rows:?}"
12994        );
12995        assert!(
12996            rows.iter().any(|r| r == "one here"),
12997            "left line resolved: {rows:?}"
12998        );
12999    }
13000
13001    #[test]
13002    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
13003        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
13004        // the same treatment as an emphasis's `*`: hidden while the caret is
13005        // elsewhere, shown in full where the caret lands. That falls out of
13006        // `delims` reading the bytes between the mark's span and its content
13007        // span, which is exactly `==🔴 ` and `==`, rather than from a table
13008        // of spellings — so the no-space form `==🟢green==` reveals right too.
13009        let mut d = doc_in(
13010            View::Wysiwyg,
13011            "reveal_coloured_mark",
13012            "a ==🔴 red== one\n\nb ==plain== two\n",
13013        );
13014        d.set_markup_mode(MarkupMode::Full);
13015
13016        caret_at(&mut d, "red");
13017        let rows = drawn_rows(&d);
13018        assert!(
13019            rows.iter().any(|r| r == "a ==🔴 red== one"),
13020            "the caret's line shows the colour it was written with: {rows:?}"
13021        );
13022        assert!(
13023            rows.iter().any(|r| r == "b plain two"),
13024            "and every other line stays resolved: {rows:?}"
13025        );
13026
13027        // Away from it, the emoji goes back to being markup — the reader sees
13028        // the words and the wash.
13029        caret_at(&mut d, "two");
13030        let rows = drawn_rows(&d);
13031        assert!(
13032            rows.iter().any(|r| r == "a red one"),
13033            "resolved again: {rows:?}"
13034        );
13035    }
13036
13037    #[test]
13038    fn hidden_modes_never_reveal_wherever_the_caret_is() {
13039        // The two rungs below `Full` share a rendering: delimiters stay hidden
13040        // even under the caret. `Shortcuts` differing from `None` only in what
13041        // typing does is exactly the point of splitting the axes.
13042        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
13043            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
13044            d.set_markup_mode(mode);
13045            caret_at(&mut d, "one");
13046            let rows = drawn_rows(&d);
13047            assert!(
13048                rows.iter().any(|r| r == "one here"),
13049                "{mode:?} hides: {rows:?}"
13050            );
13051            assert!(
13052                !rows.iter().any(|r| r.contains('*')),
13053                "{mode:?} shows no `*`: {rows:?}"
13054            );
13055        }
13056    }
13057
13058    #[test]
13059    fn revealed_delimiters_are_the_authors_own_spelling() {
13060        // Delimiters are re-read from the source rather than synthesized per
13061        // kind, so a line comes back spelled the way it was written: `_em_` does
13062        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
13063        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
13064        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
13065        d.set_markup_mode(MarkupMode::Full);
13066        caret_at(&mut d, "em");
13067        let rows = drawn_rows(&d);
13068        assert!(
13069            rows.iter().any(|r| r == body.trim_end()),
13070            "the revealed line is its own source: {rows:?}"
13071        );
13072    }
13073
13074    #[test]
13075    fn revealed_heading_shows_its_hashes() {
13076        // The `# ` marker is a block-level prefix, not an inline delimiter, so
13077        // it takes its own path — but it reveals on the same rule.
13078        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
13079        d.set_markup_mode(MarkupMode::Full);
13080
13081        caret_at(&mut d, "Title");
13082        assert!(
13083            drawn_rows(&d).iter().any(|r| r == "# Title"),
13084            "{:?}",
13085            drawn_rows(&d)
13086        );
13087
13088        caret_at(&mut d, "body");
13089        let rows = drawn_rows(&d);
13090        assert!(
13091            rows.iter().any(|r| r == "Title"),
13092            "hashes hidden again: {rows:?}"
13093        );
13094    }
13095
13096    #[test]
13097    fn revealed_delimiters_are_caret_stops() {
13098        // A delimiter that is drawn but can't be reached is worse than one
13099        // that's hidden: the mode exists so the markup can be *edited*. Every
13100        // revealed byte must be somewhere the caret can stand.
13101        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
13102        d.set_markup_mode(MarkupMode::Full);
13103        caret_at(&mut d, "em");
13104        let opener = d.source.find('*').unwrap();
13105        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
13106        assert!(
13107            d.vmap.is_stop(opener + 3),
13108            "the closing `*` is a caret stop"
13109        );
13110    }
13111
13112    #[test]
13113    fn setext_heading_reveals_nothing_across_its_newline() {
13114        // A setext heading's underline is on another line, so it is not the
13115        // caret line's to reveal — and emitting it would inject a `\n` glyph
13116        // that splits the row where the author wrote no break.
13117        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
13118        d.set_markup_mode(MarkupMode::Full);
13119        caret_at(&mut d, "Title");
13120        let rows = drawn_rows(&d);
13121        assert!(
13122            rows.iter().any(|r| r == "Title"),
13123            "title renders alone: {rows:?}"
13124        );
13125        assert!(
13126            !rows.iter().any(|r| r.contains('=')),
13127            "no underline leaks in: {rows:?}"
13128        );
13129    }
13130
13131    #[test]
13132    fn markup_mode_axes_split_the_ladder() {
13133        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
13134        // that authors markup but still hides it, and it's the only rung where
13135        // the two axes disagree.
13136        assert!(!MarkupMode::None.authors());
13137        assert!(!MarkupMode::None.reveals_caret_line());
13138        assert!(MarkupMode::Shortcuts.authors());
13139        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
13140        assert!(MarkupMode::Full.authors());
13141        assert!(MarkupMode::Full.reveals_caret_line());
13142    }
13143
13144    #[test]
13145    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
13146        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
13147        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
13148        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
13149        // nested bullet and `hello` stays prose: the file round-trips instead of
13150        // hiding a heading the user never asked for.
13151        for view in [View::Source, View::Wysiwyg] {
13152            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
13153            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
13154            d.indent();
13155            assert_eq!(d.source, "- hello\n  * \n");
13156            assert!(
13157                d.nodes().iter().all(|n| n.kind != Kind::Heading),
13158                "no heading"
13159            );
13160            // And it's genuinely a nested list, not a flat one.
13161            assert_eq!(
13162                d.nodes()
13163                    .iter()
13164                    .filter(|n| n.kind == Kind::BulletList)
13165                    .count(),
13166                2
13167            );
13168        }
13169    }
13170
13171    #[test]
13172    fn indenting_a_dash_item_with_content_keeps_its_dash() {
13173        // With content, `- x` can't be a setext underline, so there's nothing to
13174        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
13175        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
13176        d.caret = d.source.find('x').unwrap();
13177        d.indent();
13178        assert_eq!(d.source, "- hello\n  - x\n");
13179    }
13180
13181    #[test]
13182    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
13183        // The dash→`*` repair coalesces into the Tab, so a single undo restores
13184        // the whole pre-Tab state rather than stranding a half-collapsed doc.
13185        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
13186        d.caret = d.source.find("- \n").unwrap() + 2;
13187        d.indent();
13188        assert_eq!(d.source, "- hello\n  * \n");
13189        d.undo();
13190        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
13191    }
13192
13193    #[test]
13194    fn indent_leaves_a_nested_lists_first_item_put_too() {
13195        // The guard is about siblings, not depth: the first item of an *inner*
13196        // list (already nested under `a`) still has nothing before it at its own
13197        // level, so Tab can't take it deeper.
13198        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
13199        d.caret = d.source.find('b').unwrap();
13200        d.indent();
13201        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
13202        // But `c` (a sibling of `b`) nests under `b`.
13203        d.caret = d.source.find('c').unwrap();
13204        d.indent();
13205        assert_eq!(d.source, "- a\n  - b\n    - c\n");
13206    }
13207
13208    #[test]
13209    fn backspace_at_a_nested_item_start_outdents_it() {
13210        // Backspace with the caret right after a nested item's marker gives back
13211        // one level of nesting, the mirror of Tab — and renumbers the flattened
13212        // ordered list back to a clean run.
13213        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
13214        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
13215        d.backspace();
13216        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13217    }
13218
13219    #[test]
13220    fn backspace_at_a_top_level_item_start_strips_the_marker() {
13221        // At the outermost level there's no nesting left to give back, so the same
13222        // keystroke drops the bullet and leaves a plain paragraph.
13223        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
13224        d.caret = d.source.find('b').unwrap(); // right after `- `
13225        d.backspace();
13226        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
13227    }
13228
13229    #[test]
13230    fn backspace_mid_item_still_deletes_a_character() {
13231        // The list behaviour is armed only at the item's content start; anywhere
13232        // else Backspace is the ordinary character delete.
13233        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
13234        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
13235        d.backspace();
13236        assert_eq!(d.source, "- b\n");
13237    }
13238
13239    #[test]
13240    fn backspace_at_a_heading_start_strips_the_marker() {
13241        // The `# ` is markup the rich view hides, so Backspace over it takes the
13242        // whole marker and leaves a paragraph. Deleting a byte of it instead left
13243        // `#Title` — no longer a heading, with the hash now literal text the user
13244        // never typed and has to delete again.
13245        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
13246        d.caret = d.source.find('T').unwrap(); // right after `## `
13247        d.backspace();
13248        assert_eq!(d.source, "Title\n");
13249        assert_eq!(
13250            d.caret, 0,
13251            "the caret stays with the text it was in front of"
13252        );
13253    }
13254
13255    #[test]
13256    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
13257        // Only the heading's own marker goes — the quote (or list) it sits in is
13258        // untouched, exactly as un-heading it should be.
13259        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
13260        d.caret = d.source.find('T').unwrap();
13261        d.backspace();
13262        assert_eq!(d.source, "> Title\n");
13263    }
13264
13265    #[test]
13266    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
13267        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
13268        // behind would surface the same stray hash the marker delete just avoided.
13269        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
13270        d.caret = d.source.find('T').unwrap();
13271        d.backspace();
13272        assert_eq!(d.source, "Title\n");
13273        // And it's one edit: a single undo puts the whole heading back.
13274        d.undo();
13275        assert_eq!(d.source, "# Title #\n");
13276    }
13277
13278    #[test]
13279    fn backspace_mid_heading_still_deletes_a_character() {
13280        // The heading behaviour is armed only at the content's start; anywhere
13281        // else Backspace is the ordinary character delete.
13282        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
13283        d.caret = d.source.find('b').unwrap();
13284        d.backspace();
13285        assert_eq!(d.source, "# b\n");
13286    }
13287
13288    #[test]
13289    fn source_view_backspace_still_edits_the_heading_marker_literally() {
13290        // In source view the `# ` is text on the screen the user is deleting a
13291        // byte of, so it keeps its literal meaning — the same split the list
13292        // ladder and Enter draw between the two views.
13293        let mut d = doc_with("bsp_head_src", "# Title\n");
13294        d.caret = d.source.find('T').unwrap();
13295        d.backspace();
13296        assert_eq!(d.source, "#Title\n");
13297    }
13298
13299    #[test]
13300    fn outdent_unnests_an_ordered_item_in_one_press() {
13301        // Shift+Tab gives back exactly the marker width the indent added, so a
13302        // nested ordered item unnests in a single press, and the flattened list
13303        // renumbers back to a clean 1, 2, 3.
13304        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
13305        d.caret = d.source.find('b').unwrap();
13306        d.outdent();
13307        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
13308        let lists = d
13309            .nodes()
13310            .iter()
13311            .filter(|n| n.kind == Kind::OrderedList)
13312            .count();
13313        assert_eq!(lists, 1, "back to one flat list");
13314    }
13315
13316    #[test]
13317    fn table_insert_row_adds_a_row_below_the_caret() {
13318        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13319        d.caret = d.source.find('1').unwrap(); // in the body row
13320        d.table_insert_row(true);
13321        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
13322    }
13323
13324    #[test]
13325    fn table_insert_and_delete_column_at_the_caret() {
13326        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13327        d.caret = d.source.find('a').unwrap(); // column 0
13328        d.table_insert_column(true); // add a column to the right of `a`
13329        assert_eq!(
13330            d.source,
13331            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
13332        );
13333        d.caret = d.source.find('b').unwrap(); // now the third column
13334        d.table_delete_column();
13335        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
13336    }
13337
13338    // ── ragged formats ───────────────────────────────────────────────────────
13339    // No format spells every gesture. HTML writes the inline marks as a tag pair
13340    // and no heading, list, quote or link; Markdown spells five of the eight
13341    // marks — the highlight only because leaf parses with `highlight`, which is
13342    // why the question is asked with the extensions; djot spells all eight and
13343    // no in-cell break. leaf asks twig per
13344    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
13345    // op discover the fact on its own — one of them didn't.
13346
13347    /// An HTML document in the rich view, ready for a gesture.
13348    fn html_doc(body: &str) -> Doc {
13349        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
13350        d.view = View::Wysiwyg;
13351        d.build_visual(80);
13352        d
13353    }
13354
13355    #[test]
13356    fn a_table_gesture_leaves_an_html_table_alone() {
13357        // The regression this guard exists for. twig's table editor consults no
13358        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
13359        // HTML `<table>` as a *pipe table* and reported success: the whole
13360        // element replaced by `| a | b |`, silently, on one press of a toolbar
13361        // button. Every grid op went the same way.
13362        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
13363        // A table of named operations, which is what it looks like.
13364        #[allow(clippy::type_complexity)]
13365        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
13366            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
13367            ("delete row", &|d: &mut Doc| d.table_delete_row()),
13368            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
13369            ("delete column", &|d: &mut Doc| d.table_delete_column()),
13370            ("align", &|d: &mut Doc| {
13371                d.table_set_alignment(Alignment::Right)
13372            }),
13373            ("move row", &|d: &mut Doc| d.table_move_row(true)),
13374            ("move column", &|d: &mut Doc| d.table_move_column(true)),
13375        ];
13376        for (name, op) in ops {
13377            let mut d = html_doc(src);
13378            d.caret = d.source.find('a').unwrap();
13379            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
13380            op(&mut d);
13381            assert_eq!(d.source, src, "{name} rewrote an HTML table");
13382            assert!(
13383                !d.dirty,
13384                "{name} marked the document dirty without editing it"
13385            );
13386            assert!(d.status.is_some(), "{name} refused without saying why");
13387        }
13388    }
13389
13390    #[test]
13391    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
13392        // A task box is a form control in HTML and a footnote has no native
13393        // spelling at all — the two gestures twig 3.5 still spells nothing
13394        // for, now that a quote, a list, a link and an image print through
13395        // its renderer (see the test below).
13396        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13397        // A table of named operations, which is what it looks like.
13398        #[allow(clippy::type_complexity)]
13399        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
13400            ("task item", &|d: &mut Doc| d.toggle_task_item()),
13401            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
13402            ("footnote", &|d: &mut Doc| d.insert_footnote()),
13403        ];
13404        for (name, op) in ops {
13405            let mut d = html_doc(src);
13406            let at = d.source.find("Hello").unwrap();
13407            d.caret = at;
13408            d.anchor = Some(at + 5); // a selection, for the ops that want one
13409            op(&mut d);
13410            assert_eq!(d.source, src, "{name} edited an HTML document");
13411            assert!(
13412                !d.dirty,
13413                "{name} marked the document dirty without editing it"
13414            );
13415            let status = d.status.as_deref().unwrap_or("");
13416            assert!(
13417                status.contains("html"),
13418                "{name}: the refusal should name the format, got {status:?}"
13419            );
13420        }
13421    }
13422
13423    #[test]
13424    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
13425        // twig 3.5: where HTML has no marker alphabet it prints the fresh
13426        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
13427        // the paragraph as its item, an `<a>` or `<img>` over the selection.
13428        // Until then every one of these was a refusal; now each is a real
13429        // edit, which is what the toolbar's capability flags say too.
13430        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
13431        #[allow(clippy::type_complexity)]
13432        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
13433            (
13434                "quote",
13435                &|d: &mut Doc| d.toggle_blockquote(),
13436                "<blockquote>",
13437            ),
13438            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
13439            (
13440                "ordered list",
13441                &|d: &mut Doc| d.toggle_list(true),
13442                "<ol>\n<li>",
13443            ),
13444            (
13445                "link",
13446                &|d: &mut Doc| d.insert_link("https://example.dev"),
13447                "<a href=\"https://example.dev\">Hello</a>",
13448            ),
13449            (
13450                "image",
13451                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
13452                "<img alt=\"Hello\" src=\"pic.png\">",
13453            ),
13454        ];
13455        for (name, op, expect) in ops {
13456            let mut d = html_doc(src);
13457            let at = d.source.find("Hello").unwrap();
13458            d.caret = at;
13459            d.anchor = Some(at + 5);
13460            op(&mut d);
13461            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
13462            assert!(d.dirty, "{name}: a real edit");
13463            assert_eq!(
13464                d.status, None,
13465                "{name}: a supported gesture reports nothing"
13466            );
13467        }
13468    }
13469
13470    #[test]
13471    fn html_spells_a_heading_as_its_tag_pair() {
13472        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
13473        // along — the one block gesture whose HTML shape it can write. So ⌘2
13474        // in an HTML document is a real edit, and ⌘0 takes it back.
13475        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
13476        let mut d = html_doc(src);
13477        d.caret = d.source.find("Hello").unwrap();
13478        d.toggle_heading(2);
13479        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
13480        assert!(d.dirty);
13481        assert_eq!(d.status, None, "a supported gesture reports nothing");
13482        d.toggle_heading(2);
13483        assert_eq!(d.source, src, "the same level again is back to a paragraph");
13484    }
13485
13486    #[test]
13487    fn html_spells_the_inline_marks_and_the_rule() {
13488        // The other half, and why one per-document flag stopped being enough:
13489        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
13490        // already emits and the parser reads straight back as the same mark —
13491        // and the rule button writes an `<hr>`. Refusing these on the old
13492        // "HTML is parse-only" reading would now be leaf's own limitation.
13493        let mut d = html_doc("<p>Hello world</p>\n");
13494        let at = d.source.find("world").unwrap();
13495        d.caret = at;
13496        d.anchor = Some(at + 5);
13497        d.toggle(InlineKind::Strong);
13498        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
13499        assert!(d.dirty);
13500        assert_eq!(d.status, None, "a supported gesture reports nothing");
13501
13502        // And off again — the toggle reverses, which is the property that makes
13503        // authoring in HTML worth offering rather than a one-way trip.
13504        d.toggle(InlineKind::Strong);
13505        assert_eq!(d.source, "<p>Hello world</p>\n");
13506
13507        let mut d = html_doc("<p>Hello world</p>\n");
13508        d.caret = d.source.find("world").unwrap();
13509        d.insert_thematic_break();
13510        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
13511    }
13512
13513    #[test]
13514    fn a_mark_the_format_cannot_spell_arms_nothing() {
13515        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
13516        // sticky mark for the next text typed. Guarding only the twig call
13517        // leaves that path live, promising a mark the gesture will not write and
13518        // then swallowing the error inside `insert`.
13519        //
13520        // Markdown carries this, on the superscript now rather than on the
13521        // highlight: `^x^` is text there in any configuration, whereas twig
13522        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
13523        // which every leaf document does.
13524        let mut d = doc_with("mark", "Hello world\n");
13525        d.view = View::Wysiwyg;
13526        d.build_visual(80);
13527        d.caret = d.source.find("world").unwrap();
13528        d.toggle(InlineKind::Superscript);
13529        assert!(d.pending_marks.is_empty(), "no mark should be armed");
13530        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
13531        d.insert("X");
13532        assert_eq!(d.source, "Hello Xworld\n");
13533    }
13534
13535    #[test]
13536    fn markdown_authors_a_highlight_and_a_strikethrough() {
13537        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
13538        // write. `==x==` is authorable because leaf's own `parse_extensions`
13539        // turns `highlight` on — twig will only mint bytes this editor's reparse
13540        // reads back — and `~~x~~` because GFM strikethrough is parsed by
13541        // default, so the refusal there was never right for any leaf document.
13542        for (kind, marked) in [
13543            (InlineKind::Mark, "a ==word== b\n"),
13544            (InlineKind::Delete, "a ~~word~~ b\n"),
13545        ] {
13546            let mut d = doc_with("author_mark", "a word b\n");
13547            d.anchor = Some(2);
13548            d.caret = 6;
13549            d.toggle(kind);
13550            assert_eq!(d.source, marked, "{kind:?}");
13551            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
13552            assert!(d.dirty, "{kind:?}");
13553            // The region stays selected, so the second press reverses it — the
13554            // property that separates authoring from a one-way trip.
13555            d.toggle(kind);
13556            assert_eq!(d.source, "a word b\n", "{kind:?}");
13557        }
13558    }
13559
13560    #[test]
13561    fn an_authored_highlight_reads_back_as_a_mark() {
13562        // The round trip the extension gate exists to protect: what the toggle
13563        // writes, the reparse must read back as a `mark` rather than as two
13564        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
13565        // rebuilt map rather than from the source text.
13566        let mut d = doc_with("mark_roundtrip", "a word b\n");
13567        d.view = View::Wysiwyg;
13568        d.build_visual(80);
13569        d.anchor = Some(2);
13570        d.caret = 6;
13571        d.toggle(InlineKind::Mark);
13572        assert_eq!(d.source, "a ==word== b\n");
13573        d.build_visual(80);
13574        let w = d
13575            .vmap
13576            .rows
13577            .iter()
13578            .flat_map(|r| r.glyphs.iter())
13579            .find(|g| g.ch == 'w')
13580            .expect("the highlighted word");
13581        assert_eq!(w.style.role, crate::Role::Mark(None));
13582    }
13583
13584    #[test]
13585    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
13586        // The three states of one gesture, in the order a palette is pressed:
13587        // an uncoloured highlight takes the prefix, a coloured one has it
13588        // replaced, and `None` takes it away with the space that was part of the
13589        // spelling.
13590        let mut d = doc_with("mark_colour", "a ==word== b\n");
13591        d.caret = d.source.find("word").unwrap();
13592        d.set_mark_color(Some(MarkColor::Red));
13593        assert_eq!(d.source, "a ==🔴 word== b\n");
13594        assert_eq!(d.status, None);
13595        assert!(d.dirty);
13596
13597        d.set_mark_color(Some(MarkColor::Blue));
13598        assert_eq!(d.source, "a ==🔵 word== b\n");
13599
13600        d.set_mark_color(None);
13601        assert_eq!(d.source, "a ==word== b\n");
13602    }
13603
13604    #[test]
13605    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
13606        // The prefix is written *before* the word, so an offset in the word has
13607        // to ride its width — a caret that stayed put would be a caret that
13608        // walked backwards through the text it was standing in.
13609        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
13610        let word = d.source.find("word").unwrap();
13611        d.caret = word + 2; // between `wo` and `rd`
13612        d.set_mark_color(Some(MarkColor::Red));
13613        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
13614
13615        // And back the other way when the prefix goes.
13616        d.set_mark_color(None);
13617        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
13618    }
13619
13620    #[test]
13621    fn the_colour_at_the_caret_is_what_the_palette_lights() {
13622        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
13623        d.caret = d.source.find("red").unwrap();
13624        assert!(d.caret_in_mark());
13625        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
13626
13627        d.caret = d.source.find("plain").unwrap();
13628        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
13629        assert_eq!(d.mark_color_at_caret(), None);
13630
13631        d.caret = d.source.find(" and ").unwrap() + 2;
13632        assert!(!d.caret_in_mark());
13633        assert_eq!(d.mark_color_at_caret(), None);
13634    }
13635
13636    #[test]
13637    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
13638        // The gesture colours a highlight that exists; it does not make one.
13639        // Two presses is the price of a coloured highlight from bare text, and
13640        // the reason is undo — one press that spliced twice would take two
13641        // presses to take back.
13642        let mut d = doc_with("mark_colour_none", "a word b\n");
13643        d.caret = d.source.find("word").unwrap();
13644        d.set_mark_color(Some(MarkColor::Red));
13645        assert_eq!(d.source, "a word b\n");
13646        assert!(d.status.is_some(), "it should say why");
13647        assert!(!d.dirty);
13648
13649        // Clearing where there is nothing to clear is the same refusal, not a
13650        // quiet success — the caret is in no highlight either way.
13651        d.status = None;
13652        d.set_mark_color(None);
13653        assert_eq!(d.source, "a word b\n");
13654        assert!(d.status.is_some());
13655    }
13656
13657    #[test]
13658    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
13659        // twig answers this one *successfully* with a `Change` describing some
13660        // earlier edit, so a caller that trusted the change would jump the caret
13661        // to wherever that was. Core answers it before asking.
13662        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
13663        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
13664        d.caret = d.source.find("word").unwrap();
13665        let (source, caret) = (d.source.clone(), d.caret);
13666        d.set_mark_color(None);
13667        assert_eq!(d.source, source);
13668        assert_eq!(
13669            d.caret, caret,
13670            "the caret must not ride a change that isn't one"
13671        );
13672        assert_eq!(d.status, None, "and it is not an error either");
13673    }
13674
13675    #[test]
13676    fn djot_spells_the_highlight_and_not_its_colour() {
13677        // The reason the palette is its own capability rather than the Highlight
13678        // button's: `{=word=}` is a highlight djot writes happily, and there is
13679        // no djot spelling for a colour on it.
13680        assert!(Capabilities::of(Format::Djot).mark);
13681        assert!(!Capabilities::of(Format::Djot).mark_color);
13682        assert!(Capabilities::of(Format::Markdown).mark_color);
13683
13684        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
13685        d.caret = d.source.find("word").unwrap();
13686        assert!(
13687            d.caret_in_mark(),
13688            "the caret is in a highlight all the same"
13689        );
13690        d.set_mark_color(Some(MarkColor::Red));
13691        assert_eq!(d.source, "a {=word=} b\n");
13692        assert!(
13693            d.status.as_deref().unwrap_or("").contains("djot"),
13694            "and the refusal names the document's format: {:?}",
13695            d.status
13696        );
13697    }
13698
13699    #[test]
13700    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
13701        // The round trip that matters for a palette: the bytes twig writes are
13702        // bytes its own reparse reads back as a colour, so the swatch that was
13703        // pressed is the swatch that lights afterwards.
13704        let mut d = doc_with("mark_colour_undo", "a word b\n");
13705        d.anchor = Some(2);
13706        d.caret = 6;
13707        d.toggle(InlineKind::Mark);
13708        d.caret = d.source.find("word").unwrap();
13709        d.set_mark_color(Some(MarkColor::Green));
13710        assert_eq!(d.source, "a ==🟢 word== b\n");
13711        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
13712
13713        // One splice, one step: the colour comes off and the highlight stays.
13714        d.undo();
13715        assert_eq!(d.source, "a ==word== b\n");
13716        d.undo();
13717        assert_eq!(d.source, "a word b\n");
13718    }
13719
13720    #[test]
13721    fn every_colour_leaf_names_is_one_twig_writes() {
13722        // The two enums are one vocabulary, and this is what says so: each of
13723        // leaf's colours writes an emoji twig's reparse reads back as *that*
13724        // colour, so `twig_mark_color`'s table cannot quietly pair red with
13725        // orange.
13726        for color in MarkColor::ALL {
13727            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
13728            d.caret = d.source.find("word").unwrap();
13729            d.set_mark_color(Some(color));
13730            assert_eq!(d.status, None, "{color:?}");
13731            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
13732        }
13733    }
13734
13735    #[test]
13736    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
13737        // The two presses a coloured highlight is made of, in the state the
13738        // first one leaves: `toggle` selects the whole `==word==` and puts the
13739        // caret one past the closing `==`, which is *not* in the mark. Asking at
13740        // the caret alone would refuse to colour the highlight just written —
13741        // the selection's start is what answers.
13742        let mut d = doc_with("mark_colour_fresh", "a word b\n");
13743        d.anchor = Some(2);
13744        d.caret = 6;
13745        d.toggle(InlineKind::Mark);
13746        assert_eq!(d.source, "a ==word== b\n");
13747        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
13748
13749        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
13750        d.set_mark_color(Some(MarkColor::Yellow));
13751        assert_eq!(d.source, "a ==🟡 word== b\n");
13752        assert_eq!(d.status, None);
13753    }
13754
13755    #[test]
13756    fn one_press_highlights_a_selection_and_colours_it() {
13757        // What a toolbar swatch means over a plain selection, and the undo it
13758        // has to have: one press, one step. Two steps would leave an uncoloured
13759        // highlight behind on the way back, which is a state the author never
13760        // asked for and never saw.
13761        let mut d = doc_with("highlight_one", "a word b\n");
13762        d.anchor = Some(2);
13763        d.caret = 6;
13764        d.highlight(Some(MarkColor::Purple));
13765        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
13766        assert_eq!(d.status, None);
13767
13768        d.undo();
13769        assert_eq!(d.source, "a word b\n", "one press, one undo");
13770    }
13771
13772    #[test]
13773    fn one_press_on_an_existing_highlight_only_recolours_it() {
13774        // The other half: inside a highlight there is nothing to make, so the
13775        // compound is the plain gesture and the text is untouched.
13776        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
13777        d.caret = d.source.find("word").unwrap();
13778        d.highlight(Some(MarkColor::Blue));
13779        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
13780        d.undo();
13781        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
13782    }
13783
13784    #[test]
13785    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
13786        // `None` means "no colour", and over bare text that is the Highlight
13787        // button's own job. The fold must not happen here — there is no second
13788        // splice, and folding would take the *previous* edit into this one.
13789        let mut d = doc_with("highlight_none", "a word b and more\n");
13790        d.caret = d.source.find("more").unwrap() + 4; // after "more"
13791        d.insert("!"); // an earlier edit for a wrong fold to swallow
13792        d.anchor = Some(2);
13793        d.caret = 6;
13794        d.highlight(None);
13795        assert_eq!(d.source, "a ==word== b and more!\n");
13796
13797        d.undo();
13798        assert_eq!(
13799            d.source, "a word b and more!\n",
13800            "only the highlight came off"
13801        );
13802        d.undo();
13803        assert_eq!(
13804            d.source, "a word b and more\n",
13805            "and the edit before it survived"
13806        );
13807    }
13808
13809    #[test]
13810    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
13811        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
13812        // a colour cannot be armed with it — so the compound declines rather
13813        // than leaving half a promise.
13814        let mut d = doc_with("highlight_bare", "a word b\n");
13815        d.caret = 4;
13816        d.highlight(Some(MarkColor::Red));
13817        assert_eq!(d.source, "a word b\n");
13818        assert!(d.pending_marks.is_empty(), "and nothing armed");
13819        assert!(d.status.is_some());
13820    }
13821
13822    #[test]
13823    fn a_read_only_document_takes_no_colour() {
13824        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
13825        d.caret = d.source.find("word").unwrap();
13826        d.set_read_only(true);
13827        d.set_mark_color(Some(MarkColor::Red));
13828        assert_eq!(d.source, "a ==word== b\n");
13829    }
13830
13831    #[test]
13832    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
13833        // The other door into `toggle`: no selection, so nothing reaches twig
13834        // until `insert` realises the armed mark. It is armed now — the guard
13835        // above asks `Doc::supports`, which asks with the extensions — and what
13836        // it writes is the same `==…==`.
13837        let mut d = doc_with("sticky_mark", "xy\n");
13838        d.caret = 1;
13839        d.toggle(InlineKind::Mark);
13840        assert!(d.pending_marks.contains(InlineKind::Mark));
13841        d.insert("Z");
13842        assert_eq!(d.source, "x==Z==y\n");
13843    }
13844
13845    #[test]
13846    fn html_documents_still_take_typed_text() {
13847        // The guard covers *markup* gestures and must not touch plain editing:
13848        // twig's splicer is language-neutral, and typing into an HTML document
13849        // is the thing that does work today.
13850        let mut d = html_doc("<p>Hello world</p>\n");
13851        d.caret = d.source.find("world").unwrap();
13852        d.insert("big ");
13853        assert_eq!(d.source, "<p>Hello big world</p>\n");
13854        assert!(d.dirty);
13855        d.backspace();
13856        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
13857        d.undo();
13858        d.undo();
13859        assert_eq!(d.source, "<p>Hello world</p>\n");
13860    }
13861
13862    #[test]
13863    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
13864        // `authorable` only separates "there is a door in" from "there is not",
13865        // and HTML is on the near side of that line — which is exactly why a
13866        // toolbar must not be built from it.
13867        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
13868        assert!(html.authorable());
13869        assert!(
13870            !Doc::from_source("<r>x</r>".into(), Format::Xml)
13871                .unwrap()
13872                .authorable()
13873        );
13874
13875        let caps = html.capabilities();
13876        assert!(caps.bold && caps.italic && caps.code && caps.mark);
13877        assert!(caps.thematic_break && caps.cell_line_break);
13878        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
13879        // quote, a list, a code block's language, a link and an image — each
13880        // printed as a fresh node where HTML has no marker to rewrite. A task
13881        // box is a form control and a footnote has no spelling, so those two
13882        // are what keeps the record ragged.
13883        assert!(caps.heading && caps.blockquote && caps.bullet_list);
13884        assert!(caps.link && caps.image && caps.code_language);
13885        assert!(!caps.task && !caps.footnote);
13886        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
13887        // twig's table editor would happily re-emit as `| a | b |`.
13888        assert!(!caps.table);
13889
13890        // The two lightweight formats spell everything leaf offers — and still
13891        // differ from each other, which is the other half of why one boolean
13892        // can't serve.
13893        for fmt in [Format::Markdown, Format::Djot] {
13894            let caps = Capabilities::of(fmt);
13895            assert!(
13896                caps.heading && caps.blockquote && caps.ordered_list,
13897                "{fmt:?}"
13898            );
13899            assert!(
13900                caps.task && caps.link && caps.image && caps.table,
13901                "{fmt:?}"
13902            );
13903        }
13904        // Both spell the highlight and the strikethrough: djot natively, and
13905        // Markdown because `Capabilities` asks with `parse_extensions` rather
13906        // than with twig's defaults — `==x==` is text under those, and a mark
13907        // under the `highlight` leaf always parses with.
13908        for fmt in [Format::Markdown, Format::Djot] {
13909            let caps = Capabilities::of(fmt);
13910            assert!(caps.mark && caps.strike, "{fmt:?}");
13911        }
13912        // What still separates them, now that the highlight doesn't: djot has
13913        // no in-cell break, and Markdown spells neither of the scripts.
13914        assert!(Capabilities::of(Format::Djot).superscript);
13915        assert!(!Capabilities::of(Format::Markdown).superscript);
13916        assert!(Capabilities::of(Format::Markdown).cell_line_break);
13917        assert!(!Capabilities::of(Format::Djot).cell_line_break);
13918
13919        // A parse-only format answers no to every one of them, so the coarse
13920        // predicate and the record agree there.
13921        let caps = Capabilities::of(Format::Xml);
13922        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
13923    }
13924
13925    #[test]
13926    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
13927        // The guard exists to name the *document's* format rather than twig's
13928        // internals, so the message has to survive being one leaf writes itself.
13929        // Checked against a gesture twig also refuses, since that is the pair
13930        // most at risk of drifting apart — the task box, once the code
13931        // language stopped being one (twig 3.5).
13932        let mut d = html_doc("<p>Hello</p>\n");
13933        d.caret = d.source.find("Hello").unwrap();
13934        d.toggle_task_item();
13935        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
13936        assert!(!d.dirty);
13937    }
13938
13939    #[test]
13940    fn table_set_alignment_respells_the_delimiter() {
13941        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
13942        d.caret = d.source.find('b').unwrap();
13943        d.table_set_alignment(Alignment::Right);
13944        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
13945    }
13946
13947    #[test]
13948    fn each_empty_table_cell_has_its_own_editable_home() {
13949        // Regression: an empty cell has no twig content_span, so both cells of a
13950        // `|  |  |` row collapsed onto the row's start (before the first `│`).
13951        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
13952        // tell the cells apart. Each empty cell must now have a distinct home
13953        // inside it.
13954        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
13955        let (c0, c1) = {
13956            let cells = &d.vmap.tables[0].grid[1].cells;
13957            (cells[0].start, cells[1].start)
13958        };
13959        assert!(
13960            c0 < c1,
13961            "the two empty cells have distinct homes: {c0} < {c1}"
13962        );
13963        d.caret = c0;
13964        d.insert("x");
13965        assert_eq!(
13966            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
13967            "typed inside the cell"
13968        );
13969    }
13970
13971    #[test]
13972    fn arrows_step_into_each_empty_table_cell() {
13973        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
13974        let (c0, c1) = {
13975            let cells = &d.vmap.tables[0].grid[1].cells;
13976            (cells[0].start, cells[1].start)
13977        };
13978        d.caret = d.source.find('b').unwrap(); // in the header's second cell
13979        let mut seen = std::collections::HashSet::new();
13980        for _ in 0..6 {
13981            d.move_right(false);
13982            seen.insert(d.caret);
13983        }
13984        assert!(
13985            seen.contains(&c0),
13986            "right arrow reaches the first empty cell"
13987        );
13988        assert!(
13989            seen.contains(&c1),
13990            "right arrow reaches the second empty cell"
13991        );
13992    }
13993
13994    #[test]
13995    fn table_op_off_a_table_is_a_no_op_with_a_status() {
13996        let mut d = doc_with("tbl_none", "just text\n");
13997        d.caret = 3;
13998        d.table_insert_row(true);
13999        assert_eq!(d.source, "just text\n", "nothing changed");
14000        assert!(d.status.is_some(), "a status explains why");
14001        assert!(!d.caret_in_table());
14002    }
14003
14004    #[test]
14005    fn enter_in_an_ordered_list_renumbers_the_following_items() {
14006        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
14007        // the renumber pass keeps them sequential, matching what the view draws.
14008        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
14009        d.caret = d.source.find('a').unwrap() + 1; // end of item a
14010        d.newline();
14011        d.insert("x");
14012        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
14013    }
14014
14015    #[test]
14016    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
14017        for view in [View::Source, View::Wysiwyg] {
14018            let mut d = doc_in(view, "outdent_noop", "hello\n");
14019            d.caret = 2;
14020            d.outdent();
14021            assert_eq!(d.source, "hello\n");
14022            assert!(!d.dirty, "a no-op is not a modification");
14023            d.undo();
14024            assert_eq!(
14025                d.status.as_deref(),
14026                Some("nothing to undo"),
14027                "spends no undo step"
14028            );
14029            assert_eq!(d.source, "hello\n");
14030        }
14031    }
14032
14033    #[test]
14034    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
14035        for view in [View::Source, View::Wysiwyg] {
14036            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
14037            d.anchor = Some(0);
14038            d.caret = 7; // through "two"
14039            d.indent();
14040            assert_eq!(
14041                d.source, "  one\n\n  two\n",
14042                "the blank line keeps no trailing pad"
14043            );
14044            // Selected, so a second Tab lands on the same lines rather than on
14045            // whatever the shifted offsets now cover.
14046            assert_eq!(d.selection(), Some((0, 12)));
14047            d.indent();
14048            assert_eq!(d.source, "    one\n\n    two\n");
14049        }
14050    }
14051
14052    #[test]
14053    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
14054        for view in [View::Source, View::Wysiwyg] {
14055            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
14056            d.anchor = Some(0);
14057            d.caret = 15;
14058            d.outdent();
14059            assert_eq!(d.source, "two\none\nnone\n");
14060        }
14061    }
14062
14063    #[test]
14064    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
14065        for view in [View::Source, View::Wysiwyg] {
14066            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
14067            d.anchor = Some(0);
14068            d.caret = 7;
14069            d.indent();
14070            assert_eq!(d.source, "  one\n\n  two\n");
14071            d.undo();
14072            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
14073            assert_eq!(
14074                d.selection(),
14075                Some((0, 7)),
14076                "with the selection it was aimed at"
14077            );
14078            d.redo();
14079            assert_eq!(d.source, "  one\n\n  two\n");
14080            assert_eq!(
14081                d.selection(),
14082                Some((0, 12)),
14083                "redo replays the caret the indent placed, not the one splice left"
14084            );
14085        }
14086    }
14087
14088    #[test]
14089    fn vertical_motion_keeps_the_column() {
14090        let mut d = doc_with("move", "abcd\nef\n");
14091        d.caret = 3; // "abc|d" on row 0, col 3
14092        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
14093        assert_eq!(d.caret, 7); // just after "ef"
14094    }
14095
14096    // ── goal column ──────────────────────────────────────────────────────────
14097
14098    #[test]
14099    fn vertical_motion_goal_column_survives_a_short_line() {
14100        // Regression: re-deriving the column from the clamped position on
14101        // every step permanently forgets it once a short line clamps it.
14102        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
14103        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
14104        assert_eq!(
14105            g("abcd|ef\nxy\nghijkl\n", |d| {
14106                d.move_down(false); // clamps to end of "xy"
14107                d.move_down(false); // restores col 4 on the long line
14108            }),
14109            "abcdef\nxy\nghij|kl\n"
14110        );
14111    }
14112
14113    #[test]
14114    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
14115        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
14116        assert_eq!(d.goal_col, None);
14117        d.caret = 4; // row 0, col 4
14118        d.move_down(false); // clamps into "xy"; goal stays the original col
14119        assert_eq!(d.goal_col, Some(4));
14120        assert_eq!(d.caret_pos(), (1, 2));
14121
14122        // A horizontal motion drops the goal column...
14123        d.move_left(false);
14124        assert_eq!(d.goal_col, None);
14125
14126        // ...so the next vertical motion picks up the *new* column (1), not
14127        // the stale one (4).
14128        d.move_down(false);
14129        assert_eq!(d.goal_col, Some(1));
14130        assert_eq!(d.caret_pos(), (2, 1));
14131    }
14132
14133    #[test]
14134    fn editing_clears_the_goal_column() {
14135        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
14136        d.caret = 4;
14137        d.move_down(false);
14138        assert_eq!(d.goal_col, Some(4));
14139        d.insert("Z");
14140        assert_eq!(d.goal_col, None);
14141    }
14142
14143    #[test]
14144    fn vertical_motion_on_an_empty_document_is_a_no_op() {
14145        let mut d = doc_with("empty_vert", "");
14146        d.move_down(false);
14147        assert_eq!(d.caret, 0);
14148        d.move_up(false);
14149        assert_eq!(d.caret, 0);
14150    }
14151
14152    // ── the document's edges ─────────────────────────────────────────────────
14153
14154    #[test]
14155    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
14156        // The reproduction, and the disagreement: Down on the last line ran to
14157        // the end of the document in the source view — by accident, an
14158        // out-of-range row clamping to the end of the string — and did nothing
14159        // whatever in the view leaf opens in. One rule now, in both.
14160        for (view, tag) in VIEWS {
14161            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
14162            d.caret = 1;
14163            d.move_down(false);
14164            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
14165            d.move_up(false);
14166            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
14167        }
14168    }
14169
14170    #[test]
14171    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
14172        // Down off the bottom is a motion like any other, so it latches a goal
14173        // column — and Up comes back to the column the caret left, not to the
14174        // one the document's end happened to be in.
14175        for (view, tag) in VIEWS {
14176            let gap = if view == View::Source { "\n" } else { "\n\n" };
14177            let src = format!("abcdef{gap}ghijkl");
14178            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
14179            d.caret = 2; // row 0, col 2
14180            d.move_down(false);
14181            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
14182            d.move_down(false);
14183            assert_eq!(
14184                d.caret,
14185                src.len(),
14186                "{tag}: Down off the bottom reaches the end"
14187            );
14188            d.move_up(false);
14189            assert_eq!(
14190                d.caret_pos().1,
14191                2,
14192                "{tag}: Up returns to the column Down left"
14193            );
14194        }
14195    }
14196
14197    #[test]
14198    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
14199        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
14200        // Up that did nothing still armed a goal column, and the next Down aimed
14201        // at a column the caret had never been in.
14202        for (view, tag) in VIEWS {
14203            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
14204            d.caret = 0;
14205            d.move_up(false);
14206            assert_eq!(d.caret, 0, "{tag}: already at the start");
14207            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
14208
14209            d.caret = d.source.len();
14210            d.move_down(false);
14211            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
14212            assert_eq!(
14213                d.goal_col, None,
14214                "{tag}: a no-op Down latched a goal column"
14215            );
14216        }
14217    }
14218
14219    // ── soft wrap ────────────────────────────────────────────────────────────
14220    // Every other test here builds the map at 80 columns, where no fixture is
14221    // long enough to fold. A wrap is where one offset belongs to two rows at
14222    // once, and it broke everything that asks the caret what row it is on.
14223
14224    /// The wrapped fixture these cases share, folded at 12 columns into
14225    /// `one two ` / `three four ` / `five six ` / `seven eight`.
14226    fn wrapped_doc(name: &str) -> Doc {
14227        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
14228        d.build_visual(12);
14229        d
14230    }
14231
14232    #[test]
14233    fn home_and_end_work_from_a_wrapped_row() {
14234        // The reproduction: offset 19 is the `f` of "five", the first character
14235        // of the third row — and also the offset the second row ends at. It
14236        // resolved to the *second* row, so End aimed at a place the caret was
14237        // already in and did nothing, while Home walked backwards onto a row the
14238        // caret had left.
14239        let mut d = wrapped_doc("wrap_home_end");
14240        d.caret = 19;
14241        assert_eq!(
14242            d.caret_pos(),
14243            (2, 0),
14244            "the wrap boundary opens the third row"
14245        );
14246        d.move_end(false);
14247        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
14248        d.move_home(false);
14249        assert_eq!(d.caret, 19, "Home left the row the caret was on");
14250    }
14251
14252    #[test]
14253    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
14254        // The row's end is the last offset that is only ever its own: the offset
14255        // past it opens the row below, and aiming there would send a second
14256        // press on to *that* row's end, and a third to the next — End walking
14257        // down the paragraph rather than sitting where it landed.
14258        let mut d = wrapped_doc("wrap_end_twice");
14259        d.caret = 12; // inside "three", on the second row
14260        d.move_end(false);
14261        assert_eq!(
14262            d.caret, 18,
14263            "the end of `three four`, before the space the wrap ate"
14264        );
14265        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
14266        d.move_end(false);
14267        assert_eq!(d.caret, 18, "a second End moved the caret");
14268        d.move_home(false);
14269        assert_eq!(d.caret, 8, "Home takes the row's own start");
14270    }
14271
14272    #[test]
14273    fn vertical_motion_crosses_a_soft_wrap() {
14274        // Down aimed at the row below's column 0, an offset that resolved *up*
14275        // to the row above's end — so it landed on the offset it already had and
14276        // the caret could never leave a paragraph's first row.
14277        let mut d = wrapped_doc("wrap_down");
14278        d.caret = 0;
14279        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
14280            d.move_down(false);
14281            assert_eq!(d.caret, want, "Down stalled");
14282            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
14283        }
14284        d.move_down(false);
14285        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
14286
14287        // ...and back up, one row per press. The goal column is the end of the
14288        // last row, past every other row's width, so each press clamps to the
14289        // row's own last offset rather than to the one that opens the next.
14290        let mut d = wrapped_doc("wrap_up");
14291        d.caret = 39;
14292        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
14293            d.move_up(false);
14294            assert_eq!(d.caret, want, "Up stalled");
14295            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
14296        }
14297    }
14298
14299    #[test]
14300    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
14301        // The kills take the same line Home and End do, so in WYSIWYG they take
14302        // the visual row — and a soft wrap has no newline in it to delete, so
14303        // nothing is joined by reaching the end of one.
14304        let mut d = wrapped_doc("wrap_kill");
14305        d.caret = 19; // the `f` of "five", opening the third row
14306        d.delete_to_line_end();
14307        // The space the wrap ate goes with the row it was drawn on: sparing it
14308        // would leave "four  seven", two spaces where the row had been.
14309        assert_eq!(d.source, "one two three four seven eight");
14310
14311        // Backwards from the row's last caret position — which is *before* that
14312        // space, so this one survives, being on the far side of the caret.
14313        let mut d = wrapped_doc("wrap_kill_back");
14314        d.caret = 27;
14315        d.delete_to_line_start();
14316        assert_eq!(d.source, "one two three four  seven eight");
14317    }
14318
14319    // ── document start / end ────────────────────────────────────────────────
14320
14321    #[test]
14322    fn move_doc_start_and_end_jump_to_the_edges() {
14323        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
14324        assert_eq!(
14325            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
14326            "|hello\nworld\n"
14327        );
14328        assert_eq!(
14329            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
14330            "hello\nworld\n|"
14331        );
14332        // Already at the edge: a no-op.
14333        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
14334        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
14335    }
14336
14337    #[test]
14338    fn move_doc_start_and_end_extend_the_selection() {
14339        assert_eq!(
14340            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
14341                .move_doc_end(true)),
14342            "hello wor[ld\n|]"
14343        );
14344        assert_eq!(
14345            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
14346                .move_doc_start(true)),
14347            "[|hello wor]ld\n"
14348        );
14349    }
14350
14351    #[test]
14352    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
14353        let mut d = doc_with("empty_edges", "");
14354        d.move_doc_end(false);
14355        assert_eq!(d.caret, 0);
14356        d.move_doc_start(false);
14357        assert_eq!(d.caret, 0);
14358    }
14359
14360    // ── arrow collapses an active selection ─────────────────────────────────
14361
14362    #[test]
14363    fn arrow_collapses_selection_to_its_near_edge() {
14364        let mut d = doc_with("collapse", "hello world\n");
14365
14366        // Forward selection (anchor before caret): Right -> end, Left -> start.
14367        d.anchor = Some(2);
14368        d.caret = 7;
14369        d.move_right(false);
14370        assert_eq!((d.caret, d.anchor), (7, None));
14371
14372        d.anchor = Some(2);
14373        d.caret = 7;
14374        d.move_left(false);
14375        assert_eq!((d.caret, d.anchor), (2, None));
14376
14377        // Backward selection (anchor after caret): edges are the same
14378        // regardless of which end the caret started on.
14379        d.anchor = Some(7);
14380        d.caret = 2;
14381        d.move_right(false);
14382        assert_eq!((d.caret, d.anchor), (7, None));
14383
14384        d.anchor = Some(7);
14385        d.caret = 2;
14386        d.move_left(false);
14387        assert_eq!((d.caret, d.anchor), (2, None));
14388    }
14389
14390    #[test]
14391    fn arrow_with_extend_keeps_growing_the_selection() {
14392        let mut d = doc_with("collapse_extend", "hello world\n");
14393        d.anchor = Some(2);
14394        d.caret = 7;
14395        d.move_right(true); // extend: no collapse, caret steps one further
14396        assert_eq!((d.caret, d.anchor), (8, Some(2)));
14397    }
14398
14399    #[test]
14400    fn arrow_without_a_selection_moves_one_character_as_before() {
14401        let mut d = doc_with("no_collapse", "hello\n");
14402        d.caret = 2;
14403        d.move_right(false);
14404        assert_eq!(d.caret, 3);
14405        d.move_left(false);
14406        assert_eq!(d.caret, 2);
14407    }
14408
14409    /// Press Right until it stops, collecting the offsets walked through. Every
14410    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
14411    /// two stops sharing one source offset can't be moved between, so the caret
14412    /// stalls on the first of them and the walk never reaches the rest.
14413    fn walk_right(d: &mut Doc) -> Vec<usize> {
14414        let mut seen = vec![d.caret];
14415        for _ in 0..2000 {
14416            let before = d.caret;
14417            d.move_right(false);
14418            if d.caret == before {
14419                break;
14420            }
14421            seen.push(d.caret);
14422        }
14423        seen
14424    }
14425
14426    #[test]
14427    fn the_caret_crosses_a_soft_break() {
14428        // A newline inside a paragraph is a `soft_break`, which twig gives no
14429        // span of its own — the space it renders as used to borrow the offset of
14430        // the character before it, and a caret can't move without changing
14431        // offset. Right must walk clean off the end of the first line.
14432        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
14433        d.caret = 0;
14434        let seen = walk_right(&mut d);
14435        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14436    }
14437
14438    #[test]
14439    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
14440        // The paragraph holds one soft break. Folded (the default) it lays out as
14441        // a single reflowed row; Preserve re-lays it as a row per source line.
14442        // The setter must invalidate the cached map for the change to show, and
14443        // again on the way back — so a round trip returns to the folded layout.
14444        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
14445        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
14446        d.build_visual(80);
14447        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
14448
14449        d.set_line_flow(LineFlow::Preserve);
14450        d.build_visual(80);
14451        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
14452
14453        d.set_line_flow(LineFlow::Fold);
14454        d.build_visual(80);
14455        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
14456    }
14457
14458    #[test]
14459    fn the_caret_still_crosses_a_preserved_soft_break() {
14460        // Preserve renders the soft break as a row boundary rather than a space,
14461        // but the caret must still reach every offset — the break's own offset is
14462        // the first row's end stop, so Right walks clean off the end of line one
14463        // onto line two, exactly as it does when the break is folded.
14464        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
14465        d.set_line_flow(LineFlow::Preserve);
14466        d.build_visual(80);
14467        d.caret = 0;
14468        let seen = walk_right(&mut d);
14469        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
14470    }
14471
14472    #[test]
14473    fn the_caret_walks_a_code_block() {
14474        // Every glyph of a code block used to map to the block's start, so the
14475        // whole block was a single offset and the caret couldn't move inside it.
14476        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
14477        let mut d = wysiwyg_doc("code_walk", src);
14478        d.caret = 0;
14479        let seen = walk_right(&mut d);
14480        // The fences are markup: hidden, and no caret stop. The code between
14481        // them is reached a character at a time.
14482        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
14483        for off in code.clone() {
14484            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
14485        }
14486        assert!(seen.contains(&code.end), "no stop after the last line");
14487    }
14488
14489    #[test]
14490    fn the_caret_walks_an_indented_code_block() {
14491        // An indented block's text has the four-space indent stripped, so it
14492        // isn't a verbatim slice and its lines have to be re-found. The caret
14493        // lands on the code, never in the indent.
14494        let src = "    indented\n    code\n";
14495        let mut d = wysiwyg_doc("indent_code_walk", src);
14496        d.caret = 0;
14497        let seen = walk_right(&mut d);
14498        assert!(seen.contains(&src.find("indented").unwrap()));
14499        assert!(seen.contains(&src.find("code").unwrap()));
14500        assert!(
14501            !seen.contains(&0) || seen[0] == 0,
14502            "the caret starts where it was put"
14503        );
14504        // Nothing in the stripped indent is a stop.
14505        for off in [1, 2, 3] {
14506            assert!(!seen.contains(&off), "landed in the indent at {off}");
14507        }
14508    }
14509
14510    #[test]
14511    fn the_caret_leaves_a_tight_heading() {
14512        // "# H" with text directly under it: the heading row's end and the
14513        // separator row's end are the same offset. Right used to find the
14514        // separator's copy, set the caret to where it already was, and stop.
14515        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
14516        d.caret = 2; // the "H"
14517        let seen = walk_right(&mut d);
14518        assert!(
14519            seen.len() > 2,
14520            "Right stalled at the heading's end: {seen:?}"
14521        );
14522        assert!(
14523            seen.contains(&8),
14524            "never reached the end of \"text\": {seen:?}"
14525        );
14526    }
14527
14528    #[test]
14529    fn the_caret_skips_the_gap_between_two_paragraphs() {
14530        // The blank line between two paragraphs is the boundary itself. The
14531        // caret used to be able to sit on it, and typing there landed in the
14532        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
14533        // soft break, so the text visibly snapped back up.
14534        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
14535        d.caret = 1; // the end of "A"
14536        d.move_right(false);
14537        assert_eq!(d.caret, 3, "Right stopped in the gap");
14538        d.insert("x");
14539        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
14540    }
14541
14542    #[test]
14543    fn down_from_a_paragraph_lands_on_the_next_one() {
14544        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
14545        d.caret = 0;
14546        d.move_down(false);
14547        assert_eq!(d.caret, 3, "Down stopped in the gap");
14548    }
14549
14550    #[test]
14551    fn clicking_the_gap_lands_on_real_text() {
14552        // A click can still *reach* the gap — it's drawn, so it's clickable.
14553        // It has to resolve to somewhere the caret can be.
14554        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
14555        d.click(1, 0, false); // the gap row
14556        assert!(
14557            d.caret == 1 || d.caret == 3,
14558            "click left the caret in the gap at {}",
14559            d.caret
14560        );
14561        d.insert("x");
14562        // Either edge of the boundary is a fair place to land; inside it isn't.
14563        assert!(
14564            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
14565            "click in the gap typed into the boundary: {:?}",
14566            d.source
14567        );
14568    }
14569
14570    #[test]
14571    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
14572        // Enter inserts a paragraph break, which leaves a blank line spare on
14573        // either side of a new one. That middle line is a real empty paragraph:
14574        // the caret lands there, and typing makes a paragraph rather than
14575        // extending a neighbour.
14576        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
14577        d.caret = 1;
14578        d.newline();
14579        assert_eq!(d.source, "A\n\n\n\nB\n");
14580        d.build_visual(80);
14581        let (row, _) = d.caret_pos();
14582        assert!(
14583            d.vmap.row_is_navigable(row),
14584            "the caret landed on a gap row"
14585        );
14586        d.insert("x");
14587        assert_eq!(
14588            d.source, "A\n\nx\n\nB\n",
14589            "the new paragraph merged into a neighbour"
14590        );
14591    }
14592
14593    #[test]
14594    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
14595        let mut d = wysiwyg_doc("gap_eof", "A\n");
14596        d.caret = 1;
14597        d.newline();
14598        d.build_visual(80);
14599        let (row, _) = d.caret_pos();
14600        assert!(
14601            d.vmap.row_is_navigable(row),
14602            "the caret landed on a gap row"
14603        );
14604        d.insert("x");
14605        assert!(
14606            d.source.starts_with("A\n\n") && d.source.contains('x'),
14607            "typing at the end merged into A: {:?}",
14608            d.source
14609        );
14610    }
14611
14612    #[test]
14613    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
14614        // A paragraph broken over two source lines is one paragraph. Selecting
14615        // it must not stop at the newline inside it — that newline is markup the
14616        // rich-text view exists to hide.
14617        let src = "one two\nthree four\n\nnext\n";
14618        let mut d = wysiwyg_doc("triple_para", src);
14619        d.select_block_at(2);
14620        assert_eq!(
14621            d.selected_text(),
14622            Some("one two\nthree four"),
14623            "stopped at the soft break"
14624        );
14625    }
14626
14627    #[test]
14628    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
14629        // The reader scrolls down past the caret's row. Nothing moved the
14630        // caret, so the view must stay where it was put — the old code revealed
14631        // the caret every frame, which dragged the view straight back and made
14632        // the document unscrollable past the caret.
14633        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
14634        d.caret = 0;
14635        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
14636        d.scroll = 4; // the wheel
14637        d.follow_caret(0, 3, 9);
14638        assert_eq!(
14639            d.scroll, 4,
14640            "the wheel was overruled by a caret that never moved"
14641        );
14642    }
14643
14644    #[test]
14645    fn moving_the_caret_brings_the_view_back_to_it() {
14646        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
14647        d.caret = 0;
14648        d.follow_caret(0, 3, 9);
14649        d.scroll = 6; // scrolled away
14650        d.move_right(false); // ...and now the caret moves
14651        let (row, _) = d.caret_pos();
14652        d.follow_caret(row, 3, 9);
14653        assert!(
14654            d.scroll <= row && row < d.scroll + 3,
14655            "caret row {row} off screen at scroll {}",
14656            d.scroll
14657        );
14658    }
14659
14660    #[test]
14661    fn scrolling_stops_at_the_last_row() {
14662        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
14663        d.caret = 0;
14664        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
14665        d.scroll = 999; // the wheel, spun hard
14666        d.follow_caret(0, 3, 3);
14667        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
14668    }
14669
14670    #[test]
14671    fn every_cell_of_a_wide_table_is_reachable() {
14672        // A table whose cells are far wider than the surface: the columns are
14673        // cut to fit and the text wraps inside them, so no cell hangs off the
14674        // right edge where the caret can never go.
14675        let src = "| Ingredient | Notes |\n|---|---|\n\
14676                   | flour milled coarse | sift it twice before folding it in |\n";
14677        let mut d = wysiwyg_doc("wide_table_walk", src);
14678        d.build_visual(30);
14679        d.caret = 0;
14680        let seen = walk_right(&mut d);
14681        for word in ["Ingredient", "Notes", "coarse", "folding"] {
14682            let at = src.find(word).unwrap();
14683            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
14684        }
14685    }
14686
14687    // ── view parity ──────────────────────────────────────────────────────────
14688    // `doc_with` pins the source view, so everything above tests a view users
14689    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
14690    // deletion golden cases through *both*, plus the WYSIWYG cases the two
14691    // can't share: where the source carries markup the rendered text is a
14692    // different string, and the views agreeing would itself be the bug.
14693
14694    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
14695
14696    /// Run `action` in both views on one `|`-marked fixture and assert they
14697    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
14698    /// source verbatim, so the two views are looking at the same text and any
14699    /// disagreement is one of them having lost the plot.
14700    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
14701        let (src, caret) = parse_caret(marked);
14702        let run = |view: View, tag: &str| {
14703            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
14704            d.caret = caret;
14705            action(&mut d);
14706            render_caret(&d)
14707        };
14708        let source = run(VIEWS[0].0, VIEWS[0].1);
14709        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
14710        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
14711        source
14712    }
14713
14714    #[test]
14715    fn word_motion_agrees_across_the_views_on_plain_prose() {
14716        let g = both_views;
14717        assert_eq!(
14718            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
14719            "hello |world"
14720        );
14721        assert_eq!(
14722            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
14723            "|hello world"
14724        );
14725        assert_eq!(
14726            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
14727            "hello| world"
14728        );
14729        assert_eq!(
14730            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
14731            "hello world|"
14732        );
14733        assert_eq!(
14734            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
14735            "foo|.bar"
14736        );
14737        assert_eq!(
14738            g("par_ext", "hello |world", |d| d.move_word_right(true)),
14739            "hello [world|]"
14740        );
14741    }
14742
14743    #[test]
14744    fn word_deletion_agrees_across_the_views_on_plain_prose() {
14745        let g = both_views;
14746        assert_eq!(
14747            g("par_db", "hello world|", |d| d.delete_word_back()),
14748            "hello |"
14749        );
14750        assert_eq!(
14751            g("par_df", "hello |world", |d| d.delete_word_forward()),
14752            "hello |"
14753        );
14754        assert_eq!(
14755            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
14756            "|bar baz"
14757        );
14758        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
14759    }
14760
14761    #[test]
14762    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
14763        let g = both_views;
14764        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
14765        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
14766        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
14767        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
14768    }
14769
14770    #[test]
14771    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
14772        // The reproduction: the stop table was built one stop per `char`, so
14773        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
14774        // source view, which steps by grapheme, can't reach and backspace can't
14775        // survive. The two views must land on the same offset.
14776        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
14777        for (view, tag) in VIEWS {
14778            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
14779            d.caret = 1;
14780            d.move_right(false);
14781            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
14782
14783            // ...and the edit that used to sever a joiner off the front of it.
14784            d.backspace();
14785            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
14786            assert_eq!(d.caret, 1);
14787        }
14788    }
14789
14790    #[test]
14791    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
14792        for (view, tag) in VIEWS {
14793            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
14794            d.caret = 0;
14795            d.move_right(false);
14796            assert_eq!(
14797                d.caret,
14798                "e\u{0301}".len(),
14799                "{tag} stopped on the combining mark"
14800            );
14801        }
14802    }
14803
14804    #[test]
14805    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
14806        // The general form: whatever route the caret takes through a document
14807        // full of clusters, it never lands between the codepoints of one — so no
14808        // motion-then-backspace sequence can leave a dangling joiner behind.
14809        use unicode_segmentation::UnicodeSegmentation;
14810
14811        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
14812        let mut d = wysiwyg_doc("cluster_walk", src);
14813        d.caret = 0;
14814        let boundaries: Vec<usize> = src
14815            .grapheme_indices(true)
14816            .map(|(i, _)| i)
14817            .chain(std::iter::once(src.len()))
14818            .collect();
14819        for off in walk_right(&mut d) {
14820            assert!(
14821                boundaries.contains(&off),
14822                "Right stopped at {off}, inside a grapheme cluster"
14823            );
14824        }
14825    }
14826
14827    #[test]
14828    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
14829        // The reproduction: ⌥→ from inside the opening `**` computed its
14830        // boundary over the raw source and landed on byte 8 — inside the
14831        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
14832        // "bold". The caret drew past the bold word and sat inside it.
14833        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
14834        d.caret = 2;
14835        d.move_word_right(false);
14836        assert!(
14837            d.vmap.is_stop(d.caret),
14838            "landed at {}, not a caret stop",
14839            d.caret
14840        );
14841        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
14842        // The rendered row is "a bold c": column 6 is the space just past "bold",
14843        // and now the caret is really there rather than only drawn there.
14844        assert_eq!(d.caret_pos(), (0, 6));
14845
14846        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
14847        d.move_word_left(false);
14848        assert_eq!(d.caret, 4);
14849        assert_eq!(d.caret_pos(), (0, 2));
14850    }
14851
14852    #[test]
14853    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
14854        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
14855        // inside the closing `**`, and left "a ** c\n" — delimiters with no
14856        // opener. Glyph space covers the word alone, which would leave
14857        // "a **** c": markup wrapped around nothing. The word and the styling
14858        // that was only ever the word's go together.
14859        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
14860        d.caret = 10;
14861        d.delete_word_back();
14862        assert_eq!(d.source, "a  c\n");
14863        assert_eq!(d.caret, 2);
14864
14865        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
14866        d.caret = 4; // the "b"
14867        d.delete_word_forward();
14868        assert_eq!(d.source, "a  c\n");
14869    }
14870
14871    #[test]
14872    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
14873        let src = "a ***bold*** c\n";
14874        let mut d = wysiwyg_doc("wys_word_del_nest", src);
14875        d.caret = src.find(" c").unwrap();
14876        d.delete_word_back();
14877        assert_eq!(
14878            d.source, "a  c\n",
14879            "the emph inside the strong empties it too"
14880        );
14881
14882        let src = "a `code` c\n";
14883        let mut d = wysiwyg_doc("wys_word_del_code", src);
14884        d.caret = src.find(" c").unwrap();
14885        d.delete_word_back();
14886        assert_eq!(d.source, "a  c\n");
14887    }
14888
14889    #[test]
14890    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
14891        // Only an *emptied* node goes. Take one word of two and the `**` still
14892        // has a job to do — over the word that's left, with the space the delete
14893        // pushed against the opening delimiter moved out in front of it, or the
14894        // run would be no run at all (`** words**` is literal asterisks — see
14895        // the mark-edge rule on `splice`).
14896        let src = "a **two words** c\n";
14897        let mut d = wysiwyg_doc("wys_word_del_partial", src);
14898        d.caret = src.find(" words").unwrap();
14899        d.delete_word_back();
14900        assert_eq!(d.source, "a  **words** c\n");
14901    }
14902
14903    #[test]
14904    fn source_view_word_motion_still_walks_the_markup() {
14905        // The other half of the decision: in the source view the `**` are
14906        // characters like any other — they're on the screen, so word motion has
14907        // to stop at them and a word-delete has to leave them behind. Only
14908        // WYSIWYG hides them, so only WYSIWYG steps over them.
14909        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
14910        assert_eq!(
14911            g("src_word_motion", "a |**bold** c\n", |d| d
14912                .move_word_right(false)),
14913            "a **bold|** c\n"
14914        );
14915        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
14916        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
14917        assert_eq!(
14918            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
14919            "a **| c\n"
14920        );
14921    }
14922
14923    #[test]
14924    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
14925        // The single invariant both bugs violated: the caret draws and edits at
14926        // the same place only when it's on a stop. `debug_assert_on_a_stop`
14927        // makes the same claim in-place; this pins it from the outside, over a
14928        // document with every kind of thing the map has to be careful about.
14929        // At two widths: the wide one every other test builds at, where no
14930        // fixture folds, and one narrow enough that they all do. A soft wrap is
14931        // where an offset stops being on exactly one row, and testing only the
14932        // width that never wraps is how the caret came to be pinned at the first
14933        // one Down reached.
14934        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
14935                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
14936        // A table of named operations, which is what it looks like.
14937        #[allow(clippy::type_complexity)]
14938        let motions: [(&str, fn(&mut Doc)); 8] = [
14939            ("right", |d| d.move_right(false)),
14940            ("left", |d| d.move_left(false)),
14941            ("word_right", |d| d.move_word_right(false)),
14942            ("word_left", |d| d.move_word_left(false)),
14943            ("down", |d| d.move_down(false)),
14944            ("up", |d| d.move_up(false)),
14945            ("home", |d| d.move_home(false)),
14946            ("end", |d| d.move_end(false)),
14947        ];
14948        for width in [80, 12] {
14949            let mut d = wysiwyg_doc("stop_invariant", src);
14950            d.build_visual(width);
14951            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14952            assert!(stops.len() > 20, "fixture should have plenty of stops");
14953            for start in stops {
14954                for (name, motion) in &motions {
14955                    d.caret = start;
14956                    d.anchor = None;
14957                    motion(&mut d);
14958                    assert!(
14959                        d.vmap.is_stop(d.caret),
14960                        "{name} from {start} at width {width} landed at {} — not a caret stop",
14961                        d.caret
14962                    );
14963                }
14964            }
14965        }
14966    }
14967
14968    #[test]
14969    fn no_wysiwyg_motion_is_a_dead_end() {
14970        // Down held to the bottom of a document reaches the bottom, and Up held
14971        // to the top reaches the top — from anywhere, at a width that wraps. The
14972        // invariant above says a motion lands somewhere legal; this one says it
14973        // gets somewhere at all, which is what a caret pinned at a wrap boundary
14974        // was quietly failing to do while every assertion around it held.
14975        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
14976                   - item one two three four five\n\nlast\n";
14977        for width in [80, 12] {
14978            let mut d = wysiwyg_doc("no_dead_end", src);
14979            d.build_visual(width);
14980            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
14981            let (first, last) = (stops[0], stops[stops.len() - 1]);
14982            for &start in &stops {
14983                for (name, motion, want) in [
14984                    (
14985                        "down",
14986                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
14987                        last,
14988                    ),
14989                    ("up", |d: &mut Doc| d.move_up(false), first),
14990                ] {
14991                    d.caret = start;
14992                    d.anchor = None;
14993                    d.goal_col = None;
14994                    // Every row, plus the presses the edges take, plus slack.
14995                    for _ in 0..d.vmap.num_rows() + 4 {
14996                        motion(&mut d);
14997                    }
14998                    assert_eq!(
14999                        d.caret, want,
15000                        "{name} held from {start} at width {width} never arrived"
15001                    );
15002                }
15003            }
15004        }
15005    }
15006    // ── display columns ──────────────────────────────────────────────────────
15007    // A `col` is a terminal cell, not a character. The two are the same number
15008    // for the ASCII the fixtures above are written in, which is how they came
15009    // apart in the first place: `你` is one character drawn in two cells, so a
15010    // column counted in characters names a cell the text isn't in — one earlier
15011    // for every wide character to its left.
15012
15013    #[test]
15014    fn a_wide_character_is_two_columns_wide() {
15015        // The reproduction: `你` is one char and two cells, so the caret just
15016        // past it drew at column 1 — inside the character it had already left.
15017        for (view, tag) in VIEWS {
15018            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
15019            d.caret = "你".len();
15020            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
15021            d.caret = "你好".len();
15022            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
15023        }
15024    }
15025
15026    #[test]
15027    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
15028        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
15029        // two-cell — measuring six cells one at a time, but the character they
15030        // spell is drawn in two. Width belongs to the cluster, not the glyph,
15031        // and the frontends measure it the same way.
15032        let family = "👨‍👩‍👧";
15033        for (view, tag) in VIEWS {
15034            let src = format!("a{family}b\n");
15035            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
15036            d.caret = 1 + family.len();
15037            assert_eq!(
15038                d.caret_pos(),
15039                (0, 3),
15040                "{tag}: 'a' is one cell, the family two"
15041            );
15042        }
15043    }
15044
15045    #[test]
15046    fn both_cells_of_a_wide_character_mean_the_character() {
15047        // Clicking the far half of `好` is still clicking `好`: half a character
15048        // is not a place the caret can be, so it comes to rest at the
15049        // character's start — the column it would have been drawn at anyway.
15050        for (view, tag) in VIEWS {
15051            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
15052            for col in [2, 3] {
15053                d.caret = 0;
15054                d.click(0, col, false);
15055                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
15056                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
15057            }
15058            // Past the last cell is the line's end, as it is for ASCII.
15059            d.click(0, 9, false);
15060            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
15061        }
15062    }
15063
15064    #[test]
15065    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
15066        // The mapping is only a mapping if it inverts: the cell the caret is
15067        // drawn in has to be the cell that brings it back to the same offset.
15068        // Over a fixture where a character may be one cell or two, and one
15069        // codepoint or five.
15070        use unicode_segmentation::UnicodeSegmentation;
15071
15072        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
15073
15074        let mut d = doc_in(View::Source, "roundtrip_source", src);
15075        // Every offset the source view's caret can occupy: it steps by grapheme
15076        // cluster, so those are its boundaries.
15077        for (off, _) in src
15078            .grapheme_indices(true)
15079            .chain(std::iter::once((src.len(), "")))
15080        {
15081            d.caret = off;
15082            let (row, col) = d.caret_pos();
15083            d.click(row, col, false);
15084            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
15085        }
15086
15087        // And in WYSIWYG, where the offsets the caret can occupy are the map's
15088        // stops rather than every boundary.
15089        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
15090        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
15091        assert!(stops.len() > 20, "fixture should have plenty of stops");
15092        for off in stops {
15093            d.caret = off;
15094            let (row, col) = d.caret_pos();
15095            d.click(row, col, false);
15096            assert_eq!(
15097                d.caret, off,
15098                "wysiwyg: {off} → ({row}, {col}) → {}",
15099                d.caret
15100            );
15101        }
15102    }
15103
15104    #[test]
15105    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
15106        // Down from under `世` lands under the glyph in that cell, not two
15107        // characters further along the line. The goal is a column, so a line of
15108        // wide characters and a line of ASCII line up the way they're drawn.
15109        //
15110        // The gap differs by view: a bare newline inside a paragraph is a soft
15111        // break, which WYSIWYG draws as a space on a single row. The views share
15112        // a grid only where the source's lines are the renderer's rows too.
15113        for (view, tag) in VIEWS {
15114            let gap = if view == View::Source { "\n" } else { "\n\n" };
15115            let src = format!("你好世{gap}abcdef\n");
15116            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
15117            d.caret = "你好".len();
15118            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
15119            d.move_down(false);
15120            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
15121            assert!(
15122                d.source[d.caret..].starts_with('e'),
15123                "{tag}: landed on the wrong glyph"
15124            );
15125        }
15126    }
15127
15128    #[test]
15129    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
15130        // Down from column 3 onto `你好`, whose characters start at columns 0
15131        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
15132        // between the cells of one character, so the caret rests on it — and on
15133        // its start, which is the only offset there that is a caret stop.
15134        for (view, tag) in VIEWS {
15135            let gap = if view == View::Source { "\n" } else { "\n\n" };
15136            let src = format!("abcdef{gap}你好\n");
15137            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
15138            let line = src.find('你').unwrap();
15139            d.caret = 3;
15140            d.move_down(false);
15141            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
15142            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
15143        }
15144    }
15145
15146    #[test]
15147    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
15148        // The column the cell's text is laid out in is measured in cells, so the
15149        // caret walking that text has to be too — the two agreeing is the whole
15150        // point of the grid staying square.
15151        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
15152        let at = d.source.find("你").unwrap();
15153        d.caret = at;
15154        let (row, col) = d.caret_pos();
15155        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
15156        // cells further along.
15157        assert_eq!(col, 2, "the cell's first character");
15158        d.move_right(false);
15159        assert_eq!(
15160            d.caret_pos(),
15161            (row, 4),
15162            "`好` is drawn past `你`'s two cells"
15163        );
15164        assert_eq!(d.caret, at + "你".len());
15165    }
15166
15167    // ── active inline marks ───────────────────────────────────────────────────
15168
15169    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
15170    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
15171        let (src, caret) = parse_caret(marked);
15172        let mut d = doc_in(view, name, &src);
15173        d.caret = caret;
15174        d.active_inline_marks().iter().collect()
15175    }
15176
15177    /// The marks over the selection `[start, end)`.
15178    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
15179        let mut d = doc_in(view, name, src);
15180        d.anchor = Some(start);
15181        d.caret = end;
15182        d.active_inline_marks().iter().collect()
15183    }
15184
15185    #[test]
15186    fn a_caret_in_a_mark_reports_it() {
15187        for (view, tag) in VIEWS {
15188            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
15189            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
15190            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
15191            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
15192            // Plain text under no mark lights nothing — the toolbar's resting state.
15193            assert_eq!(m("a| **bold** b"), [], "{tag}");
15194            assert!(m("plain t|ext").is_empty(), "{tag}");
15195        }
15196    }
15197
15198    #[test]
15199    fn nested_marks_all_report() {
15200        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
15201        // the ancestor chain is a chain, and every mark on it is in force.
15202        for (view, tag) in VIEWS {
15203            assert_eq!(
15204                marks(
15205                    view,
15206                    &format!("marks_nested_{tag}"),
15207                    "**bold and *bo|th*** end"
15208                ),
15209                [InlineKind::Strong, InlineKind::Emph],
15210                "{tag}"
15211            );
15212        }
15213    }
15214
15215    #[test]
15216    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
15217        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
15218        // the first byte of its text and the byte after its last — both inside
15219        // the mark's span, both places typing lands inside the bold. The offset
15220        // past the closing delimiter is the next text, and reports nothing.
15221        let src = "a **bold** b";
15222        let inner_start = src.find("bold").unwrap(); // 4
15223        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
15224        for (view, tag) in VIEWS {
15225            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
15226            for off in [2, 3, inner_start, inner_end, 9] {
15227                d.caret = off;
15228                assert!(
15229                    d.active_inline_marks().contains(InlineKind::Strong),
15230                    "{tag}: offset {off} is inside the strong span"
15231                );
15232            }
15233            for off in [0, 1, 10, 11, 12] {
15234                d.caret = off;
15235                assert!(
15236                    !d.active_inline_marks().contains(InlineKind::Strong),
15237                    "{tag}: offset {off} is outside the strong run"
15238                );
15239            }
15240        }
15241    }
15242
15243    #[test]
15244    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
15245        // Regression: twig resolves an offset that is one node's end and the
15246        // next one's start to the node that *starts* there, so `**bold**|\n`
15247        // isn't bold. With nothing following there's no tie to break and the
15248        // chain still ended at the mark, which made a trailing `\n` — not the
15249        // text — decide whether the caret after a bold word reported bold. It's
15250        // the offset past the mark either way, and typing there is plain either
15251        // way. A blank document typed into is exactly this shape.
15252        for (view, tag) in VIEWS {
15253            let m = |name: String, marked| marks(view, &name, marked);
15254            assert_eq!(
15255                m(format!("marks_eob_{tag}"), "**bold**|"),
15256                [],
15257                "{tag}: no trailing newline"
15258            );
15259            assert_eq!(
15260                m(format!("marks_eol_{tag}"), "**bold**|\n"),
15261                [],
15262                "{tag}: with one"
15263            );
15264            // And the last offset that *is* in the mark still is.
15265            assert_eq!(
15266                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
15267                [InlineKind::Strong],
15268                "{tag}"
15269            );
15270        }
15271    }
15272
15273    #[test]
15274    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
15275        let src = "a **bold** b";
15276        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
15277        for (view, tag) in VIEWS {
15278            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
15279            // The whole bold word, and a slice of it.
15280            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
15281            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
15282            // Ending exactly at the closing delimiter's start is still all-bold:
15283            // an exclusive end sits *past* the last selected character, so the
15284            // question is asked of the character, not the boundary.
15285            assert_eq!(
15286                m(b, d_ + 2),
15287                [InlineKind::Strong],
15288                "{tag}: through the close"
15289            );
15290            // Half in, half out: Bold lit here would claim a press turns it off.
15291            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
15292            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
15293        }
15294    }
15295
15296    #[test]
15297    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
15298        // Both ends are bold, but the space between them isn't — two runs are two
15299        // nodes, which is exactly what the node id catches and a kind-only
15300        // comparison would not.
15301        let src = "**one** **two**";
15302        for (view, tag) in VIEWS {
15303            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
15304            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
15305        }
15306    }
15307
15308    #[test]
15309    fn marks_read_the_document_as_it_is_edited() {
15310        // The point of asking twig every frame instead of caching: the answer has
15311        // to follow the toggle that changed it.
15312        let mut d = wysiwyg_doc("marks_live", "one two\n");
15313        d.anchor = Some(0);
15314        d.caret = 3;
15315        assert!(d.active_inline_marks().is_empty(), "plain to start");
15316        d.toggle(InlineKind::Strong);
15317        assert_eq!(d.source, "**one** two\n");
15318        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
15319        assert!(d.active_inline_marks().contains(InlineKind::Strong));
15320        d.toggle(InlineKind::Strong);
15321        assert!(d.active_inline_marks().is_empty(), "and off again");
15322    }
15323
15324    #[test]
15325    fn a_link_is_not_an_inline_mark() {
15326        // `link`/`str` are inline nodes, but nothing on the inline toolbar
15327        // toggles them — a set with a "link mark" in it would have no button.
15328        for (view, tag) in VIEWS {
15329            assert_eq!(
15330                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
15331                [],
15332                "{tag}"
15333            );
15334        }
15335    }
15336
15337    // ── blank documents ───────────────────────────────────────────────────────
15338
15339    #[test]
15340    fn a_blank_document_is_untitled_empty_and_markdown() {
15341        let mut d = Doc::blank().unwrap();
15342        assert!(d.is_untitled());
15343        assert_eq!(d.path, PathBuf::new());
15344        assert_eq!(
15345            d.file_name(),
15346            "untitled",
15347            "the header has to show something"
15348        );
15349        assert_eq!(d.format_name(), "markdown");
15350        assert_eq!(d.source, "");
15351        assert!(!d.dirty, "nothing typed yet is nothing to lose");
15352        assert_eq!(d.disk_state(), DiskState::Untitled);
15353        // And it's a document you can be in: the default view renders it.
15354        d.build_visual(80);
15355        assert_eq!(d.caret, 0);
15356    }
15357
15358    #[test]
15359    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
15360        let mut d = Doc::blank().unwrap();
15361        d.insert("hello");
15362        assert!(d.dirty);
15363        d.save();
15364        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
15365        assert!(d.dirty, "it must not come away believing it saved");
15366        assert!(d.is_untitled(), "and it still has no file");
15367    }
15368
15369    #[test]
15370    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
15371        let p = temp_path("blank_save_as");
15372        let mut d = Doc::blank().unwrap();
15373        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
15374        // be kept literal (`\#`); this test is about save-as, not escaping (which
15375        // has its own test), so it types nothing that escaping would touch.
15376        d.insert("hi");
15377        d.save_as(p.clone());
15378        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
15379        assert!(!d.is_untitled());
15380        assert!(!d.dirty);
15381        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
15382        assert_eq!(
15383            d.disk_state(),
15384            DiskState::Unchanged,
15385            "the watermark is stamped"
15386        );
15387        // And ⌘S is a plain save from here on.
15388        d.insert("!");
15389        d.save();
15390        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
15391        let _ = std::fs::remove_file(&p);
15392    }
15393
15394    // ── a file that isn't there yet ───────────────────────────────────────────
15395
15396    /// A unique path in the temp dir with the given extension, guaranteed not to
15397    /// exist — what `leaf notes.md` is handed when the file has never been made.
15398    fn missing_path(name: &str, ext: &str) -> PathBuf {
15399        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15400        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15401        let mut p = std::env::temp_dir();
15402        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
15403        let _ = std::fs::remove_file(&p);
15404        p
15405    }
15406
15407    #[test]
15408    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
15409        let p = missing_path("named", "md");
15410        let mut d = Doc::open_or_create(p.clone()).unwrap();
15411
15412        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
15413        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
15414        assert!(
15415            !d.is_untitled(),
15416            "it has the name the user asked for — ^S must not detour to Save As"
15417        );
15418        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
15419        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
15420        assert!(!p.exists(), "and opening it wrote nothing");
15421        // And it's a document you can be in.
15422        d.build_visual(80);
15423        assert_eq!(d.caret, 0);
15424    }
15425
15426    #[test]
15427    fn a_new_file_is_created_by_its_first_save() {
15428        let p = missing_path("first_save", "md");
15429        let mut d = Doc::open_or_create(p.clone()).unwrap();
15430        d.insert("hello\n");
15431        assert!(d.dirty);
15432        d.save();
15433
15434        assert_eq!(
15435            std::fs::read_to_string(&p).unwrap(),
15436            "hello\n",
15437            "a plain ^S wrote it — no Save As, no name to invent"
15438        );
15439        assert!(!d.dirty);
15440        assert_eq!(d.disk_state(), DiskState::Unchanged);
15441        let _ = std::fs::remove_file(&p);
15442    }
15443
15444    #[test]
15445    fn a_new_file_takes_its_format_from_the_extension() {
15446        // The one thing `blank` can't do: with no name it has to assume Markdown,
15447        // and typing djot into a Markdown parse is the wrong buffer.
15448        let dj = missing_path("format", "dj");
15449        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
15450        let md = missing_path("format", "md");
15451        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
15452    }
15453
15454    #[test]
15455    fn a_new_file_reports_itself_missing_until_it_is_saved() {
15456        // Not `Untitled` — that's the answer for a document with no path, and it
15457        // would tell a frontend there is nothing a save could collide with. Here
15458        // there is a path, and the file simply isn't at it yet.
15459        let p = missing_path("disk_state", "md");
15460        let mut d = Doc::open_or_create(p.clone()).unwrap();
15461        assert_eq!(d.disk_state(), DiskState::Missing);
15462
15463        // Somebody else creates it while the buffer is open: that's an overwrite
15464        // the frontend has to be able to prompt about, exactly as for an opened
15465        // file. Their bytes, not ours, so `Changed`.
15466        std::fs::write(&p, "theirs\n").unwrap();
15467        assert_eq!(d.disk_state(), DiskState::Changed);
15468
15469        // Saving makes the file ours and re-stamps the watermark.
15470        d.insert("ours\n");
15471        d.save();
15472        assert_eq!(d.disk_state(), DiskState::Unchanged);
15473        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
15474        let _ = std::fs::remove_file(&p);
15475    }
15476
15477    #[test]
15478    fn open_or_create_still_opens_a_file_that_is_there() {
15479        let d = doc_with("open_or_create_existing", "body\n");
15480        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
15481        assert_eq!(reopened.source, "body\n");
15482        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
15483    }
15484
15485    #[test]
15486    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
15487        // A mistyped flag or a stray argument must not become a buffer promising
15488        // to save somewhere — the same refusal `open` gives a real file.
15489        let mut p = std::env::temp_dir();
15490        p.push("leaf_test_new_bad_ext.wat");
15491        assert!(Doc::open_or_create(p).is_err());
15492        let mut none = std::env::temp_dir();
15493        none.push("leaf_test_new_no_ext");
15494        assert!(Doc::open_or_create(none).is_err());
15495    }
15496
15497    #[test]
15498    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
15499        // Opening reads nothing, so there is nothing to fail on yet; the write is
15500        // where it fails, and it says so rather than claiming a save.
15501        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
15502        let mut d = Doc::open_or_create(p).unwrap();
15503        d.insert("x");
15504        d.save();
15505        assert!(
15506            d.status.as_deref().unwrap().starts_with("save failed:"),
15507            "got {:?}",
15508            d.status
15509        );
15510        assert!(d.dirty, "it must not come away believing it saved");
15511    }
15512
15513    // ── save as ───────────────────────────────────────────────────────────────
15514
15515    /// A unique path in the temp dir that no fixture wrote — a Save As target.
15516    fn temp_path(name: &str) -> PathBuf {
15517        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
15518        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15519        let mut p = std::env::temp_dir();
15520        p.push(format!("leaf_test_target_{name}_{seq}.md"));
15521        let _ = std::fs::remove_file(&p);
15522        p
15523    }
15524
15525    #[test]
15526    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
15527        let mut d = doc_with("save_as_move", "original\n");
15528        let old = d.path.clone();
15529        let new = temp_path("save_as_move");
15530        d.insert("edited: ");
15531        d.save_as(new.clone());
15532
15533        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
15534        assert_eq!(
15535            std::fs::read_to_string(&old).unwrap(),
15536            "original\n",
15537            "Save As doesn't touch the file it came from"
15538        );
15539        assert_eq!(d.path, new, "the document moved");
15540        assert!(!d.dirty);
15541        assert_eq!(
15542            d.status.as_deref(),
15543            Some(&*format!("saved {}", d.file_name()))
15544        );
15545
15546        // Every later save follows it, which is the whole difference from a copy.
15547        d.caret = 0;
15548        d.insert("re-");
15549        d.save();
15550        assert_eq!(
15551            std::fs::read_to_string(&new).unwrap(),
15552            "re-edited: original\n"
15553        );
15554        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
15555        let _ = std::fs::remove_file(&new);
15556    }
15557
15558    #[test]
15559    fn save_as_overwrites_an_existing_target() {
15560        // The picker already asked; asking again down here is the same question
15561        // twice, and the second one has no way to be answered.
15562        let new = temp_path("save_as_over");
15563        std::fs::write(&new, "theirs\n").unwrap();
15564        let mut d = doc_with("save_as_over", "ours\n");
15565        d.save_as(new.clone());
15566        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
15567        let _ = std::fs::remove_file(&new);
15568    }
15569
15570    #[test]
15571    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
15572        let mut d = doc_with("save_as_fail", "body\n");
15573        let old = d.path.clone();
15574        d.insert("x");
15575        // A directory that doesn't exist: the write can't land.
15576        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
15577        d.save_as(bad);
15578
15579        assert_eq!(
15580            d.path, old,
15581            "the document must not move to a file that isn't there"
15582        );
15583        assert!(d.dirty, "and must not believe it saved");
15584        assert!(
15585            d.status.as_deref().unwrap().starts_with("save failed:"),
15586            "the same failure a plain save reports, got {:?}",
15587            d.status
15588        );
15589        // The original is still the document's file, and still saveable.
15590        d.save();
15591        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
15592        assert!(!d.dirty);
15593    }
15594
15595    #[test]
15596    fn save_as_renames_without_reparsing_the_format() {
15597        // `.dj` on the name doesn't make the buffer djot: it was parsed as
15598        // Markdown and still is, and saying otherwise would be a conversion the
15599        // user never asked for (and an undo history thrown away to do it).
15600        let mut d = doc_with("save_as_format", "**b**\n");
15601        let mut new = temp_path("save_as_format");
15602        new.set_extension("dj");
15603        d.save_as(new.clone());
15604        assert_eq!(d.format_name(), "markdown");
15605        let _ = std::fs::remove_file(&new);
15606    }
15607
15608    // ── external change / reload ──────────────────────────────────────────────
15609
15610    #[test]
15611    fn an_untouched_file_reports_unchanged() {
15612        let mut d = doc_with("disk_clean", "body\n");
15613        assert_eq!(d.disk_state(), DiskState::Unchanged);
15614        // Editing the buffer is not editing the file.
15615        d.insert("x");
15616        assert_eq!(d.disk_state(), DiskState::Unchanged);
15617        assert!(d.dirty);
15618        // Saving re-stamps the watermark rather than reporting our own bytes back.
15619        d.save();
15620        assert_eq!(d.disk_state(), DiskState::Unchanged);
15621    }
15622
15623    #[test]
15624    fn a_file_written_underneath_reports_changed() {
15625        let mut d = doc_with("disk_changed", "body\n");
15626        std::fs::write(&d.path, "someone else\n").unwrap();
15627        assert_eq!(d.disk_state(), DiskState::Changed);
15628        // Dirty *and* changed is the clobber: both halves are readable, and
15629        // leaf-core takes neither side.
15630        d.insert("x");
15631        assert!(d.dirty && d.disk_state() == DiskState::Changed);
15632        // Saving anyway is allowed — the frontend asked, or chose not to.
15633        d.save();
15634        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
15635        assert_eq!(d.disk_state(), DiskState::Unchanged);
15636    }
15637
15638    #[test]
15639    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
15640        // The hash is what makes this honest: the file was written (a fresh
15641        // mtime), and nothing about the document is stale.
15642        let d = doc_with("disk_same_bytes", "body\n");
15643        std::fs::write(&d.path, "body\n").unwrap();
15644        assert_eq!(d.disk_state(), DiskState::Unchanged);
15645    }
15646
15647    #[test]
15648    fn a_deleted_file_reports_missing() {
15649        let mut d = doc_with("disk_missing", "body\n");
15650        std::fs::remove_file(&d.path).unwrap();
15651        assert_eq!(d.disk_state(), DiskState::Missing);
15652        // A save recreates it, and the document is whole again.
15653        d.save();
15654        assert_eq!(d.disk_state(), DiskState::Unchanged);
15655        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
15656    }
15657
15658    #[test]
15659    fn reload_replaces_the_document_with_the_file() {
15660        for (view, tag) in VIEWS {
15661            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
15662            d.insert("edited ");
15663            assert!(d.dirty);
15664            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15665            d.reload();
15666
15667            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
15668            assert!(!d.dirty, "{tag}: the file is what we have");
15669            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
15670            assert_eq!(
15671                d.status.as_deref(),
15672                Some(&*format!("reloaded {}", d.file_name()))
15673            );
15674            // The reloaded tree is live, not the old parse.
15675            d.caret = d.source.find("three").unwrap();
15676            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
15677        }
15678    }
15679
15680    #[test]
15681    fn reload_clamps_the_caret_and_drops_the_selection() {
15682        let mut d = doc_with("reload_caret", "a long first line\n");
15683        d.caret = 12;
15684        d.anchor = Some(4);
15685        std::fs::write(&d.path, "short\n").unwrap();
15686        d.reload();
15687        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
15688        assert_eq!(
15689            d.anchor, None,
15690            "a selection over bytes that changed is a lie"
15691        );
15692        assert!(d.selection().is_none());
15693
15694        // A caret the file still has room for stays put.
15695        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
15696        d.caret = 2;
15697        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
15698        d.reload();
15699        assert_eq!(d.caret, 2);
15700    }
15701
15702    /// A silent reload is something that happened *to* a reader — a formatter,
15703    /// a `git checkout` — so it has to be undoable like anything else that
15704    /// changes the document, and undoable as one step rather than as however
15705    /// many the file happens to differ by.
15706    #[test]
15707    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
15708        let mut d = doc_with("reload_undo", "body\n");
15709        d.insert("x");
15710        assert_eq!(d.source, "xbody\n");
15711        std::fs::write(&d.path, "replaced\n").unwrap();
15712        d.reload();
15713        assert_eq!(d.source, "replaced\n");
15714        assert!(!d.dirty, "a reload lands clean");
15715
15716        // One ^Z takes the whole swap off, and hands back the unsaved work it
15717        // replaced — which is unsaved again, because the file no longer says it.
15718        d.undo();
15719        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
15720        assert!(d.dirty, "and what it comes back to is unsaved");
15721        // …and the history under it is still there.
15722        d.undo();
15723        assert_eq!(
15724            d.source, "body\n",
15725            "the typing before the reload undoes too"
15726        );
15727        // Redo walks back up through the reload.
15728        d.redo();
15729        d.redo();
15730        assert_eq!(d.source, "replaced\n");
15731    }
15732
15733    /// A file rewritten with the bytes it already had is not an edit, so it
15734    /// must not leave an undo step behind for something nobody did.
15735    #[test]
15736    fn reloading_identical_bytes_pushes_no_undo_step() {
15737        let mut d = doc_with("reload_same", "body\n");
15738        d.insert("x");
15739        std::fs::write(&d.path, "xbody\n").unwrap();
15740        d.reload();
15741        assert_eq!(d.source, "xbody\n");
15742        assert!(!d.dirty, "the file now says what the buffer does");
15743        d.undo();
15744        assert_eq!(
15745            d.source, "body\n",
15746            "one step back is the typing, not a no-op"
15747        );
15748    }
15749
15750    #[test]
15751    fn a_reload_that_cant_read_leaves_the_document_alone() {
15752        let mut d = doc_with("reload_gone", "body\n");
15753        d.insert("x");
15754        std::fs::remove_file(&d.path).unwrap();
15755        d.reload();
15756        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
15757        assert!(d.dirty);
15758        assert!(
15759            d.status.as_deref().unwrap().starts_with("reload failed:"),
15760            "{:?}",
15761            d.status
15762        );
15763
15764        // And an untitled document has nothing to reload from.
15765        let mut d = Doc::blank().unwrap();
15766        d.insert("typed");
15767        d.reload();
15768        assert_eq!(d.source, "typed");
15769        assert_eq!(d.status.as_deref(), Some("no file to reload"));
15770    }
15771
15772    #[test]
15773    fn a_read_only_document_refuses_every_door() {
15774        let mut d = doc_with("readonly", "one two three\n");
15775        d.insert("x");
15776        assert!(d.dirty, "writable first, so the undo step exists");
15777        d.set_read_only(true);
15778        let before = d.source.clone();
15779        d.insert("y");
15780        d.backspace();
15781        d.undo();
15782        d.redo();
15783        assert_eq!(d.source, before, "no door moved a byte");
15784        d.set_read_only(false);
15785        d.undo();
15786        assert_ne!(d.source, before, "off again, the same doors work");
15787    }
15788
15789    /// The doors that go to twig's own verbs rather than through the splice.
15790    /// Typed text in the rendered view under the default markup mode is the
15791    /// everyday one — it is what a keystroke in leaf-web or the Apple views
15792    /// becomes — and it walked straight past the gate.
15793    #[test]
15794    fn a_read_only_document_refuses_the_doors_around_the_splice() {
15795        let mut d = wysiwyg_doc(
15796            "readonly-doors",
15797            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
15798        );
15799        d.set_markup_mode(MarkupMode::None);
15800        d.set_read_only(true);
15801        let before = d.source.clone();
15802        d.place_caret(3, false);
15803        d.insert("y");
15804        d.insert_link("https://example.com");
15805        d.insert_image("a.png", "alt");
15806        d.insert_thematic_break();
15807        d.insert_footnote();
15808        d.place_caret(0, false);
15809        d.place_caret(3, true);
15810        d.toggle(InlineKind::Strong);
15811        d.toggle_heading(2);
15812        d.set_block(BlockKind::Paragraph);
15813        d.toggle_list(false);
15814        d.toggle_blockquote();
15815        d.toggle_task_item();
15816        d.newline();
15817        d.indent();
15818        d.set_code_language("rust");
15819        let in_cell = d.source.find("| c").unwrap() + 2;
15820        d.place_caret(in_cell, false);
15821        assert!(d.caret_in_table(), "the caret is in the grid");
15822        assert!(!d.cell_line_break(), "the cell break reports the refusal");
15823        assert_eq!(d.source, before, "no door moved a byte");
15824        assert!(!d.dirty, "nothing to save");
15825        d.set_read_only(false);
15826        d.place_caret(3, false);
15827        d.insert("y");
15828        assert_ne!(d.source, before, "off again, the same doors work");
15829    }
15830
15831    #[test]
15832    fn a_selection_quote_carries_its_context_on_char_boundaries() {
15833        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
15834        let start = d.source.find("exact").unwrap();
15835        d.place_caret(start, false);
15836        d.place_caret(start + "exact".len(), true);
15837        let q = d.selection_quote(3).unwrap();
15838        assert_eq!(q.exact, "exact");
15839        assert_eq!(
15840            q.prefix, "你好 ",
15841            "chars, not bytes — the multibyte pair counts as two"
15842        );
15843        assert_eq!(q.suffix, " 世界");
15844        assert_eq!(&d.source[q.start..q.end], "exact");
15845        // At the edges the context clips rather than erring.
15846        d.place_caret(0, false);
15847        d.place_caret(6, true);
15848        let q = d.selection_quote(40).unwrap();
15849        assert_eq!(q.prefix, "");
15850        assert_eq!(q.exact, "before");
15851        // No selection is no quote.
15852        d.place_caret(0, false);
15853        assert!(d.selection_quote(3).is_none());
15854    }
15855
15856    #[test]
15857    fn highlights_are_kept_sorted_and_answer_point_queries() {
15858        let mut d = doc_with("hl", "one two three\n");
15859        d.set_highlights(vec![
15860            Highlight {
15861                start: 8,
15862                end: 13,
15863                id: "b".into(),
15864                color: None,
15865                marker: None,
15866            },
15867            Highlight {
15868                start: 0,
15869                end: 3,
15870                id: "a".into(),
15871                color: Some("#ffe066".into()),
15872                marker: None,
15873            },
15874            Highlight {
15875                start: 5,
15876                end: 5,
15877                id: "empty".into(),
15878                color: None,
15879                marker: None,
15880            },
15881        ]);
15882        assert_eq!(
15883            d.highlights()
15884                .iter()
15885                .map(|h| h.id.as_str())
15886                .collect::<Vec<_>>(),
15887            ["a", "b"],
15888            "sorted by start, the empty range dropped"
15889        );
15890        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
15891        assert_eq!(d.highlight_at(3), None, "end is exclusive");
15892        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
15893        d.set_highlights(Vec::new());
15894        assert!(d.highlights().is_empty(), "a replace is a replace");
15895    }
15896
15897    /// `Highlight::covering` and the cursor over it are what both painters ask
15898    /// per glyph, so they have to answer the same as the scan they replaced —
15899    /// including in the gaps, which is where most glyphs are.
15900    #[test]
15901    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
15902        let hl = |start: usize, end: usize, id: &str| Highlight {
15903            start,
15904            end,
15905            id: id.into(),
15906            color: None,
15907            marker: None,
15908        };
15909        // Disjoint, as search hits are: in a range, in a gap, and past the end.
15910        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
15911        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
15912        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
15913        assert_eq!(
15914            Highlight::covering(&hits, 105),
15915            None,
15916            "a gap covers nothing"
15917        );
15918        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
15919        assert_eq!(Highlight::covering(&hits, 9_999), None);
15920        assert_eq!(Highlight::covering(&[], 0), None);
15921
15922        // Nested: first by start, so a hit inside an annotation still resolves
15923        // to the annotation — and the range that stops short doesn't mask it.
15924        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
15925        assert_eq!(
15926            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
15927            Some("outer")
15928        );
15929        assert_eq!(
15930            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
15931            Some("outer")
15932        );
15933    }
15934
15935    /// The cursor is an optimisation, so the only thing worth asserting is that
15936    /// it is not also a change of answer — at every offset, over a list with a
15937    /// nest in it, walked forwards and then backwards.
15938    #[test]
15939    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
15940        let hl = |start: usize, end: usize, id: &str| Highlight {
15941            start,
15942            end,
15943            id: id.into(),
15944            color: None,
15945            marker: None,
15946        };
15947        let mut list = vec![
15948            hl(0, 20, "outer"),
15949            hl(5, 10, "inner"),
15950            hl(30, 33, "hit"),
15951            hl(40, 43, "hit"),
15952        ];
15953        list.sort_by_key(|h| (h.start, h.end));
15954
15955        let mut cursor = HighlightCursor::new(&list);
15956        for offset in 0..50 {
15957            assert_eq!(
15958                cursor.at(offset).map(|h| h.id.as_str()),
15959                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15960                "cursor disagrees at {offset}"
15961            );
15962        }
15963        // Backwards: the cursor re-seats rather than answering from where it
15964        // had got to, so a painter that revisits a row is still told the truth.
15965        for offset in (0..50).rev() {
15966            assert_eq!(
15967                cursor.at(offset).map(|h| h.id.as_str()),
15968                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
15969                "cursor disagrees walking back at {offset}"
15970            );
15971        }
15972    }
15973
15974    // ── the presentation vocabulary ─────────────────────────────────────────
15975
15976    /// A document in `format`, for the gesture tests that want more than the
15977    /// Markdown `doc_with` writes.
15978    fn fmt_doc(body: &str, format: Format) -> Doc {
15979        Doc::from_source(body.to_string(), format).unwrap()
15980    }
15981
15982    /// Alignment is a block property, so the gesture is `set_block_attrs` on
15983    /// the caret's block whatever is selected — and each format spells it its
15984    /// own way: djot's `{…}` line above the block, a `<div>` around it in
15985    /// Markdown (the format has nowhere else to put it), the tag in HTML.
15986    #[test]
15987    fn set_alignment_spells_the_class_the_format_s_own_way() {
15988        let mut dj = fmt_doc("hello\n", Format::Djot);
15989        dj.caret = 1;
15990        dj.set_alignment(Some(Align::Center));
15991        assert_eq!(dj.source, "{.center}\nhello\n");
15992        assert!(dj.dirty);
15993        assert_eq!(dj.status, None);
15994
15995        let mut md = fmt_doc("hello\n", Format::Markdown);
15996        md.caret = 1;
15997        md.set_alignment(Some(Align::Right));
15998        assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
15999
16000        let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
16001        html.caret = html.source.find("hello").unwrap();
16002        html.set_alignment(Some(Align::Justify));
16003        assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
16004    }
16005
16006    /// Each gesture edits **one key and keeps the rest** — twig's contract is
16007    /// replace-not-merge, so leaf reads the node's attributes, edits its own
16008    /// key out of them, and passes the list back whole. A document from
16009    /// elsewhere passes through the editor unharmed.
16010    #[test]
16011    fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
16012        let mut d = fmt_doc(
16013            "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
16014            Format::Djot,
16015        );
16016        d.caret = d.source.find("hello").unwrap();
16017        d.set_alignment(Some(Align::Right));
16018        // `center` goes, `lead` stays, and neither the id nor the spacing is
16019        // touched.
16020        // The serializer picks the order; what matters is which keys survive.
16021        assert!(d.source.contains(".lead"), "{:?}", d.source);
16022        assert!(d.source.contains(".right"), "{:?}", d.source);
16023        assert!(!d.source.contains(".center"), "{:?}", d.source);
16024        assert!(d.source.contains("#intro"), "{:?}", d.source);
16025        assert!(
16026            d.source.contains("data-line-height=\"1.5\""),
16027            "{:?}",
16028            d.source
16029        );
16030        assert_eq!(d.alignment_at_caret(), Some(Align::Right));
16031        assert_eq!(
16032            d.line_spacing_at_caret(),
16033            Some(LineHeight::Step(LineSpacing::OneHalf))
16034        );
16035
16036        // And the other way round: the spacing gesture leaves the classes be.
16037        d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16038        assert!(d.source.contains(".lead"), "{:?}", d.source);
16039        assert!(d.source.contains(".right"), "{:?}", d.source);
16040        assert_eq!(
16041            d.line_spacing_at_caret(),
16042            Some(LineHeight::Step(LineSpacing::Double))
16043        );
16044    }
16045
16046    /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
16047    /// owns go out of `class`, and a block left with nothing at all is spelled
16048    /// bare again — in Markdown by unwrapping the div twig wrapped it in.
16049    #[test]
16050    fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
16051        let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
16052        dj.caret = dj.source.find("hello").unwrap();
16053        dj.set_alignment(None);
16054        assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
16055        assert_eq!(dj.alignment_at_caret(), None);
16056
16057        let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
16058        bare.caret = bare.source.find("hello").unwrap();
16059        bare.set_alignment(None);
16060        assert_eq!(
16061            bare.source, "hello\n",
16062            "the last key takes the line with it"
16063        );
16064
16065        let mut md = fmt_doc("hello\n", Format::Markdown);
16066        md.caret = 1;
16067        md.set_alignment(Some(Align::Center));
16068        assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
16069        md.caret = md.source.find("hello").unwrap();
16070        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
16071        assert_eq!(
16072            md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
16073            "the second key rewrites the div rather than nesting a second"
16074        );
16075        md.caret = md.source.find("hello").unwrap();
16076        md.set_alignment(None);
16077        md.caret = md.source.find("hello").unwrap();
16078        md.set_line_spacing(None);
16079        assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
16080    }
16081
16082    /// Size, face and colour are the run's over a selection and the block's
16083    /// with none — so "make this paragraph larger" is a click with the caret in
16084    /// it rather than a select-all first.
16085    #[test]
16086    fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
16087        // With a selection: a span, in each format's own spelling.
16088        let mut dj = fmt_doc("a big b\n", Format::Djot);
16089        dj.anchor = Some(2);
16090        dj.caret = 5;
16091        dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16092        assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
16093        assert_eq!(
16094            dj.font_size_at_caret(),
16095            Some(FontSize::Step(SizeStep::Large))
16096        );
16097
16098        let mut md = fmt_doc("a big b\n", Format::Markdown);
16099        md.anchor = Some(2);
16100        md.caret = 5;
16101        md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
16102        assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
16103        assert_eq!(
16104            md.text_color_at_caret(),
16105            Some(TextColor::Named(MarkColor::Blue))
16106        );
16107
16108        // Without one: the caret's block, through the block gesture.
16109        let mut block = fmt_doc("a big b\n", Format::Djot);
16110        block.caret = 3;
16111        block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
16112        assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
16113        assert_eq!(
16114            block.font_family_at_caret(),
16115            Some(FontFace::Generic(FontFamily::Monospace))
16116        );
16117    }
16118
16119    /// The *Other…* row of each of the four menus: a value goes into the
16120    /// document in its canonical spelling and comes back out of the query as
16121    /// the same value. One round trip per property, because the four go out
16122    /// through different doors — two block gestures, and the run three through
16123    /// the span that `wrap_range_attrs` mints.
16124    #[test]
16125    fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
16126        // Size: the run three, over a selection.
16127        let mut d = fmt_doc("a big b\n", Format::Djot);
16128        d.anchor = Some(2);
16129        d.caret = 5;
16130        d.set_font_size(FontSize::points(14.0));
16131        assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
16132        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16133
16134        // Colour, onto the same span — the gesture keeps the size it finds.
16135        d.set_text_color(Some(TextColor::Rgb {
16136            r: 0xc0,
16137            g: 0x30,
16138            b: 0x30,
16139        }));
16140        assert_eq!(
16141            d.source,
16142            "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
16143        );
16144        assert_eq!(
16145            d.text_color_at_caret(),
16146            Some(TextColor::Rgb {
16147                r: 0xc0,
16148                g: 0x30,
16149                b: 0x30
16150            })
16151        );
16152
16153        // Face: a family name, as given.
16154        d.set_font_family(Some(FontFace::Named("Garamond".into())));
16155        assert!(
16156            d.source.contains("data-font=\"Garamond\""),
16157            "{:?}",
16158            d.source
16159        );
16160        assert_eq!(
16161            d.font_family_at_caret(),
16162            Some(FontFace::Named("Garamond".into()))
16163        );
16164
16165        // Line spacing: a block gesture, and an exact ratio.
16166        let mut block = fmt_doc("hello\n", Format::Djot);
16167        block.caret = 1;
16168        block.set_line_spacing(LineHeight::ratio(1.3));
16169        assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
16170        assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
16171
16172        // And a value spelled long is written back short, so the same press
16173        // twice writes the same bytes: `14.0pt` in, `14pt` out.
16174        let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
16175        long.caret = long.source.find("hello").unwrap();
16176        assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
16177        let in_force = long.font_size_at_caret();
16178        long.set_font_size(in_force);
16179        assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
16180    }
16181
16182    /// A value the grammar does not cover is what it was before the vocabulary
16183    /// opened: carried untouched by the document, answered `None` by the query
16184    /// so the menu ticks *Default*, and rewritten only by a gesture on its own
16185    /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
16186    #[test]
16187    fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
16188        let src =
16189            "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
16190        let mut d = fmt_doc(src, Format::Djot);
16191        d.caret = d.source.find("hello").unwrap();
16192        assert_eq!(d.font_size_at_caret(), None);
16193        assert_eq!(d.text_color_at_caret(), None);
16194        assert_eq!(d.line_spacing_at_caret(), None);
16195
16196        // The keys are still there, untouched, after a gesture on a *different*
16197        // key — "edit one key and keep the rest" holds for a value it cannot
16198        // read as readily as for one it can.
16199        d.set_alignment(Some(Align::Center));
16200        assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
16201        assert!(
16202            d.source.contains("data-color=\"rgb(1,2,3)\""),
16203            "{:?}",
16204            d.source
16205        );
16206        assert!(
16207            d.source.contains("data-line-height=\"1.3em\""),
16208            "{:?}",
16209            d.source
16210        );
16211        // And the gesture on its *own* key replaces it, which is the one way a
16212        // carried value ever changes.
16213        d.caret = d.source.find("hello").unwrap();
16214        d.set_font_size(FontSize::points(12.0));
16215        assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
16216        assert!(!d.source.contains("huge"), "{:?}", d.source);
16217    }
16218
16219    /// The nearest node wins whichever *form* either node wrote: a value inside
16220    /// a name, a name inside a value. The fold has one rule and does not learn
16221    /// a second one for exact values.
16222    #[test]
16223    fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
16224        // A value inside a name: the block says `small`, the span says `14pt`.
16225        let mut d = fmt_doc(
16226            "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
16227            Format::Djot,
16228        );
16229        d.caret = d.source.find('y').unwrap();
16230        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
16231        d.caret = d.source.find('x').unwrap();
16232        assert_eq!(
16233            d.font_size_at_caret(),
16234            Some(FontSize::Step(SizeStep::Small))
16235        );
16236
16237        // And a name inside a value, which is the same rule read the other way.
16238        let mut e = fmt_doc(
16239            "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
16240            Format::Djot,
16241        );
16242        e.caret = e.source.find('y').unwrap();
16243        assert_eq!(
16244            e.font_size_at_caret(),
16245            Some(FontSize::Step(SizeStep::Small))
16246        );
16247        assert_eq!(
16248            e.text_color_at_caret(),
16249            Some(TextColor::Rgb {
16250                r: 0xc0,
16251                g: 0x30,
16252                b: 0x30
16253            }),
16254            "the block's colour still reaches the span"
16255        );
16256        e.caret = e.source.find('x').unwrap();
16257        assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
16258    }
16259
16260    /// twig re-styles the span a range already lies in rather than nesting a
16261    /// second, and an empty set unwraps it — so a second press of the menu
16262    /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
16263    /// means "the theme's own" takes the span away.
16264    #[test]
16265    fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
16266        let mut d = fmt_doc("a big b\n", Format::Djot);
16267        d.anchor = Some(2);
16268        d.caret = 5;
16269        d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16270        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16271
16272        // The selection `wrap_range_attrs` left behind covers the whole span;
16273        // colouring it now keeps the size, because the gesture reads the span's
16274        // attributes before it edits its own key.
16275        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16276        assert_eq!(
16277            d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
16278            "one span, both keys"
16279        );
16280        assert_eq!(
16281            d.font_size_at_caret(),
16282            Some(FontSize::Step(SizeStep::Large))
16283        );
16284        assert_eq!(
16285            d.text_color_at_caret(),
16286            Some(TextColor::Named(MarkColor::Red))
16287        );
16288
16289        d.set_text_color(None);
16290        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
16291        d.set_font_size(None);
16292        assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
16293        assert_eq!(d.font_size_at_caret(), None);
16294    }
16295
16296    /// The queries read the nearest node that names the property: the span the
16297    /// caret is in, then its block, then the `div`s around it.
16298    #[test]
16299    fn a_presentation_query_reads_the_nearest_node_that_names_it() {
16300        let mut d = fmt_doc(
16301            "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
16302            Format::Djot,
16303        );
16304        // In the span: its own size, the block's face and alignment.
16305        d.caret = d.source.find('y').unwrap();
16306        assert_eq!(
16307            d.font_size_at_caret(),
16308            Some(FontSize::Step(SizeStep::XxLarge))
16309        );
16310        assert_eq!(
16311            d.font_family_at_caret(),
16312            Some(FontFace::Generic(FontFamily::Serif))
16313        );
16314        assert_eq!(d.alignment_at_caret(), Some(Align::Center));
16315        assert_eq!(d.line_spacing_at_caret(), None);
16316        assert_eq!(d.text_color_at_caret(), None);
16317
16318        // Outside it: the block's size.
16319        d.caret = d.source.find('x').unwrap();
16320        assert_eq!(
16321            d.font_size_at_caret(),
16322            Some(FontSize::Step(SizeStep::Small))
16323        );
16324
16325        // And through a Markdown div, which is where a Markdown block's
16326        // attributes live.
16327        let mut md = fmt_doc(
16328            "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
16329            Format::Markdown,
16330        );
16331        md.caret = md.source.find("hello").unwrap();
16332        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16333        assert_eq!(
16334            md.font_size_at_caret(),
16335            Some(FontSize::Step(SizeStep::Large))
16336        );
16337
16338        // A document that names none of it answers `None` everywhere, which is
16339        // "the theme's own" and what every toolbar draws unlit.
16340        let mut plain = doc_with("plain_presentation", "hello\n");
16341        plain.caret = 1;
16342        assert_eq!(plain.alignment_at_caret(), None);
16343        assert_eq!(plain.line_spacing_at_caret(), None);
16344        assert_eq!(plain.font_size_at_caret(), None);
16345        assert_eq!(plain.font_family_at_caret(), None);
16346        assert_eq!(plain.text_color_at_caret(), None);
16347    }
16348
16349    /// A djot fenced div is anonymous the way an attributed span is, and is a
16350    /// block all the same — the *form* is the whole of what tells them apart.
16351    /// Read as a span it poisoned both halves: the run gesture copied the div's
16352    /// entire attribute set onto the span it minted, duplicating the `id`, and
16353    /// the run and block queries answered off a node the walker draws nothing
16354    /// for.
16355    #[test]
16356    fn a_djot_fenced_div_is_not_an_attributed_span() {
16357        let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
16358        let mut d = fmt_doc(src, Format::Djot);
16359        let at = d.source.find("world").unwrap();
16360        d.anchor = Some(at);
16361        d.caret = at + "world".len();
16362        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
16363        assert_eq!(
16364            d.source,
16365            "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
16366            "the span carries its own key and nothing of the div's"
16367        );
16368
16369        // And the queries stop at the block: a djot div is not a `<div>`, the
16370        // walker lends its keys to nothing inside it, and a query that said
16371        // otherwise would tick a menu entry no glyph on screen obeys.
16372        assert_eq!(
16373            d.text_color_at_caret(),
16374            Some(TextColor::Named(MarkColor::Red))
16375        );
16376        assert_eq!(d.font_size_at_caret(), None);
16377        assert_eq!(d.alignment_at_caret(), None);
16378    }
16379
16380    /// Clearing a property the block does not name and a `div` around it does
16381    /// would write nothing and change nothing — twig's `set_block_attrs`
16382    /// reaches one node, and the div is not it. The gesture says so instead of
16383    /// leaving the author pressing an entry that never ticks.
16384    #[test]
16385    fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
16386        // Markdown, two paragraphs in one div: not the sole-child shape twig
16387        // writes, so `block_attrs_at_caret` reads the paragraph and the
16388        // paragraph names none of it.
16389        let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
16390        let mut md = fmt_doc(src, Format::Markdown);
16391        md.caret = md.source.find("hello").unwrap();
16392        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16393
16394        md.set_alignment(None);
16395        assert_eq!(md.source, src, "nothing written");
16396        assert!(!md.dirty);
16397        assert_eq!(
16398            md.status.as_deref(),
16399            Some("alignment: set on the div around the block")
16400        );
16401        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16402
16403        // The same for a `data-` key, at both levels — the block pair and the
16404        // run three, the run three at a bare caret being the block gesture.
16405        md.set_line_spacing(None);
16406        assert_eq!(md.source, src);
16407        assert_eq!(
16408            md.status.as_deref(),
16409            Some("line spacing: set on the div around the block")
16410        );
16411        md.set_font_size(None);
16412        assert_eq!(md.source, src);
16413        assert_eq!(
16414            md.status.as_deref(),
16415            Some("size: set on the div around the block")
16416        );
16417
16418        // HTML has no sole-child fold at all: a block's attributes go on the
16419        // block, so the div around one is always out of reach.
16420        let html_src = "<div class=\"center\"><p>hi</p></div>\n";
16421        let mut html = fmt_doc(html_src, Format::Html);
16422        html.caret = html.source.find("hi").unwrap();
16423        assert_eq!(html.alignment_at_caret(), Some(Align::Center));
16424        html.set_alignment(None);
16425        assert_eq!(html.source, html_src);
16426        assert!(!html.dirty);
16427        assert_eq!(
16428            html.status.as_deref(),
16429            Some("alignment: set on the div around the block")
16430        );
16431
16432        // And it is a refusal, not a rule against clearing: a block that names
16433        // the property itself still loses it, div or no div.
16434        let mut own = fmt_doc(
16435            "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
16436            Format::Html,
16437        );
16438        own.caret = own.source.find("hi").unwrap();
16439        own.set_alignment(None);
16440        assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
16441        assert_eq!(own.status, None);
16442    }
16443
16444    /// An edited key is rewritten **where it stands**. The proposal's worked
16445    /// example is the test: a paragraph that came in as `id="intro"
16446    /// class="lead center" data-line-height="1.5"` and is right-aligned goes
16447    /// out as the same list with one token changed. Removing the key and
16448    /// pushing it back shuffled a document's attributes on every press.
16449    #[test]
16450    fn an_edited_key_keeps_its_place_among_the_attributes() {
16451        let mut html = fmt_doc(
16452            "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
16453            Format::Html,
16454        );
16455        html.caret = html.source.find("hello").unwrap();
16456        html.set_alignment(Some(Align::Right));
16457        assert_eq!(
16458            html.source,
16459            "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
16460        );
16461
16462        // A `data-` key the same way, and a key the block did not have still
16463        // goes on the end.
16464        html.caret = html.source.find("hello").unwrap();
16465        html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16466        assert_eq!(
16467            html.source,
16468            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
16469        );
16470        html.caret = html.source.find("hello").unwrap();
16471        html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16472        assert_eq!(
16473            html.source,
16474            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
16475        );
16476
16477        // Djot writes the same list in its own spelling, and the order is the
16478        // author's there too.
16479        let mut dj = fmt_doc(
16480            "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
16481            Format::Djot,
16482        );
16483        dj.caret = dj.source.find("hello").unwrap();
16484        dj.set_alignment(Some(Align::Right));
16485        assert_eq!(
16486            dj.source,
16487            "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
16488        );
16489    }
16490
16491    /// A page break is a block, so twig alone lands one after the caret's whole
16492    /// block; the paragraph is parted at the caret first, exactly as
16493    /// `insert_thematic_break` parts it, and each format spells the directive
16494    /// its own way.
16495    #[test]
16496    fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
16497        let mut md = doc_with("page_break_md", "hello world\n");
16498        md.caret = 5;
16499        md.insert_page_break();
16500        assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
16501        assert!(md.dirty);
16502        assert_eq!(md.status, None);
16503
16504        let mut dj = fmt_doc("hello world\n", Format::Djot);
16505        dj.caret = 5;
16506        dj.insert_page_break();
16507        assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
16508
16509        // At a block's end there is no second half to mint, so the break simply
16510        // follows the block — the rule the rule button already has.
16511        let mut end = doc_with("page_break_end", "hello\n");
16512        end.caret = 5;
16513        end.insert_page_break();
16514        assert_eq!(end.source, "hello\n\n::page-break\n");
16515
16516        // And it reaches the map as the placeholder row a frontend paginates on.
16517        end.view = View::Wysiwyg;
16518        end.build_visual(80);
16519        assert_eq!(
16520            end.vmap
16521                .rows
16522                .iter()
16523                .find_map(|r| r.leaf_directive.as_ref())
16524                .map(|m| m.name.as_str()),
16525            Some(PAGE_BREAK)
16526        );
16527    }
16528
16529    /// The vocabulary's capabilities, per format. The two block properties are
16530    /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
16531    /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
16532    /// keeps an id and a role and has no slot for a `data-` key.
16533    #[test]
16534    fn the_presentation_capabilities_are_ragged_per_format() {
16535        for fmt in [Format::Markdown, Format::Djot, Format::Html] {
16536            let c = Capabilities::of(fmt);
16537            assert!(c.alignment, "{fmt:?} alignment");
16538            assert!(c.line_spacing, "{fmt:?} line spacing");
16539            assert!(c.font_size, "{fmt:?} size");
16540            assert!(c.font_family, "{fmt:?} face");
16541            assert!(c.text_color, "{fmt:?} colour");
16542        }
16543        // Markdown spells both only under the extensions leaf parses with — a
16544        // `<div>` and a `<span>` read back as containers under `html_elements`,
16545        // and `::page-break` as a directive under `directives`. Ask twig's own
16546        // defaults and the answer is no, which is why `Capabilities` is built
16547        // with `supports_with`.
16548        assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
16549        assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
16550        assert!(!Format::Markdown.supports(Gesture::InsertDirective));
16551
16552        let adoc = Capabilities::of(Format::Asciidoc);
16553        assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
16554        assert!(
16555            !adoc.font_size && !adoc.font_family && !adoc.text_color,
16556            "AsciiDoc has no inline spelling that keeps a data- key"
16557        );
16558
16559        // XML spells none of it, and neither page break.
16560        let xml = Capabilities::of(Format::Xml);
16561        assert!(!xml.alignment && !xml.font_size && !xml.page_break);
16562        assert!(Capabilities::of(Format::Markdown).page_break);
16563        assert!(Capabilities::of(Format::Djot).page_break);
16564
16565        // And those two *only*, though twig spells the gesture in HTML and
16566        // AsciiDoc as well: it spells it differently there —
16567        // `<page-break></page-break>` and `<<<` — and the walker reads neither,
16568        // so the button would write a break that draws as nothing at all in
16569        // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
16570        // what leaf can show, not what twig can write. See
16571        // `docs/tasks/page-break-in-html-and-asciidoc.md`.
16572        let exts = parse_extensions();
16573        assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
16574        assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
16575        assert!(!Capabilities::of(Format::Html).page_break);
16576        assert!(!Capabilities::of(Format::Asciidoc).page_break);
16577    }
16578
16579    /// A format that cannot spell a property refuses in its own words and
16580    /// writes nothing — the guard every other gesture has.
16581    #[test]
16582    fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
16583        let src = "<doc><p>hello</p></doc>\n";
16584        #[allow(clippy::type_complexity)]
16585        let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
16586            ("alignment", &|d: &mut Doc| {
16587                d.set_alignment(Some(Align::Center))
16588            }),
16589            ("line spacing", &|d: &mut Doc| {
16590                d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
16591            }),
16592            ("size", &|d: &mut Doc| {
16593                d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
16594            }),
16595            ("face", &|d: &mut Doc| {
16596                d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
16597            }),
16598            ("colour", &|d: &mut Doc| {
16599                d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
16600            }),
16601            ("page break", &|d: &mut Doc| d.insert_page_break()),
16602        ];
16603        for (name, op) in ops {
16604            let mut d = fmt_doc(src, Format::Xml);
16605            let at = d.source.find("hello").unwrap();
16606            d.caret = at;
16607            d.anchor = Some(at + 5);
16608            op(&mut d);
16609            assert_eq!(d.source, src, "{name} edited an XML document");
16610            assert!(!d.dirty, "{name} marked the document dirty");
16611            let status = d.status.as_deref().unwrap_or("");
16612            assert!(
16613                status.contains("xml"),
16614                "{name}: the refusal should name the format, got {status:?}"
16615            );
16616        }
16617
16618        // AsciiDoc is the ragged one: the block gesture works where the run
16619        // gesture does not, and a *selection* is what tells the two apart.
16620        let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
16621        adoc.anchor = Some(0);
16622        adoc.caret = 5;
16623        adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16624        assert_eq!(adoc.source, "hello world\n", "no inline spelling");
16625        assert!(adoc.status.is_some());
16626    }
16627
16628    /// A read-only document takes none of it, and a caret on a blank line has
16629    /// no block to carry an attribute — both say so rather than writing.
16630    #[test]
16631    fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
16632        let mut ro = fmt_doc("hello\n", Format::Djot);
16633        ro.read_only = true;
16634        ro.caret = 1;
16635        ro.set_alignment(Some(Align::Center));
16636        assert_eq!(ro.source, "hello\n");
16637
16638        let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
16639        blank.caret = 2; // the empty line between the two paragraphs
16640        blank.set_alignment(Some(Align::Center));
16641        assert_eq!(blank.source, "a\n\n\nb\n");
16642        assert!(
16643            blank.status.as_deref().unwrap_or("").contains("no block"),
16644            "got {:?}",
16645            blank.status
16646        );
16647    }
16648
16649    /// A block attribute gesture keeps the caret on the **text** it was on, not
16650    /// on the byte offset it had. Markdown has nowhere to put a paragraph's
16651    /// attributes but a `<div>` around it, and twig splices the div and the
16652    /// block it wraps as one region — so a caret that kept its offset landed in
16653    /// the markup, and every press after the first answered "no block at the
16654    /// caret" with the toolbar's queries reading nothing.
16655    #[test]
16656    fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
16657        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16658        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16659        md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
16660
16661        // Wrapping: the div and two blank lines open above the block.
16662        md.set_alignment(Some(Align::Center));
16663        assert_eq!(
16664            md.source,
16665            "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
16666        );
16667        assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
16668        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
16669
16670        // Re-styling: the attribute line changes length under the same caret,
16671        // and the second press reaches the same block rather than nothing.
16672        md.set_alignment(Some(Align::Right));
16673        assert_eq!(
16674            md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
16675            "a second press re-styles the div"
16676        );
16677        assert_eq!(md.status, None);
16678        assert_eq!(word(&md), 2);
16679
16680        // A second key on the same div — the line grows, the caret rides it.
16681        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16682        assert_eq!(
16683            md.source,
16684            "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16685        );
16686        assert_eq!(word(&md), 2);
16687        assert_eq!(
16688            md.line_spacing_at_caret(),
16689            Some(LineHeight::Step(LineSpacing::Double))
16690        );
16691
16692        // Unwrapping: the line shrinks, and then the div goes altogether.
16693        md.set_alignment(None);
16694        assert_eq!(
16695            md.source,
16696            "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
16697        );
16698        assert_eq!(word(&md), 2);
16699        md.set_line_spacing(None);
16700        assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
16701        assert_eq!(word(&md), 2, "the caret came back down with the block");
16702        assert_eq!(md.alignment_at_caret(), None);
16703        assert_eq!(md.status, None);
16704    }
16705
16706    /// The same rule in djot, where the spelling is a `{…}` line *above* the
16707    /// block rather than a wrapper around it: inserting it pushes the block
16708    /// down, re-styling it changes the line's length, and clearing the last key
16709    /// takes the line away again. The caret rides all three.
16710    #[test]
16711    fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
16712        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
16713        let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
16714        dj.caret = dj.source.find("brown").unwrap() + 2;
16715
16716        dj.set_alignment(Some(Align::Center));
16717        assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
16718        assert_eq!(word(&dj), 2);
16719        assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
16720
16721        dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
16722        assert_eq!(
16723            dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
16724            "a second press edits the line the first wrote"
16725        );
16726        assert_eq!(word(&dj), 2);
16727
16728        dj.set_alignment(None);
16729        assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
16730        assert_eq!(word(&dj), 2);
16731
16732        dj.set_line_spacing(None);
16733        assert_eq!(dj.source, "the quick brown fox\n");
16734        assert_eq!(word(&dj), 2);
16735        assert_eq!(dj.status, None);
16736    }
16737
16738    /// The run gestures with no selection are the block gesture, so they keep
16739    /// the caret the same way — and a heading keeps it inside the heading's own
16740    /// text, past the `# ` its content span starts after. A selection rides
16741    /// along whole: a block gesture is not a run gesture, and what was selected
16742    /// before the press is still selected after it.
16743    #[test]
16744    fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
16745        // No selection: the run gesture goes through the block door.
16746        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
16747        md.caret = md.source.find("brown").unwrap() + 2;
16748        md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
16749        assert_eq!(
16750            md.source,
16751            "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
16752        );
16753        assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
16754        assert_eq!(
16755            md.font_size_at_caret(),
16756            Some(FontSize::Step(SizeStep::Large))
16757        );
16758        md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
16759        assert_eq!(
16760            md.font_size_at_caret(),
16761            Some(FontSize::Step(SizeStep::Small)),
16762            "the second press reached the same block"
16763        );
16764
16765        // A selection: alignment is the block's whatever is selected, and the
16766        // words stay selected.
16767        let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
16768        let at = sel.source.find("brown").unwrap();
16769        sel.anchor = Some(at);
16770        sel.caret = at + 5;
16771        sel.set_alignment(Some(Align::Center));
16772        let now = sel.source.find("brown").unwrap();
16773        assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
16774
16775        // A heading: the content span starts past the `# `.
16776        let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
16777        h.caret = h.source.find("there").unwrap() + 1;
16778        h.set_alignment(Some(Align::Right));
16779        assert_eq!(
16780            h.source,
16781            "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
16782        );
16783        assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
16784        assert_eq!(h.alignment_at_caret(), Some(Align::Right));
16785    }
16786
16787    /// A djot document open in the rich view, with its map built as
16788    /// [`wysiwyg_doc`] builds a Markdown one's.
16789    fn wysiwyg_djot(body: &str) -> Doc {
16790        let mut d = fmt_doc(body, Format::Djot);
16791        d.view = View::Wysiwyg;
16792        d.build_visual(80);
16793        d
16794    }
16795
16796    /// Backspace at the start of a block whose presentation is spelled as
16797    /// hidden markup before it strips that presentation, the way Backspace at
16798    /// a heading's start strips its `#`. The ordinary delete fused djot's
16799    /// `{.center}` line onto the text and took the blank line a Markdown div
16800    /// needs between its tag and its paragraph.
16801    #[test]
16802    fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
16803        let mut md = wysiwyg_doc(
16804            "wys_attr_bksp",
16805            "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16806        );
16807        md.caret = md.source.find("hello").unwrap();
16808        md.backspace();
16809        assert_eq!(
16810            md.source, "above\n\nhello\n\nbelow\n",
16811            "the div is unwrapped"
16812        );
16813        assert_eq!(md.caret, 7, "the caret stays at the start of its text");
16814        md.backspace();
16815        assert_eq!(
16816            md.source, "above\nhello\n\nbelow\n",
16817            "the next press joins the paragraphs, as it always did"
16818        );
16819
16820        let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
16821        dj.caret = dj.source.find("hello").unwrap();
16822        dj.backspace();
16823        assert_eq!(
16824            dj.source, "above\n\nhello\n\nbelow\n",
16825            "the attribute line goes"
16826        );
16827        assert_eq!(dj.caret, 7);
16828
16829        // A heading's own marker is the nearer hidden markup, and goes first;
16830        // the attributes are the next press's.
16831        let mut dj = wysiwyg_djot("{.center}\n# Title\n");
16832        dj.caret = dj.source.find("Title").unwrap();
16833        dj.backspace();
16834        assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
16835        dj.build_visual(80);
16836        dj.backspace();
16837        assert_eq!(dj.source, "Title\n", "then the attributes");
16838        assert_eq!(dj.caret, 0);
16839    }
16840
16841    /// A div around several blocks has no sole child for twig to unwrap, so
16842    /// at its first block the caret steps back to the stop before rather than
16843    /// taking the div apart; a later block has an ordinary paragraph above it
16844    /// and joins as any paragraph does.
16845    #[test]
16846    fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
16847        let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
16848        let mut d = wysiwyg_doc("wys_div_first", src);
16849        d.caret = d.source.find("hello").unwrap();
16850        d.backspace();
16851        assert_eq!(d.source, src, "nothing is deleted");
16852        assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
16853
16854        let mut d = wysiwyg_doc("wys_div_later", src);
16855        d.caret = d.source.find("world").unwrap();
16856        d.backspace();
16857        assert_eq!(
16858            d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
16859            "a later block joins the one above it"
16860        );
16861    }
16862
16863    /// Backspace at the start of the paragraph after a Markdown div joins it
16864    /// into the div's last paragraph — the join any two paragraphs make, with
16865    /// the hidden `</div>` carried past the joined text. The ordinary delete
16866    /// took the newline under the tag, which drew nothing different, and the
16867    /// next press took the `>` and left the div unclosed.
16868    #[test]
16869    fn backspace_after_a_div_joins_the_paragraph_into_it() {
16870        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
16871        let mut d = wysiwyg_doc("wys_div_join", src);
16872        d.caret = d.source.find("below").unwrap();
16873        d.backspace();
16874        assert_eq!(
16875            d.source,
16876            "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
16877        );
16878        assert_eq!(
16879            d.caret,
16880            d.source.find("below").unwrap(),
16881            "the caret stays at the start of the joined text"
16882        );
16883        d.build_visual(80);
16884        assert_eq!(
16885            d.alignment_at_caret(),
16886            Some(Align::Center),
16887            "and is centred now"
16888        );
16889        d.undo();
16890        assert_eq!(d.source, src, "one undo step");
16891
16892        // A list closes the div: the paragraph joins the last item's text,
16893        // under the item's continuation indent, inside the div.
16894        let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
16895        let mut d = wysiwyg_doc("wys_div_list", src);
16896        d.caret = d.source.find("below").unwrap();
16897        d.backspace();
16898        assert_eq!(
16899            d.source, "<div class=\"center\">\n\n- item\n  below\n\n</div>\n",
16900            "the paragraph joins the item"
16901        );
16902        assert_eq!(d.caret, d.source.find("below").unwrap());
16903
16904        // And where twig has nothing to join into — a code block above — the
16905        // caret steps back to the stop before, and nothing is deleted.
16906        let src = "```\ncode\n```\n\nbelow\n";
16907        let mut d = wysiwyg_doc("wys_code_then_para", src);
16908        d.caret = d.source.find("below").unwrap();
16909        d.backspace();
16910        assert_eq!(d.source, src, "nothing is deleted");
16911        assert!(
16912            d.caret < d.source.find("below").unwrap(),
16913            "the caret stepped back"
16914        );
16915    }
16916
16917    /// Backspace on a blank line collapses to the stop before it — but not
16918    /// across hidden markup, which that collapse deleted whole: a `</div>`,
16919    /// or a comment between two blocks. There the blank line goes alone, and
16920    /// the caret lands where the collapse would have put it.
16921    #[test]
16922    fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
16923        let mut d = wysiwyg_doc(
16924            "wys_div_blank",
16925            "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
16926        );
16927        d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
16928        assert!(
16929            d.vmap.is_stop(d.caret),
16930            "the empty paragraph is a caret home"
16931        );
16932        d.backspace();
16933        assert_eq!(
16934            d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
16935            "the blank line goes and the div stays closed"
16936        );
16937        assert_eq!(
16938            d.caret,
16939            d.source.find("hello").unwrap() + 5,
16940            "onto the end of `hello`"
16941        );
16942
16943        let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
16944        d.caret = d.source.find("below").unwrap() - 2;
16945        assert!(d.vmap.is_stop(d.caret));
16946        d.backspace();
16947        assert_eq!(
16948            d.source, "above\n\n<!-- note -->\n\nbelow\n",
16949            "the comment stays"
16950        );
16951        assert_eq!(d.caret, 5);
16952    }
16953
16954    /// Backspace at the end of an attributed span steps inside its hidden
16955    /// closing tag the way it steps inside a `**`, and takes the span with
16956    /// its last letter. Before, the byte-step took the `>` of `</span>`,
16957    /// which left the paragraph unparseable: it vanished from the rich view,
16958    /// and the Backspace after that joined the next block into the wreck.
16959    #[test]
16960    fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
16961        let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
16962        let mut d = wysiwyg_doc("wys_span_bs", src);
16963        d.caret = d.source.find("a test").unwrap() + 6;
16964        for _ in 0..7 {
16965            d.backspace();
16966        }
16967        assert_eq!(
16968            d.source,
16969            "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
16970        );
16971        d.backspace();
16972        assert_eq!(
16973            d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
16974            "the first Backspace after the tag takes the letter, not the `>`"
16975        );
16976        d.backspace();
16977        assert_eq!(
16978            d.source, "above\n\nThis \n\nTest 2\n",
16979            "the last letter takes the span with it"
16980        );
16981        assert_eq!(d.caret, 12, "the caret is where the letter was");
16982        d.backspace();
16983        assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
16984        assert_eq!(d.caret, 11);
16985        d.build_visual(80);
16986        assert!(
16987            d.vmap
16988                .rows
16989                .iter()
16990                .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
16991            "the paragraph is still drawn"
16992        );
16993    }
16994
16995    #[test]
16996    fn backspace_walks_into_a_djot_sized_span_too() {
16997        let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
16998        d.caret = d.source.find("\n\nTest 2").unwrap();
16999        // The caret home at the paragraph's end is inside the span, before
17000        // its `]`: the map offers no stop after `]{…}`.
17001        d.build_visual(80);
17002        assert_eq!(d.vmap.stop_before(31), Some(8));
17003        d.caret = 8;
17004        d.backspace();
17005        assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
17006        d.backspace();
17007        assert_eq!(
17008            d.source, "This \n\nTest 2\n",
17009            "the attribute block outside the span goes with it"
17010        );
17011        d.backspace();
17012        assert_eq!(d.source, "This\n\nTest 2\n");
17013        assert_eq!(d.caret, 4);
17014    }
17015
17016    /// A span that is empty as the file was written has no stop of its own;
17017    /// Backspace reaching it from behind takes it with the character before
17018    /// it, the character the key looked aimed at.
17019    #[test]
17020    fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
17021        let src = "This <span data-size=\"x-large\"></span> a test\n";
17022        let mut d = wysiwyg_doc("wys_span_empty", src);
17023        d.caret = d.source.find(" a test").unwrap();
17024        d.backspace();
17025        assert_eq!(d.source, "This a test\n");
17026        assert_eq!(d.caret, 4);
17027    }
17028
17029    /// The mirror: Delete in front of a span's opening tag takes its first
17030    /// letter, and the span with its last.
17031    #[test]
17032    fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
17033        let src = "This <span data-size=\"x-large\">is</span> a test\n";
17034        let mut d = wysiwyg_doc("wys_span_del", src);
17035        d.caret = 5;
17036        d.delete_forward();
17037        assert_eq!(
17038            d.source,
17039            "This <span data-size=\"x-large\">s</span> a test\n"
17040        );
17041        d.delete_forward();
17042        assert_eq!(d.source, "This  a test\n");
17043        assert_eq!(d.caret, 5);
17044        d.delete_forward();
17045        assert_eq!(d.source, "This a test\n");
17046        assert_eq!(d.caret, 5);
17047    }
17048
17049    /// The block version of the span's emptying rule. Centre a one-letter
17050    /// paragraph — Markdown spells that as a `<div>` around it — and
17051    /// Backspace the letter: the div goes with it, leaving a plain blank line
17052    /// the caret is at home on, in the incremental map and the from-scratch
17053    /// one alike. Before, the letter went alone; the emptied div drew a
17054    /// caret home only the stale map had, and the next Backspace collapsed
17055    /// the line and left `<div class="center">\n\n</div>` standing invisibly
17056    /// in the file.
17057    #[test]
17058    fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
17059        let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
17060        d.caret = d.source.len() - 1;
17061        d.set_alignment(Some(Align::Center));
17062        assert_eq!(
17063            d.source,
17064            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
17065        );
17066        d.backspace();
17067        assert_eq!(d.source, "Try the toolbar.\n\n\n");
17068        assert_eq!(d.caret, 18, "on the blank line where the letter was");
17069        // The host rebuilds the map after every key; the spliced map and a
17070        // fresh one both give the line a caret home.
17071        d.build_visual_unwrapped();
17072        assert!(d.vmap.is_stop(18));
17073        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
17074        d.backspace();
17075        assert_eq!(
17076            d.source, "Try the toolbar.\n",
17077            "then the blank line collapses"
17078        );
17079        assert_eq!(d.caret, 16);
17080
17081        // With a block after the div the blank line keeps a gap each side.
17082        let mut d = wysiwyg_doc(
17083            "wys_div_empty_mid",
17084            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
17085        );
17086        d.caret = d.source.find("T\n").unwrap() + 1;
17087        d.backspace();
17088        assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
17089        assert_eq!(d.caret, 18);
17090        d.build_visual_unwrapped();
17091        assert!(d.vmap.is_stop(18));
17092        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
17093    }
17094
17095    /// djot spells the same paragraph as a `{.center}` line above it, and
17096    /// twig has no node at all for that line once the paragraph is gone —
17097    /// so the line goes with the letter too.
17098    #[test]
17099    fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
17100        let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
17101        d.build_visual(80);
17102        d.caret = d.source.len() - 1;
17103        d.set_alignment(Some(Align::Center));
17104        assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
17105        d.backspace();
17106        assert_eq!(d.source, "Try the toolbar.\n\n\n");
17107        assert_eq!(d.caret, 18);
17108        d.build_visual(80);
17109        assert!(d.vmap.is_stop(18));
17110    }
17111
17112    /// The rule is for a block that would be no block: a div holding more
17113    /// keeps its tags, and an emptied heading is still a heading.
17114    #[test]
17115    fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
17116        let mut d = wysiwyg_doc(
17117            "wys_div_more",
17118            "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
17119        );
17120        d.caret = d.source.find("T\n").unwrap() + 1;
17121        d.backspace();
17122        assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
17123        assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
17124
17125        let mut d = wysiwyg_doc(
17126            "wys_div_heading",
17127            "<div class=\"center\">\n\n# T\n\n</div>\n",
17128        );
17129        d.caret = d.source.find("T\n").unwrap() + 1;
17130        d.backspace();
17131        assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
17132        assert_eq!(d.caret, 24);
17133        d.build_visual(80);
17134        assert!(
17135            d.vmap.is_stop(24),
17136            "the empty heading is still a caret home"
17137        );
17138    }
17139
17140    /// The mirror: Delete in front of the letter takes the div with it.
17141    #[test]
17142    fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
17143        let mut d = wysiwyg_doc(
17144            "wys_div_empty_del",
17145            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
17146        );
17147        d.caret = d.source.find("T\n").unwrap();
17148        d.delete_forward();
17149        assert_eq!(d.source, "Try the toolbar.\n\n\n");
17150        assert_eq!(d.caret, 18);
17151        d.build_visual(80);
17152        assert!(d.vmap.is_stop(18));
17153
17154        let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
17155        d.build_visual(80);
17156        d.caret = d.source.find("T\n").unwrap();
17157        d.delete_forward();
17158        assert_eq!(d.source, "Try the toolbar.\n\n\n");
17159        assert_eq!(d.caret, 18);
17160    }
17161
17162    /// Backspace at a block's start is twig's join, spelled per format — so
17163    /// the cases the one-newline delete got wrong come out right: a
17164    /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
17165    /// and a quote's prefix is written on the joined line.
17166    #[test]
17167    fn backspace_at_a_block_start_joins_it_the_format_s_way() {
17168        let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
17169        d.caret = d.source.find("below").unwrap();
17170        d.backspace();
17171        assert_eq!(d.source, "# Title below\n", "onto the heading's line");
17172        assert_eq!(d.caret, d.source.find("below").unwrap());
17173        d.undo();
17174        assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
17175
17176        let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
17177        d.caret = d.source.find('b').unwrap();
17178        d.backspace();
17179        assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
17180        assert_eq!(d.caret, d.source.find('b').unwrap());
17181
17182        let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
17183        h.view = View::Wysiwyg;
17184        h.build_visual(80);
17185        h.caret = h.source.find("below").unwrap();
17186        h.backspace();
17187        assert_eq!(
17188            h.source, "<p>above\nbelow</p>\n",
17189            "one paragraph, the tag gone whole"
17190        );
17191        assert_eq!(h.caret, h.source.find("below").unwrap());
17192    }
17193
17194    /// Delete at the end of a block's content is the same join aimed at the
17195    /// block after it, and the caret stays where the joined text now begins.
17196    #[test]
17197    fn delete_at_a_block_end_joins_the_next_block_into_it() {
17198        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
17199        let mut d = wysiwyg_doc("wys_del_join", src);
17200        d.caret = d.source.find("hello").unwrap() + 5;
17201        d.delete_forward();
17202        assert_eq!(
17203            d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
17204            "below joins hello inside the div"
17205        );
17206        assert_eq!(
17207            d.caret,
17208            d.source.find("hello").unwrap() + 5,
17209            "the caret stays"
17210        );
17211
17212        let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
17213        d.caret = 5;
17214        d.delete_forward();
17215        assert_eq!(
17216            d.source, "above\nTitle\n",
17217            "the heading's marker goes with the join"
17218        );
17219        assert_eq!(d.caret, 5);
17220
17221        // A code block after the paragraph: nothing to join, the caret steps
17222        // forward onto the next stop and nothing is deleted.
17223        let src = "above\n\n```\ncode\n```\n";
17224        let mut d = wysiwyg_doc("wys_del_code", src);
17225        d.caret = 5;
17226        d.delete_forward();
17227        assert_eq!(d.source, src);
17228        assert!(d.caret > 5, "the caret stepped forward");
17229    }
17230
17231    // ── Text statistics ──────────────────────────────────────────────────────
17232
17233    /// The counts of `body`, from a document open in the WYSIWYG view — the
17234    /// shape every case below starts from.
17235    fn counts_of(name: &str, body: &str) -> TextCounts {
17236        doc_in(View::Wysiwyg, name, body).counts()
17237    }
17238
17239    #[test]
17240    fn counts_tally_plain_prose() {
17241        let c = counts_of(
17242            "counts_prose",
17243            "The quick brown fox jumps over the lazy dog.\n",
17244        );
17245        assert_eq!(
17246            c,
17247            TextCounts {
17248                words: 9,
17249                characters: 44,
17250                characters_without_spaces: 36,
17251                paragraphs: 1,
17252            }
17253        );
17254    }
17255
17256    #[test]
17257    fn counts_read_the_text_and_not_the_markup() {
17258        // The `**` are four bytes of source and no part of the word.
17259        assert_eq!(
17260            counts_of("counts_marks", "a **bold** word\n"),
17261            TextCounts {
17262                words: 3,
17263                characters: 11,
17264                characters_without_spaces: 9,
17265                paragraphs: 1,
17266            }
17267        );
17268        // A link is its label; the destination is plumbing, however long.
17269        assert_eq!(
17270            counts_of(
17271                "counts_link",
17272                "see [the label](https://example.com/a/b/c) here\n"
17273            ),
17274            TextCounts {
17275                words: 4,
17276                characters: 18,
17277                characters_without_spaces: 15,
17278                paragraphs: 1,
17279            }
17280        );
17281    }
17282
17283    #[test]
17284    fn counts_spend_nothing_on_a_picture() {
17285        // A block image renders as a `🖼 alt` placeholder — a picture, not a
17286        // sentence, and not a paragraph either.
17287        assert_eq!(
17288            counts_of("counts_image", "![a long caption](pic.png)\n"),
17289            TextCounts::default()
17290        );
17291        // And it adds nothing to the prose around it.
17292        assert_eq!(
17293            counts_of("counts_image_prose", "text\n\n![a long caption](pic.png)\n"),
17294            TextCounts {
17295                words: 1,
17296                characters: 4,
17297                characters_without_spaces: 4,
17298                paragraphs: 1,
17299            }
17300        );
17301    }
17302
17303    #[test]
17304    fn counts_spend_nothing_on_drawn_furniture() {
17305        // A thematic break is drawn, not written, and an empty paragraph has
17306        // nothing in it — neither is a paragraph of the document.
17307        assert_eq!(
17308            counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
17309            TextCounts {
17310                words: 2,
17311                characters: 6,
17312                characters_without_spaces: 6,
17313                paragraphs: 2,
17314            }
17315        );
17316    }
17317
17318    #[test]
17319    fn counts_measure_characters_as_a_reader_does() {
17320        // Four Han characters (each its own word under UAX#29), one ZWJ emoji
17321        // family that is a single grapheme cluster, and two letters.
17322        let c = counts_of(
17323            "counts_graphemes",
17324            "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
17325        );
17326        assert_eq!(
17327            c,
17328            TextCounts {
17329                words: 5,
17330                characters: 9,
17331                characters_without_spaces: 7,
17332                paragraphs: 1,
17333            }
17334        );
17335    }
17336
17337    #[test]
17338    fn counts_take_a_code_block_as_one_paragraph() {
17339        let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
17340        assert_eq!(
17341            c,
17342            TextCounts {
17343                words: 6,
17344                characters: 20,
17345                characters_without_spaces: 14,
17346                paragraphs: 1,
17347            }
17348        );
17349    }
17350
17351    #[test]
17352    fn counts_take_a_table_as_one_paragraph() {
17353        // The box-drawn borders and the column padding are the renderer's, not
17354        // the author's; the cells are what was written.
17355        let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
17356        assert_eq!(
17357            c,
17358            TextCounts {
17359                words: 5,
17360                characters: 6,
17361                characters_without_spaces: 5,
17362                paragraphs: 1,
17363            }
17364        );
17365    }
17366
17367    #[test]
17368    fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
17369        let c = counts_of(
17370            "counts_blocks",
17371            "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
17372        );
17373        assert_eq!(
17374            c,
17375            TextCounts {
17376                words: 7,
17377                characters: 36,
17378                characters_without_spaces: 34,
17379                paragraphs: 5,
17380            }
17381        );
17382    }
17383
17384    #[test]
17385    fn counts_leave_the_frontmatter_out() {
17386        // The WYSIWYG view doesn't render it and a writer didn't write it.
17387        let c = counts_of(
17388            "counts_frontmatter",
17389            "---\ntitle: Hidden\n---\n\nvisible words here\n",
17390        );
17391        assert_eq!(
17392            c,
17393            TextCounts {
17394                words: 3,
17395                characters: 18,
17396                characters_without_spaces: 16,
17397                paragraphs: 1,
17398            }
17399        );
17400    }
17401
17402    #[test]
17403    fn counts_of_an_empty_document_are_all_zero() {
17404        assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
17405    }
17406
17407    /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
17408    /// words. Recorded rather than corrected: it is what the algorithm says,
17409    /// and what every other UAX#29 counter reports.
17410    #[test]
17411    fn counts_split_a_hyphenated_compound_in_two() {
17412        let c = counts_of("counts_hyphen", "well-known example\n");
17413        assert_eq!(c.words, 3);
17414        assert_eq!(c.characters, 18);
17415        // Punctuation on its own is no word, and an apostrophe doesn't split one.
17416        assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
17417    }
17418
17419    #[test]
17420    fn selection_counts_measure_the_selection_and_nothing_without_one() {
17421        let src = "alpha beta\n\ngamma delta\n";
17422        let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
17423        assert_eq!(d.selection_counts(), None, "no selection, no counts");
17424
17425        // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
17426        d.select_range(6, 17);
17427        assert_eq!(
17428            d.selection_counts(),
17429            Some(TextCounts {
17430                words: 2,
17431                characters: 9,
17432                characters_without_spaces: 9,
17433                paragraphs: 2,
17434            })
17435        );
17436    }
17437
17438    /// The count is of the document, not of the window it is shown in — so
17439    /// ⌘E must not move it, and neither must a resize or an edit made with no
17440    /// map built at all.
17441    #[test]
17442    fn counts_agree_across_the_views() {
17443        let src =
17444            "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
17445        let mut d = doc_in(View::Wysiwyg, "counts_views", src);
17446        let wysiwyg = d.counts();
17447        assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
17448
17449        d.toggle_view();
17450        assert_eq!(d.view, View::Source);
17451        d.build_source();
17452        assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
17453
17454        // A narrower measure is a narrower window, not a shorter document.
17455        d.toggle_view();
17456        d.build_visual(24);
17457        assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
17458
17459        // And an edit made in the source view, with the visual map left stale,
17460        // still counts the document as it now stands.
17461        d.toggle_view();
17462        d.caret = d.source.len();
17463        d.insert("\n\ntail words\n");
17464        let after = d.counts();
17465        assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
17466        assert_eq!(after.words, wysiwyg.words + 2);
17467    }
17468
17469    // ── math ─────────────────────────────────────────────────────────────────
17470
17471    #[test]
17472    fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
17473        // The rule the proposal states: a formula's content is its TeX, not
17474        // its picture, so it reveals on the caret's line in *every* mode —
17475        // and nothing else on that line does outside `Full`.
17476        let mut d = doc_in(
17477            View::Wysiwyg,
17478            "math_reveal",
17479            "*one* $x+y$ here\n\ntwo there\n",
17480        );
17481        d.set_inline_pictures(true);
17482        assert_eq!(d.markup_mode(), MarkupMode::None);
17483
17484        // Away from the formula's line: the atom, and no reveal at all.
17485        caret_at(&mut d, "two");
17486        assert!(
17487            drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
17488            "{:?}",
17489            drawn_rows(&d)
17490        );
17491        assert_eq!(d.vmap.math.len(), 1);
17492        assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
17493
17494        // On it: the formula is its source, the emphasis is still resolved.
17495        caret_at(&mut d, "here");
17496        assert!(
17497            drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
17498            "{:?}",
17499            drawn_rows(&d)
17500        );
17501        assert!(d.vmap.math.is_empty());
17502        assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
17503
17504        // Off again, and the picture is back.
17505        caret_at(&mut d, "two");
17506        assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
17507
17508        // The same in Shortcuts; and Full reveals the emphasis too.
17509        d.set_markup_mode(MarkupMode::Shortcuts);
17510        caret_at(&mut d, "here");
17511        assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
17512        d.set_markup_mode(MarkupMode::Full);
17513        caret_at(&mut d, "here");
17514        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
17515    }
17516
17517    #[test]
17518    fn a_formula_closed_by_typing_reveals_at_once() {
17519        // The reveal line is decided from the last build's layout, which
17520        // across an edit is stale: the keystroke that closes a `$…$` asks a
17521        // layout that knew no math. `build_map` asks again once the new
17522        // layout is in, so the formula does not snap to its picture under
17523        // the caret.
17524        let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
17525        d.set_inline_pictures(true);
17526        d.set_markup_mode(MarkupMode::Shortcuts);
17527        d.caret = 4;
17528        for ch in ["$", "x", "$"] {
17529            d.insert(ch);
17530            d.build_visual(80);
17531        }
17532        assert_eq!(d.source, "say $x$\n");
17533        assert_eq!(
17534            drawn_rows(&d)[0],
17535            "say $x$",
17536            "source, not a picture, under the caret"
17537        );
17538        assert!(d.vmap.math.is_empty());
17539        // Leaving the line folds it — there is only one line, so add one.
17540        d.newline();
17541        d.insert("more");
17542        d.build_visual(80);
17543        assert_eq!(drawn_rows(&d)[0], "say ∑");
17544        assert_eq!(d.vmap.math.len(), 1);
17545        // And deleting the formula while revealed drops the reveal with it.
17546        d.caret = 7;
17547        d.build_visual(80);
17548        assert_eq!(drawn_rows(&d)[0], "say $x$");
17549        for _ in 0..3 {
17550            d.backspace();
17551        }
17552        d.build_visual(80);
17553        assert_eq!(d.source, "say \n\nmore\n");
17554        assert_eq!(d.reveal_line(), None);
17555    }
17556
17557    #[test]
17558    fn a_display_block_is_edited_where_it_stands() {
17559        let mut d = doc_in(
17560            View::Wysiwyg,
17561            "math_block",
17562            "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
17563        );
17564        caret_at(&mut d, "end");
17565        assert_eq!(
17566            drawn_rows(&d),
17567            vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
17568        );
17569        // Up from `end` lands on the placeholder, whose glyphs all carry the
17570        // block's start — which is on its `$$` line, so the block reveals.
17571        d.move_up(false);
17572        d.build_visual(80);
17573        assert_eq!(d.caret, 7);
17574        assert_eq!(
17575            drawn_rows(&d),
17576            vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
17577        );
17578        // Down walks the source lines, still revealed; typing edits the TeX.
17579        d.move_down(false);
17580        d.build_visual(80);
17581        assert_eq!(d.caret, 10);
17582        d.move_end(false);
17583        d.insert("^2");
17584        d.build_visual(80);
17585        assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
17586        assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
17587        // Out below, and it folds to the placeholder with the new TeX.
17588        d.move_down(false);
17589        d.move_down(false);
17590        d.move_down(false);
17591        d.build_visual(80);
17592        assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
17593        assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
17594    }
17595
17596    #[test]
17597    fn set_math_rows_reserves_filler_rows_by_tex() {
17598        let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
17599        caret_at(&mut d, "end");
17600        assert_eq!(d.vmap.math[0].rows_span, 0..1);
17601        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
17602        d.build_visual(80);
17603        assert_eq!(d.vmap.math[0].rows_span, 0..3);
17604        assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
17605        // Cheap when nothing changed.
17606        let key = d.visual_key();
17607        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
17608        d.build_visual(80);
17609        assert_eq!(d.visual_key(), key);
17610    }
17611
17612    #[test]
17613    fn a_dollar_typed_in_shortcuts_authors_math() {
17614        // (In `None` the same keystrokes *also* mint a formula for now: twig's
17615        // `insert_literal` does not yet escape `$` under the math extension —
17616        // see `docs/tasks/a-typed-dollar-mints-math-in-the-hidden-mode.md`.)
17617        let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
17618        d.set_markup_mode(MarkupMode::Shortcuts);
17619        d.caret = 0;
17620        d.insert("$x$");
17621        assert_eq!(d.source, "$x$\n");
17622        d.caret = 1;
17623        assert_eq!(d.breadcrumb(), "doc › para › inline_math");
17624    }
17625
17626    #[test]
17627    fn counts_see_a_formula_as_a_picture_however_it_is_written() {
17628        let c = counts_of(
17629            "counts_math",
17630            "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
17631        );
17632        // `the sum … and` is three words; neither formula counts, and the
17633        // display block is not a paragraph of text.
17634        assert_eq!(c.words, 3);
17635        assert_eq!(c.paragraphs, 1);
17636    }
17637}