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/// Where a dragged block would land — the answer to [`Doc::drop_target_at`].
404///
405/// A drop is aimed at a *row* (the one under the pointer) and lands at a
406/// *boundary* (between two blocks), and a frontend needs both halves: the
407/// offset to hand [`Doc::move_block`], and the row to draw the indicator
408/// above — which is not the row aimed at, since a drop on the lower half of a
409/// block lands below it.
410#[derive(Clone, Copy, PartialEq, Eq, Debug)]
411pub struct DropTarget {
412    /// The boundary offset — `move_block`'s `to`.
413    pub offset: usize,
414    /// The rendered row the indicator is drawn above; `rows.len()` for a drop
415    /// below everything.
416    pub row: usize,
417}
418
419/// A selection cited out of the source: the text itself, up to a requested
420/// number of characters either side, and the byte range it came from. See
421/// [`Doc::selection_quote`].
422///
423/// The prefix and suffix are what make the quote *re-findable*: the same text
424/// can occur twice, and a little of what surrounded it is how a later reader —
425/// or the same document after an edit — tells the occurrences apart. The Web
426/// Annotation model calls this a `TextQuoteSelector`; the shape is older than
427/// the name.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct Quote {
430    /// The selected source, verbatim.
431    pub exact: String,
432    /// What immediately preceded it — possibly empty, at the document's start.
433    pub prefix: String,
434    /// What immediately followed it — possibly empty, at the document's end.
435    pub suffix: String,
436    /// Byte offset in the source where the selection begins.
437    pub start: usize,
438    /// Byte offset where it ends (exclusive).
439    pub end: usize,
440}
441
442/// A host-painted range of the source — an annotation's footprint, a search
443/// hit, a reviewer's mark. Leaf renders it (a background wash behind the
444/// glyphs whose source falls inside it) and hands back the `id` when the
445/// reader activates it; what the range *means* is entirely the host's.
446///
447/// Ranges are source bytes, like the caret and the selection, so a host that
448/// anchors quotes against the source ([`Doc::selection_quote`] is the other
449/// half of that loop) can paint what it found without any coordinate
450/// conversion. A range that drifts off the text it meant is the host's to
451/// re-anchor; leaf draws what it is told.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct Highlight {
454    /// Byte offset in the source where the wash begins.
455    pub start: usize,
456    /// Byte offset where it ends (exclusive).
457    pub end: usize,
458    /// The host's name for it, handed back on activation. Opaque to leaf.
459    pub id: String,
460    /// A rendering hint the frontend maps — a `#RRGGBB` hex string, or
461    /// nothing for the theme's default wash.
462    pub color: Option<String>,
463    /// A margin glyph's name, or nothing for wash-only ink. A highlight with
464    /// a marker gets a small glyph in the margin beside its first line, and
465    /// the glyph — not the wash — is what activates it: the wash is ink, the
466    /// marker is the control, which is what lets a reader put a caret in (or
467    /// copy from) annotated text without a card leaping at them. The name is
468    /// opaque to leaf; an Apple frontend reads it as an SF Symbol, a web one
469    /// as a class.
470    pub marker: Option<String>,
471}
472
473impl Highlight {
474    /// The range covering source `offset` in a list [`Doc::set_highlights`]
475    /// sorted, first by start where several overlap — the one place that
476    /// question is answered, for the frontends that paint by asking it as well
477    /// as for [`Doc::highlight_at`].
478    ///
479    /// The list is sorted by `(start, end)`, so the scan can stop at the first
480    /// range starting past `offset` rather than running to the end. A painter
481    /// asking once per glyph wants [`HighlightCursor`] instead; this is the
482    /// one-shot form, for the host asking what the reader just activated.
483    pub fn covering(highlights: &[Highlight], offset: usize) -> Option<&Highlight> {
484        highlights
485            .iter()
486            .take_while(|h| h.start <= offset)
487            .find(|h| offset < h.end)
488    }
489}
490
491/// [`Highlight::covering`] for a caller walking the document in order — which
492/// is every painter, since a frontend draws rows top to bottom and glyphs left
493/// to right.
494///
495/// The one-shot form is a scan from the front of the list per glyph, and a
496/// document with two hundred search hits pays that two hundred times a row. A
497/// range that ends at or before an offset can never cover that offset *or any
498/// later one*, so the cursor retires those permanently and each glyph costs the
499/// ranges that actually reach it. The answer is identical to
500/// [`Highlight::covering`]'s, offset for offset — this is the same scan with
501/// the part that was being redone dropped, not a cheaper approximation.
502///
503/// Offsets are expected to arrive non-decreasing. One that goes backwards is
504/// still answered correctly: the cursor re-seats to the front, since a painter
505/// that revisits a row is asking a question the retired ranges may own again.
506pub struct HighlightCursor<'a> {
507    highlights: &'a [Highlight],
508    /// The first range not yet retired.
509    at: usize,
510    /// The last offset asked about, to notice a caller going backwards.
511    last: usize,
512}
513
514impl<'a> HighlightCursor<'a> {
515    pub fn new(highlights: &'a [Highlight]) -> Self {
516        HighlightCursor {
517            highlights,
518            at: 0,
519            last: 0,
520        }
521    }
522
523    /// The range covering `offset`, advancing the cursor past every range that
524    /// can no longer cover anything.
525    pub fn at(&mut self, offset: usize) -> Option<&'a Highlight> {
526        if offset < self.last {
527            self.at = 0;
528        }
529        self.last = offset;
530        while self
531            .highlights
532            .get(self.at)
533            .is_some_and(|h| h.end <= offset)
534        {
535            self.at += 1;
536        }
537        Highlight::covering(&self.highlights[self.at..], offset)
538    }
539}
540
541/// The identity of a built [`VisualMap`] — see [`Doc::visual_key`]. Opaque on
542/// purpose: the only useful question is whether two of them are the same map,
543/// and what is behind it — which `Doc` built it, and the (revision, wrap,
544/// reveal line) it was built from — is core's business.
545///
546/// The document is part of it because the rest is not unique to one: two
547/// documents opened at the same width are both at revision zero with no reveal
548/// line, and a frontend holding one copy of a map across the two would take
549/// the second's key for the first's and paint the wrong document.
550#[derive(Clone, PartialEq, Eq, Debug)]
551pub struct VisualKey(u64, Option<(u64, Option<usize>, Option<Reveal>)>);
552
553pub struct Doc {
554    editor: Editor,
555    pub format: Format,
556    pub path: PathBuf,
557    /// Current source, refreshed from the editor after every successful edit.
558    pub source: String,
559    /// The caret, as a byte offset into `source` (always on a char boundary).
560    pub caret: usize,
561    /// The selection's fixed end, if a selection is active; the moving end is
562    /// the caret. `None` means no selection.
563    pub anchor: Option<usize>,
564    pub dirty: bool,
565    pub status: Option<String>,
566    pub view: View,
567    /// Whether the document refuses to change — a *reading* surface over the
568    /// same rendering, selection, and navigation the editor has.
569    ///
570    /// Enforced here rather than by each frontend hiding its input paths,
571    /// because every mutation funnels through a few doors —
572    /// [`splice_exact`](Self::splice_exact), [`undo`](Self::undo),
573    /// [`redo`](Self::redo), and the handful of inserts that go to twig's own
574    /// verbs directly rather than through the splice (a typed literal, a link,
575    /// an image, a rule, a footnote, a cell's line break) — and guarded doors
576    /// are a guarantee where a frontend's suppressed keyboard is a hope. A
577    /// gated door reports exactly like a rolled-back splice, a path every
578    /// caller already handles. `a_read_only_document_refuses_every_door` is
579    /// the list; a new `self.editor.insert_*` call belongs on it.
580    read_only: bool,
581    /// The host-painted ranges, kept sorted by start — see [`Highlight`].
582    /// State like the selection rather than like the text: no edit history,
583    /// no dirty bit, redrawn from whatever the host last set.
584    highlights: Vec<Highlight>,
585    /// How much of the source markup the rich view exposes — a frontend preference (see
586    /// [`MarkupMode`]). Its two axes are read apart: the rendering one by
587    /// [`reveal_line`](Self::reveal_line), the editing one by
588    /// [`insert`](Self::insert).
589    markup_mode: MarkupMode,
590    /// Whether soft breaks fold into the reflowed paragraph or render where
591    /// they were written (see [`LineFlow`]) — an independent frontend
592    /// preference the WYSIWYG builder consults when it lays out a block.
593    line_flow: LineFlow,
594    /// The kind of the last edit, for coalescing: twig owns the undo *history*
595    /// (see `undo`/`redo`), but "what counts as one undo step" is a frontend-UX
596    /// call, so leaf decides when a run continues and tells twig to coalesce.
597    last_edit_kind: Option<EditKind>,
598    /// The inline marks the user has toggled *at a collapsed caret* with no
599    /// selection — "start typing bold here". Held as the XOR delta from the marks
600    /// already in force at [`pending_at`](Self::pending_at): a set bit means
601    /// "flip this kind for the next typed text", so it both turns a mark on where
602    /// none is (type into bold) and off where one already covers the caret (type
603    /// past the bold you're standing in). [`Doc::insert`] realises it onto the
604    /// freshly typed text and then clears it — a mark once realised is carried by
605    /// the caret sitting inside the run, not by this delta.
606    pending_marks: InlineMarks,
607    /// The caret offset [`pending_marks`](Self::pending_marks) applies to. The
608    /// delta is live only while the caret still stands here with no selection;
609    /// any motion or edit ([`move_to`](Self::move_to), a splice, a click) drops
610    /// it, so a toggled-but-never-typed format doesn't leak onto text elsewhere.
611    pending_at: Option<usize>,
612    /// The source as of the last open/save — `dirty` is `source != clean_source`,
613    /// so undoing back to the saved state correctly clears the modified flag.
614    clean_source: String,
615    /// A hash of the bytes leaf last read from `path` or wrote to it; `None`
616    /// while the document has no file behind it. [`Doc::disk_state`] compares
617    /// the file against this to catch an edit made *outside* leaf before a save
618    /// silently overwrites it — `clean_source` only knows what leaf itself did.
619    ///
620    /// A hash, not an mtime: mtime is the cheap answer and the wrong one — two
621    /// writes inside one filesystem timestamp tick are indistinguishable, a
622    /// clock that steps backwards (or a writer that restores an mtime) hides a
623    /// real change, and a `touch` invents one. The whole point of the watermark
624    /// is to not clobber someone's work, so it reads the bytes and compares what
625    /// is actually there. That costs a file read per question, which is why the
626    /// question is asked on a user event (focus, save) and not every frame.
627    disk_hash: Option<u64>,
628    /// The "sticky" display column vertical motion aims for, in the active
629    /// view's grid. Set on the first `move_up`/`move_down` of a run and
630    /// reused by every subsequent one in that run, so passing through a
631    /// shorter line doesn't permanently forget the original column. Any
632    /// horizontal motion or edit clears it.
633    ///
634    /// A column, not a character index: dropping down a line of `你好` onto one
635    /// of ASCII has to land under the glyph the caret was drawn beneath, which
636    /// is the only thing the user can see to aim by. Where the goal falls inside
637    /// a wide character on the target line, the mapping resolves it to that
638    /// character — the caret lands on it rather than between its cells.
639    goal_col: Option<usize>,
640    /// The rendered map for the WYSIWYG view; empty in the source view. Movement
641    /// and clicks read it to stay in visible space.
642    pub vmap: VisualMap,
643    /// The syntax map for the source view; empty in the WYSIWYG view, which
644    /// styles resolved glyphs instead. Built by [`Doc::build_source`] — a
645    /// frontend that never calls it paints raw source unstyled, which is what
646    /// every frontend did before this map existed.
647    pub smap: SourceMap,
648    /// The revision `smap` was built from, or `None` before the first build.
649    /// The map is a pure function of the text alone — no width, no caret, no
650    /// reveal line — so unlike [`vmap_key`](Self::vmap_key) the revision is the
651    /// whole key.
652    smap_key: Option<u64>,
653    /// Everything the map is built from, as one number: bumped whenever the
654    /// document's text changes, and never by a motion, a selection, or a save.
655    /// A frontend can hold work against it — see [`Doc::revision`].
656    revision: u64,
657    /// How many history steps stand behind the caret, and how many ahead of
658    /// it — the answer to a native Edit menu's "may Undo be enabled?", which
659    /// twig's history does not ask itself. Counted at [`refresh`](Self::refresh),
660    /// the funnel every edit comes through, and moved back and forth by
661    /// [`undo`](Self::undo)/[`redo`](Self::redo). An upper bound rather than
662    /// an exact depth: a coalesced run of typing is one of twig's steps but
663    /// several of these, and twig's own cap on history is not mirrored here.
664    /// Neither error can make `can_undo` false while a step remains, which is
665    /// the only property a menu needs; the one place the bound can be wrong the
666    /// other way — the cap has retired every step — is reconciled the moment
667    /// twig reports nothing to undo.
668    undo_steps: usize,
669    redo_steps: usize,
670    /// What `vmap` was built from, or `None` before the first build. The map is
671    /// a pure function of `(revision, wrap, reveal line)`, so when those haven't
672    /// moved, rebuilding it produces the identical map — see
673    /// [`Doc::build_visual`].
674    ///
675    /// The reveal line ([`Doc::reveal_line`]) is the caret's, and is `None` in
676    /// every mode but [`MarkupMode::Full`] — so outside that mode the key is
677    /// text and width alone, and a caret motion still rebuilds nothing.
678    vmap_key: Option<(u64, Option<usize>, Option<Reveal>)>,
679    /// Which `Doc` this is, distinct from every other one built in this
680    /// process. Folded into [`VisualKey`] so that a map stashed by a frontend
681    /// can never be mistaken for another document's — see
682    /// [`Doc::visual_key`]. Nothing else reads it.
683    identity: u64,
684    /// Per-block row cache backing the incremental rebuild: when the text
685    /// changes, only the top-level blocks whose bytes moved are re-rendered and
686    /// the rest are reused shifted (see [`wysiwyg::BlockCache`]). Persists across
687    /// builds; a pure accelerator, so it's never read for correctness.
688    block_cache: wysiwyg::BlockCache,
689    /// What the frontend has said about itself — how tall its pictures came
690    /// out, keyed by destination or by TeX, and whether it paints a picture in
691    /// a line — set through [`Doc::set_media_rows`], [`Doc::set_math_rows`] and
692    /// [`Doc::set_inline_pictures`]. Core does no I/O and lays out in glyphs,
693    /// so this is the only way it learns a height or a capability. Threaded
694    /// into every build; a change drops both caches, since none of it is in a
695    /// block's bytes.
696    surface: wysiwyg::Surface,
697
698    // View geometry the renderer stamps each frame, so mouse events can map a
699    // screen cell back to a byte offset.
700    pub scroll: usize,
701    pub body_origin: (u16, u16),
702    /// Width of the body rectangle last painted by the frontend. Zero means
703    /// unknown (used by tests or a frontend that has not drawn yet).
704    pub body_width: u16,
705    pub body_height: u16,
706    /// The caret as of the last frame drawn, or `None` before the first.
707    ///
708    /// Scrolling is the viewport's business, not the caret's: the view follows
709    /// the caret when the caret *moves*, but a wheel that doesn't touch the
710    /// caret has to be free to scroll away from it — otherwise the view is
711    /// pinned to the caret and stops dead at the edge of the document you can
712    /// see. Comparing against this is what tells the two apart, and it catches a
713    /// caret set by any route, including a frontend assigning the field itself.
714    pub drawn_caret: Option<usize>,
715}
716
717/// The Markdown extensions every leaf document is parsed with — five of them,
718/// each departing from twig's defaults for a reason leaf can state.
719///
720/// `html_elements` promotes embedded raw HTML (`<img>`, `<picture>`,
721/// `<source>`, …) into semantic AST nodes, so a picture becomes a real `image`
722/// node the frontends can frame and rasterize instead of opaque `raw_block`
723/// text. `directives` turns on generic `:::name{.class}` fenced-div containers
724/// (`directive` nodes), which a host app uses for its own semantics (diaryx's
725/// `:::vis{.audience}` visibility blocks) — core renders any directive as a
726/// plain tinted container, agnostic of `name`.
727///
728/// `highlight` and `highlight_colors` are the pair that makes Markdown read
729/// `==text==` as a `mark` node, and `==🔴 text==` as one carrying a
730/// `data-color`. leaf already had somewhere to put both: the
731/// [`Mark`](crate::Role::Mark) role and the ⌘⇧M highlight button predate them,
732/// and until twig 3.3 a Markdown document could only ever *receive* a highlight
733/// from a Djot one it was converted from — the button wrote `==…==` and the
734/// reparse read it straight back as text.
735/// They are on together because a colour is inert without the highlight itself,
736/// and a document that writes `==🔴 x==` means the colour by it.
737///
738/// `math` makes Markdown read `$…$` as an `inline_math` node and `$$…$$` as
739/// a `display_math` one — what djot reads natively and what leaf has
740/// somewhere to put: a formula typesets to a picture, or reveals to its TeX
741/// on the caret's line. Without it a `$$` block is a paragraph whose `\,`
742/// twig has already read as an escaped comma, and an author who types a
743/// backslash in it is authoring Markdown, not TeX. The flag is bounded by
744/// twig's own rule that a dollar followed by whitespace never opens math, so
745/// `$5 and $6` stays prose.
746///
747/// Every flag is inert for non-Markdown formats, so it's safe to pass them
748/// unconditionally. Threading this through every constructor (not just `open`)
749/// keeps `from_source`, `blank`, and `reload` parsing the same document the same
750/// way — twig reparses with these same flags after each edit.
751pub(crate) fn parse_extensions() -> MarkdownExtensions {
752    MarkdownExtensions {
753        html_elements: true,
754        directives: true,
755        highlight: true,
756        highlight_colors: true,
757        math: true,
758    }
759}
760
761/// Build an editor over `bytes` in `format` with leaf's [`parse_extensions`],
762/// mapping twig's error into the `anyhow` context every constructor shares.
763fn new_editor(bytes: &[u8], format: Format) -> Result<Editor> {
764    Editor::new_ext(bytes, format, parse_extensions()).map_err(|e| anyhow!("twig parse: {e}"))
765}
766
767/// Does `format` spell a table as a **pipe table** — the one grid twig's table
768/// editor knows how to emit?
769///
770/// This is the single capability leaf still has to answer for itself, and the
771/// only hand-maintained format list left in this file. Every other gesture is
772/// [`Format::supports`], which is twig's own answer read across the C ABI — but
773/// twig deliberately leaves the table ops out of that query, because they read
774/// no `Syntax` table at all. They rewrite a grid that is already in the source
775/// and refuse on *position*, never on format. Handed a caret inside an HTML
776/// `<table>`, `table_insert_row` therefore re-emits the whole element as
777/// `| a | b |` and reports success — a real splice, a clean reparse, an honest
778/// `dirty` flag, and nothing downstream able to tell it from a good edit.
779///
780/// So the list is narrow on purpose. `Format` is `#[non_exhaustive]`, and the
781/// wildcard answers "no" for a format leaf has never heard of: a new twig
782/// language that *does* spell pipe tables loses its grid controls until this
783/// line is updated, which shows up as a missing button. The other default hands
784/// it to [`Doc::table_op`], which rewrites documents it cannot spell.
785fn spells_pipe_tables(format: Format) -> bool {
786    matches!(format, Format::Markdown | Format::Djot)
787}
788
789/// Which of leaf's authoring controls this document's format can actually
790/// spell — one flag per toolbar button, resolved once so a frontend can build
791/// its chrome instead of discovering each refusal on a click.
792///
793/// Every field but [`table`](Self::table) is `Format::supports_with` on the
794/// gesture the matching [`Doc`] method calls, so this record cannot drift from
795/// what the ops do; `table` is [`spells_pipe_tables`], the one answer twig
796/// doesn't export.
797///
798/// `supports_with` rather than `supports` because two of these are facts about
799/// the *parse options* as much as about the format. `Format::supports` answers
800/// for twig's defaults, and leaf never parses with those — it parses with
801/// [`parse_extensions`], and a document's toolbar has to describe the document
802/// it is over. A Markdown editor holding `highlight` authors `==text==`; one
803/// without it would mint bytes its own reparse hands back as plain text, which
804/// is why twig asks before it writes.
805///
806/// **The formats are ragged, and that is the point.** A single per-document
807/// boolean was enough while the two authorable formats were Markdown and djot
808/// and everything else spelled nothing. HTML is neither: it writes seven of the
809/// eight inline marks as a tag pair, plus `<code>`, `<hr>`, an in-cell
810/// `<br>`, and — since twig 3.4 — a heading or paragraph rebuilt as its tag
811/// pair, and since 3.5 a quote, a list, a code block, a link and an image
812/// printed as fresh nodes; it spells no task box (a form control there) and
813/// no footnote, and its `<table>` is one twig reads but will not write. So
814/// ⌘B, ⌘1 and the quote button work in an HTML document and the task and
815/// table buttons do not, and no one flag can say that. Markdown and djot
816/// differ from each other too:
817/// `^superscript^` is djot-only, and an in-cell `<br>` is Markdown-only.
818#[derive(Clone, Copy, Debug, Eq, PartialEq)]
819pub struct Capabilities {
820    /// ⌘B — `InlineKind::Strong`.
821    pub bold: bool,
822    /// ⌘I — `InlineKind::Emph`.
823    pub italic: bool,
824    /// Inline code — `InlineKind::Verbatim`.
825    pub code: bool,
826    /// Highlight — `InlineKind::Mark`. Djot spells it, and so does Markdown
827    /// under the `highlight` extension [`parse_extensions`] turns on: the
828    /// button writes `==text==`, which is what the reparse reads back.
829    pub mark: bool,
830    /// ⌘U — `InlineKind::Insert`, which every format that marks at all spells.
831    pub underline: bool,
832    /// Strikethrough — `InlineKind::Delete`. Markdown spells GFM's `~~text~~`
833    /// out of the box, since twig parses it out of the box.
834    pub strike: bool,
835    /// The highlight *palette* — [`Doc::set_mark_color`]. Narrower than
836    /// [`mark`](Self::mark) and deliberately its own flag: Markdown spells a
837    /// colour on a highlight (`==🔴 text==`) and djot spells only the highlight,
838    /// so a toolbar offering the swatches wherever the button lights would offer
839    /// them in a document that cannot write one. Pair with
840    /// [`Doc::caret_in_mark`], which asks the other question — the palette needs
841    /// a highlight to colour as much as a format that spells one.
842    pub mark_color: bool,
843    pub superscript: bool,
844    pub subscript: bool,
845    /// Heading levels and "make this a paragraph" — [`Doc::set_block`].
846    pub heading: bool,
847    pub blockquote: bool,
848    pub bullet_list: bool,
849    pub ordered_list: bool,
850    /// The checkbox controls: giving an item a box, and ticking one.
851    pub task: bool,
852    pub link: bool,
853    /// Covers [`Doc::insert_media`] too — see the note there on why the three
854    /// media kinds stand or fall together.
855    pub image: bool,
856    /// The horizontal-rule button. HTML spells this one (`<hr>`).
857    pub thematic_break: bool,
858    /// The footnote button — [`Doc::insert_footnote`]. Markdown and djot spell
859    /// the pair; HTML has no footnote of its own, so the button goes away rather
860    /// than writing brackets that would render as brackets.
861    pub footnote: bool,
862    /// The code-block button — [`Doc::toggle_code_block`], twig's
863    /// `Gesture::ToggleCodeBlock`. Markdown and djot spell the fence; HTML
864    /// rebuilds the block as `<pre><code>`.
865    pub code_block: bool,
866    /// Setting a fenced block's language — a control only ever offered with the
867    /// caret already in a fence.
868    pub code_language: bool,
869    /// The grid controls: insert/delete/move a row or column, set a column's
870    /// alignment. Pair with [`Doc::caret_in_table`], which asks the other
871    /// question — an HTML `<table>` holds the caret and still can't be edited.
872    pub table: bool,
873    /// Shift+Return inside a cell. Markdown and HTML spell it; djot has no
874    /// idiomatic in-cell break.
875    pub cell_line_break: bool,
876    /// The alignment control — [`Doc::set_alignment`], twig's
877    /// `Gesture::SetBlockAttrs`. Every format leaf opens but XML spells a
878    /// block's attributes, Markdown under the `html_elements`
879    /// [`parse_extensions`] turns on (a `<div>` around the block) and AsciiDoc
880    /// through its `[…]` line.
881    pub alignment: bool,
882    /// The line-spacing menu — [`Doc::set_line_spacing`]. The same gesture as
883    /// [`alignment`](Self::alignment) and so the same answer, and its own flag
884    /// because a toolbar dims controls one at a time and the pair may yet
885    /// diverge.
886    pub line_spacing: bool,
887    /// The size menu — [`Doc::set_font_size`], twig's `Gesture::WrapRangeAttrs`
888    /// over a selection. **Narrower than the block pair**: AsciiDoc's
889    /// `[#id.role]#text#` keeps an id and a role and has no slot for a
890    /// `data-` key, so twig refuses the span there and this is `false` while
891    /// [`alignment`](Self::alignment) is `true`. The block-level form of the
892    /// same property — the caret in a paragraph, no selection — goes through
893    /// `SetBlockAttrs` and still works, which is why the flag describes the
894    /// control rather than the caret.
895    pub font_size: bool,
896    /// The face menu — [`Doc::set_font_family`]. `WrapRangeAttrs`, as
897    /// [`font_size`](Self::font_size) is.
898    pub font_family: bool,
899    /// The text-colour swatches — [`Doc::set_text_color`]. `WrapRangeAttrs`,
900    /// and not to be confused with [`mark_color`](Self::mark_color): that is a
901    /// highlight's background and rides the `mark` node twig already owns,
902    /// this is a run's foreground and rides an attributed span.
903    pub text_color: bool,
904    /// The page-break button — [`Doc::insert_page_break`], twig's
905    /// `Gesture::InsertDirective`. Markdown under the `directives` extension
906    /// [`parse_extensions`] turns on (`::page-break`) and djot, which spells
907    /// it as an empty `::: page-break` fence.
908    ///
909    /// **Those two and no others**, though twig spells the gesture in HTML and
910    /// AsciiDoc as well — see [`Capabilities::of`].
911    pub page_break: bool,
912    /// Moving a block — [`Doc::move_block`] and the Alt+↑/↓ pair, twig's
913    /// `Gesture::MoveBlock`. Every format with blocks a caret can name; XML
914    /// has none.
915    pub move_block: bool,
916}
917
918impl Capabilities {
919    /// Resolve every flag for `format`, as leaf parses it. Pure and cheap —
920    /// twig computes each from a static table — but a frontend that wants to
921    /// hold them can.
922    ///
923    /// The extensions are not a parameter because they are not a choice a
924    /// caller makes: every leaf document is parsed with [`parse_extensions`],
925    /// so the format is the whole of what varies.
926    pub fn of(format: Format) -> Self {
927        let exts = parse_extensions();
928        let supports = |g| format.supports_with(exts, g);
929        let inline = |k| supports(Gesture::ToggleInline(k));
930        let container = |k| supports(Gesture::ToggleBlockContainer(k));
931        Self {
932            bold: inline(InlineKind::Strong),
933            italic: inline(InlineKind::Emph),
934            code: inline(InlineKind::Verbatim),
935            mark: inline(InlineKind::Mark),
936            underline: inline(InlineKind::Insert),
937            strike: inline(InlineKind::Delete),
938            mark_color: supports(Gesture::SetMarkColor),
939            superscript: inline(InlineKind::Superscript),
940            subscript: inline(InlineKind::Subscript),
941            heading: supports(Gesture::SetBlock),
942            blockquote: container(BlockContainerKind::BlockQuote),
943            bullet_list: container(BlockContainerKind::BulletList),
944            ordered_list: container(BlockContainerKind::OrderedList),
945            // Both halves of the checkbox story, and leaf offers no control that
946            // needs only one: the item gesture mints the box, the checked one
947            // ticks it, and a format spelling a `task_marker` spells both.
948            task: supports(Gesture::ToggleTaskItem) && supports(Gesture::ToggleTaskChecked),
949            link: supports(Gesture::InsertLink),
950            image: supports(Gesture::InsertImage),
951            thematic_break: supports(Gesture::InsertThematicBreak),
952            footnote: supports(Gesture::InsertFootnote),
953            code_block: supports(Gesture::ToggleCodeBlock),
954            code_language: supports(Gesture::SetCodeLanguage),
955            table: spells_pipe_tables(format),
956            cell_line_break: supports(Gesture::InsertLineBreak),
957            // The presentation vocabulary, one gesture per level: the two
958            // line-level properties are a block's attributes and the three
959            // run-level ones a span's. They are asked separately because the
960            // formats answer differently — AsciiDoc spells the block and not
961            // the span — and a toolbar that dimmed all five together would dim
962            // three controls that work.
963            alignment: supports(Gesture::SetBlockAttrs),
964            line_spacing: supports(Gesture::SetBlockAttrs),
965            font_size: supports(Gesture::WrapRangeAttrs),
966            font_family: supports(Gesture::WrapRangeAttrs),
967            text_color: supports(Gesture::WrapRangeAttrs),
968            // Narrower than the gesture, on purpose. Twig spells
969            // `InsertDirective` in HTML and AsciiDoc too, and spells it
970            // *differently* there — `<page-break></page-break>` and `<<<` —
971            // and the walker reads only the two spellings above. An HTML page
972            // break draws as nothing at all (no row, no caret home) and an
973            // AsciiDoc one as an empty unlabelled row, so the button would
974            // write a break the author cannot see and cannot get back to.
975            // The proposal claims Markdown and djot, and this is that claim.
976            // Widening it is the walker's work, not this line's — see
977            // `docs/tasks/page-break-in-html-and-asciidoc.md`.
978            page_break: supports(Gesture::InsertDirective)
979                && matches!(format, Format::Markdown | Format::Djot),
980            move_block: supports(Gesture::MoveBlock),
981        }
982    }
983}
984
985/// The source of [`Doc::identity`], one per document ever built.
986static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
987
988impl Doc {
989    #[cfg(feature = "fs")]
990    pub fn open(path: PathBuf) -> Result<Self> {
991        let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
992        Self::from_disk_bytes(path, bytes)
993    }
994
995    /// An empty document *named* `path`, for a file that isn't there yet — what
996    /// every other terminal editor gives you when you name a file that doesn't
997    /// exist. It is a real named document, not a [`Doc::blank`]: `is_untitled`
998    /// is false, so ⌘S writes straight to `path` with no Save As detour, and
999    /// the header shows the name the user asked for.
1000    ///
1001    /// The format comes from the extension, exactly as [`Doc::open`] reads it —
1002    /// so `leaf notes.dj` starts a djot buffer rather than the Markdown
1003    /// [`Doc::blank`] has to assume for want of a name. An extension leaf can't
1004    /// parse is still an error: a mistyped flag or a stray argument should say
1005    /// so, not open a buffer promising to save somewhere.
1006    ///
1007    /// The watermark is the hash of *no bytes*, not `None`, and that is the
1008    /// whole trick: `None` means untitled, and would leave [`Doc::disk_state`]
1009    /// answering [`DiskState::Untitled`] for a document that has a path and
1010    /// intends to write to it. Hashing `""` instead makes the answers the true
1011    /// ones — [`DiskState::Missing`] while the file still isn't there (a save
1012    /// recreates it, which is exactly what this is for), and
1013    /// [`DiskState::Changed`] if somebody creates it underneath us between
1014    /// launch and save, so the frontend's overwrite prompt guards a new file as
1015    /// it guards an opened one.
1016    ///
1017    /// Nothing is written here. A buffer that is never typed into never touches
1018    /// the filesystem, and a `path` whose directory doesn't exist is allowed to
1019    /// open — the write is where that fails, and it says so then.
1020    #[cfg(feature = "fs")]
1021    pub fn create(path: PathBuf) -> Result<Self> {
1022        Self::from_disk_bytes(path, Vec::new())
1023    }
1024
1025    /// [`Doc::open`] when the file is there, [`Doc::create`] when it isn't —
1026    /// the call a CLI frontend wants for its path argument.
1027    ///
1028    /// The decision is made from the failed read itself rather than a `exists()`
1029    /// check first, so there is no window between the two for the file to appear
1030    /// or vanish in. Only `NotFound` opens a new buffer: a permissions error or
1031    /// a directory in the way is still an error, because pretending those are
1032    /// "no file yet" would offer to save over something leaf couldn't read.
1033    #[cfg(feature = "fs")]
1034    pub fn open_or_create(path: PathBuf) -> Result<Self> {
1035        match std::fs::read(&path) {
1036            Ok(bytes) => Self::from_disk_bytes(path, bytes),
1037            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::create(path),
1038            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
1039        }
1040    }
1041
1042    /// The shared body of [`Doc::open`] and [`Doc::create`]: bytes that are (or
1043    /// stand in for) the file at `path`, parsed as the format its extension
1044    /// names. Keeping the two on one path is what makes a new file's document
1045    /// identical in every respect to an opened one but its contents.
1046    #[cfg(feature = "fs")]
1047    fn from_disk_bytes(path: PathBuf, bytes: Vec<u8>) -> Result<Self> {
1048        let format = detect_format(&path)?;
1049        let editor = new_editor(&bytes, format)?;
1050        let source = String::from_utf8(bytes).map_err(|_| anyhow!("document is not UTF-8"))?;
1051        let disk_hash = Some(hash_bytes(source.as_bytes()));
1052        // Store the document's *absolute* path. A relative one (`leaf README.md`)
1053        // has an empty parent, so a frontend can't resolve a relative image
1054        // destination (`![](pic.png)`) against the document's directory and the
1055        // picture silently falls back to its text placeholder. `absolute` is
1056        // purely lexical — it prefixes the current directory and normalizes, but
1057        // reads nothing and resolves no symlinks — so `file_name` and save are
1058        // unchanged; it only gives `path.parent()` something to join against.
1059        let path = std::path::absolute(&path).unwrap_or(path);
1060        Ok(Doc::from_parts(editor, format, path, source, disk_hash))
1061    }
1062
1063    /// Build a document from an in-memory string, the format named explicitly —
1064    /// the portable, filesystem-free counterpart to [`Doc::open`] (which reads a
1065    /// path and sniffs the format from its extension). A wasm or FFI host, which
1066    /// has no path to read, uses this: it hands over bytes it fetched however it
1067    /// could, and later persists [`Doc::source`] however it can (a browser
1068    /// download, `localStorage`, a backend `PUT`) and calls [`Doc::mark_saved`].
1069    ///
1070    /// No file backs the result, so it starts untitled ([`Doc::is_untitled`] is
1071    /// true) exactly like a [`Doc::blank`] that has been given content.
1072    pub fn from_source(source: String, format: Format) -> Result<Self> {
1073        let editor = new_editor(source.as_bytes(), format)?;
1074        Ok(Doc::from_parts(
1075            editor,
1076            format,
1077            PathBuf::new(),
1078            source,
1079            None,
1080        ))
1081    }
1082
1083    /// An untitled, empty document — the `+` button and a `leaf` launched with
1084    /// no file argument. Nothing on disk backs it until a [`Doc::save_as`].
1085    ///
1086    /// It is Markdown, because a format has to be chosen before a name exists to
1087    /// read one from: `detect_format` reads the extension and an untitled
1088    /// document has neither. Markdown is what leaf's own files are, what its
1089    /// block markers are already written for (`insert_block_prefix`), and the
1090    /// extension a Save As will overwhelmingly pick — a wrong guess here would
1091    /// mean typing djot into a buffer parsing it as Markdown. Note that Save As
1092    /// *doesn't* revisit this: see [`Doc::save_as`].
1093    pub fn blank() -> Result<Self> {
1094        let format = Format::Markdown;
1095        let editor = new_editor(b"", format)?;
1096        // An empty `path` is the untitled marker (`path` is a public `PathBuf`
1097        // field two frontends already read; making it an `Option` to say this
1098        // would break both). `is_untitled` is the question to ask, not the
1099        // representation to copy.
1100        Ok(Doc::from_parts(
1101            editor,
1102            format,
1103            PathBuf::new(),
1104            String::new(),
1105            None,
1106        ))
1107    }
1108
1109    /// The fields every constructor agrees on, so `open` and `blank` can't drift
1110    /// apart in the ones neither of them has an opinion about.
1111    // `identity` is taken from a counter rather than from the `Doc`'s address,
1112    // which moves — a session that holds one is moved into and out of
1113    // containers freely, and an identity that changed with it would defeat the
1114    // one comparison it exists for.
1115    fn from_parts(
1116        editor: Editor,
1117        format: Format,
1118        path: PathBuf,
1119        source: String,
1120        disk_hash: Option<u64>,
1121    ) -> Self {
1122        Doc {
1123            editor,
1124            format,
1125            path,
1126            disk_hash,
1127            clean_source: source.clone(),
1128            source,
1129            caret: 0,
1130            anchor: None,
1131            dirty: false,
1132            status: None,
1133            read_only: false,
1134            highlights: Vec::new(),
1135            // leaf opens in the rich-text (WYSIWYG) view by default — the
1136            // markup-resolved surface is leaf's differentiator. Frontends can
1137            // still start in source view explicitly (e.g. a CLI flag), and ⌘e/⌥w
1138            // toggles at runtime.
1139            view: View::Wysiwyg,
1140            // `None` by default — the clean surface Diaryx ships, with typed
1141            // syntax kept literal; a markup-fluent frontend can climb the
1142            // ladder to `Shortcuts` or `Full`.
1143            markup_mode: MarkupMode::default(),
1144            // Fold by default — flowing prose that reflows to the viewport, the
1145            // behaviour every frontend had before this preference existed.
1146            line_flow: LineFlow::default(),
1147            last_edit_kind: None,
1148            pending_marks: InlineMarks::empty(),
1149            pending_at: None,
1150            goal_col: None,
1151            vmap: VisualMap::default(),
1152            smap: SourceMap::default(),
1153            // No map yet — the first `build_source` always builds.
1154            smap_key: None,
1155            revision: 0,
1156            undo_steps: 0,
1157            redo_steps: 0,
1158            // No map yet — the first `build_visual` always builds.
1159            vmap_key: None,
1160            identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1161            block_cache: wysiwyg::BlockCache::default(),
1162            surface: wysiwyg::Surface::default(),
1163            scroll: 0,
1164            body_origin: (0, 0),
1165            body_width: 0,
1166            body_height: 0,
1167            drawn_caret: None,
1168        }
1169    }
1170
1171    /// Whether this document has no file behind it yet — a [`Doc::blank`] that
1172    /// has never been saved. The question a ⌘S handler asks to know it should
1173    /// open a Save As picker instead ([`Doc::save`] won't guess a name), and the
1174    /// header asks to know the name it shows is a placeholder.
1175    pub fn is_untitled(&self) -> bool {
1176        self.path.as_os_str().is_empty()
1177    }
1178
1179    pub fn toggle_view(&mut self) {
1180        self.view = match self.view {
1181            View::Source => View::Wysiwyg,
1182            View::Wysiwyg => View::Source,
1183        };
1184        self.scroll = 0;
1185        self.status = None;
1186        // Entering WYSIWYG, the caret may be sitting in now-hidden frontmatter;
1187        // lift it to the first rendered offset.
1188        self.clamp_caret();
1189    }
1190
1191    /// The current markup-exposure preference (see [`MarkupMode`]).
1192    pub fn markup_mode(&self) -> MarkupMode {
1193        self.markup_mode
1194    }
1195
1196    /// Set the markup-exposure preference. Both of its axes take effect at
1197    /// once: the editing one on the next [`insert`](Self::insert), and the
1198    /// rendering one on the next build — which is why this drops the cached
1199    /// visual map and the per-block render cache, exactly as
1200    /// [`set_line_flow`](Self::set_line_flow) does.
1201    pub fn set_markup_mode(&mut self, mode: MarkupMode) {
1202        if self.markup_mode == mode {
1203            return;
1204        }
1205        self.markup_mode = mode;
1206        // Neither cache is keyed on the mode, and moving between `Full` and the
1207        // hidden modes changes every row the caret's line renders to — so
1208        // invalidate both explicitly.
1209        self.vmap_key = None;
1210        self.block_cache = wysiwyg::BlockCache::default();
1211    }
1212
1213    /// The line the caret sits on, when that line should render something
1214    /// raw — `None` when nothing on it would, which is what the builder reads
1215    /// as "reveal nothing" and what keeps caret motion from costing a build.
1216    ///
1217    /// Two things ask for it. Under [`MarkupMode::Full`] every delimiter on
1218    /// the caret's line shows ([`Reveal::full`]). In the two hidden modes a
1219    /// *formula* on it still shows its TeX ([`Reveal::math`]), because a
1220    /// formula's content is not its picture and hiding the `$` alone would
1221    /// leave nothing to edit; there the line is threaded through only when it
1222    /// meets a block that holds one, which the last build's layout knows
1223    /// ([`wysiwyg::BlockCache::math_meets`]) — so a document with no math
1224    /// keeps the `None` it always had, and one with math pays a rebuild only
1225    /// while the caret is in the formula's block.
1226    ///
1227    /// A *source* line (newline to newline), not a visual row: a wrapped
1228    /// paragraph and a `LineFlow::Preserve` soft break both split one source
1229    /// line across several rows, and revealing half a delimiter pair because the
1230    /// other half wrapped would be worse than revealing neither. The range
1231    /// excludes the terminating newline and is empty-but-present on a blank
1232    /// line, which reveals nothing but still keys the caches correctly.
1233    ///
1234    /// Only in [`View::Wysiwyg`]: source view already shows every byte, so
1235    /// there is nothing there to reveal.
1236    pub(crate) fn reveal_line(&self) -> Option<Reveal> {
1237        if self.view != View::Wysiwyg {
1238            return None;
1239        }
1240        let line = source_line_range(&self.source, self.caret);
1241        if self.markup_mode.reveals_caret_line() {
1242            return Some(Reveal::full(line));
1243        }
1244        self.block_cache
1245            .math_meets(&line)
1246            .then_some(Reveal::math(line))
1247    }
1248
1249    /// The current soft-break flow preference (see [`LineFlow`]).
1250    pub fn line_flow(&self) -> LineFlow {
1251        self.line_flow
1252    }
1253
1254    /// Set the soft-break flow preference. The mode changes how every block lays
1255    /// out, so a change drops the cached visual map and the per-block render
1256    /// cache, forcing the next [`build_visual`] to rebuild under the new flow.
1257    ///
1258    /// [`build_visual`]: Self::build_visual
1259    pub fn set_line_flow(&mut self, mode: LineFlow) {
1260        if self.line_flow == mode {
1261            return;
1262        }
1263        self.line_flow = mode;
1264        // Both caches are keyed on `(revision, wrap)`, neither of which moved —
1265        // so invalidate them explicitly, or the next build would reuse rows laid
1266        // out under the old flow.
1267        self.vmap_key = None;
1268        self.block_cache = wysiwyg::BlockCache::default();
1269    }
1270
1271    pub fn view_name(&self) -> &'static str {
1272        match self.view {
1273            View::Source => "source",
1274            View::Wysiwyg => "wysiwyg",
1275        }
1276    }
1277
1278    /// Rebuild the WYSIWYG visual map for the current tree at `width` columns
1279    /// (called by the renderer each frame it's in the WYSIWYG view).
1280    /// Build the WYSIWYG map, wrapped at `width` display columns.
1281    ///
1282    /// Cheap to call every frame, which is what both frontends do: the map is a
1283    /// pure function of the document and the wrap width, so a call that would
1284    /// rebuild the same map returns the one already built. Only an edit (or a
1285    /// resize) pays.
1286    ///
1287    /// That isn't a micro-optimisation. A frontend repaints for reasons that have
1288    /// nothing to do with the text — a blinking caret, a scroll, a focus change —
1289    /// and rebuilding here is O(document): 23 ms on a 1 MB file, of which 5 ms is
1290    /// marshalling twig's AST across the C ABI. Paid twice a second by the GUI's
1291    /// blink timer, that was 14% of a core spent redrawing an unchanged document.
1292    /// (`cargo run --release -p leaf-core --example bench` for the numbers.)
1293    pub fn build_visual(&mut self, width: usize) {
1294        self.build_map(Some(width));
1295    }
1296
1297    /// Build the WYSIWYG map with each block as a single unwrapped row — for a
1298    /// frontend (the GUI) that wraps at its own proportional pixel width rather
1299    /// than a fixed character column.
1300    pub fn build_visual_unwrapped(&mut self) {
1301        self.build_map(None);
1302    }
1303
1304    /// Build the source view's syntax map ([`Doc::smap`]) — the styling for
1305    /// [`View::Source`], the way [`build_visual`](Self::build_visual) is the
1306    /// styling for [`View::Wysiwyg`].
1307    ///
1308    /// A frontend calls this before painting raw source. One that doesn't gets
1309    /// an empty map and paints unstyled text, so this is additive: nothing
1310    /// breaks by not calling it.
1311    ///
1312    /// Built at most once per revision, and the revision is the whole key — the
1313    /// map has no width and no caret in it, so it survives every resize, every
1314    /// motion, and every selection change.
1315    ///
1316    /// The builds it does do cost a whole-arena marshal, which is precisely what
1317    /// the WYSIWYG path works to avoid, so this has no incremental path where
1318    /// that one has two. From `cargo run --release -p leaf-core --example
1319    /// bench`, per keystroke, against the WYSIWYG build the source view is
1320    /// *not* doing:
1321    ///
1322    /// |  size |  nodes | marshal | `source::build` | (`wysiwyg::build`) |
1323    /// |------:|-------:|--------:|----------------:|-------------------:|
1324    /// |  10 KB|    613 |  0.16 ms|         0.07 ms |            0.28 ms |
1325    /// | 100 KB|  6 097 |  0.84 ms|         0.38 ms |            2.43 ms |
1326    /// |   1 MB| 60 601 |  5.67 ms|         3.12 ms |           23.39 ms |
1327    ///
1328    /// Linear, two thirds of it the marshal, and the build itself five to seven
1329    /// times cheaper than the one it stands in for at every size. Comfortable
1330    /// well past any document a person edits in a terminal — a megabyte is where
1331    /// it would want [`Editor::dirty_range`] and the same splice treatment
1332    /// `build_spliced` gives the other map. The door is open; nothing has needed
1333    /// it yet.
1334    pub fn build_source(&mut self) {
1335        if self.smap_key == Some(self.revision) {
1336            return;
1337        }
1338        let nodes = self.nodes();
1339        self.smap = source::build(&nodes, &self.source);
1340        self.smap_key = Some(self.revision);
1341    }
1342
1343    /// Tell the model how many visual rows each block image should reserve, keyed
1344    /// by the image's destination. A terminal frontend calls this once it has
1345    /// decoded and measured its pictures — core does no image I/O, so this is the
1346    /// only way it learns a height — and the next [`Doc::build_visual`] lays each
1347    /// placeholder out that tall (the label row plus blank filler rows the
1348    /// frontend paints the raster over). A destination left out of the map falls
1349    /// back to the bare one-row placeholder, which is also what a frontend that
1350    /// can't draw pictures (or lays them out in its own units, like the GUI) gets
1351    /// by never calling this.
1352    ///
1353    /// Cheap to call every frame with the same map: only a *change* invalidates
1354    /// the built map (and the block-row cache, since a height isn't part of a
1355    /// block's bytes and so wouldn't otherwise re-render it). Steady state is a
1356    /// no-op, so a frontend can just hand over its current measurements each frame.
1357    pub fn set_media_rows(&mut self, rows: HashMap<String, usize>) {
1358        if self.surface.media_rows == rows {
1359            return;
1360        }
1361        self.surface.media_rows = rows;
1362        self.surface_changed();
1363    }
1364
1365    /// Tell the model how many visual rows each display formula should
1366    /// reserve, keyed by the formula's TeX exactly as the map's
1367    /// [`MathInfo::tex`](wysiwyg::MathInfo::tex) handed it over. The peer of
1368    /// [`set_media_rows`](Self::set_media_rows) for the terminal, which
1369    /// typesets the picture, measures it in cells, and reports back; a
1370    /// frontend that lays formulas out in pixels never calls this and gets
1371    /// the one-row placeholder to paint over.
1372    pub fn set_math_rows(&mut self, rows: HashMap<String, usize>) {
1373        if self.surface.math_rows == rows {
1374            return;
1375        }
1376        self.surface.math_rows = rows;
1377        self.surface_changed();
1378    }
1379
1380    /// Tell the model whether the frontend can paint a picture *inside* a line
1381    /// of text. When it can, an inline formula renders to one atom glyph the
1382    /// frontend draws its typeset picture over — see
1383    /// [`MathInfo`](wysiwyg::MathInfo) — and when it cannot (a terminal), to
1384    /// the code-styled TeX it always showed. Off until a frontend says
1385    /// otherwise, so a host that has not caught up sees what it saw.
1386    pub fn set_inline_pictures(&mut self, on: bool) {
1387        if self.surface.inline_pictures == on {
1388            return;
1389        }
1390        self.surface.inline_pictures = on;
1391        self.surface_changed();
1392    }
1393
1394    /// A height or a capability lives outside a block's source bytes, so the
1395    /// content-keyed block cache would hand back the old rows on a hit. Drop
1396    /// it (and the splice layout it carries) so the next build re-renders
1397    /// every block against the new surface, and force that build by clearing
1398    /// the map key.
1399    fn surface_changed(&mut self) {
1400        self.block_cache = wysiwyg::BlockCache::default();
1401        self.vmap_key = None;
1402    }
1403
1404    /// The revision the document's text is at — bumped by every edit, undo,
1405    /// redo, and reload, and by nothing else. A frontend caches against this to
1406    /// tell a repaint that needs new work from one that doesn't.
1407    ///
1408    /// It counts *edits*, not distinct texts: typing `x` and deleting it again
1409    /// lands on the same text two revisions later. Work is only ever rebuilt
1410    /// needlessly, never wrongly reused.
1411    pub fn revision(&self) -> u64 {
1412        self.revision
1413    }
1414
1415    /// The identity of the map presently in [`vmap`](Self::vmap) — what the last
1416    /// [`build_visual`](Self::build_visual) built it from, or the identity of an
1417    /// unbuilt map before the first one.
1418    ///
1419    /// This is *not* [`revision`](Self::revision). The revision says where the
1420    /// text is; this says where the map is, and the two part company the moment
1421    /// an edit lands, until something rebuilds. A frontend that keeps its own
1422    /// copy of the map — leaf-ratatui stashes core's before splicing filler rows
1423    /// under an oversized heading — compares this against the value it held when
1424    /// it took the copy, and learns whether `vmap` is still the map it stashed
1425    /// or one somebody else has since rebuilt. Restoring a copy over a newer
1426    /// map would paint a stale document; restoring nothing hands core's
1427    /// incremental rebuild a map it never built.
1428    ///
1429    /// "Somebody else" includes another document. The key names the `Doc`
1430    /// as well as the build, so a frontend that draws two documents through
1431    /// one stash — a host with several buffers, or one that opens the next
1432    /// document where the last one stood — never has the copy it took of one
1433    /// accepted by the other, however alike their builds are.
1434    pub fn visual_key(&self) -> VisualKey {
1435        VisualKey(self.identity, self.vmap_key.clone())
1436    }
1437
1438    /// The map, built at most once per `(revision, wrap)`. `clamp_caret` still
1439    /// runs on every call: the caret moves without the document changing, and
1440    /// keeping it on a legal stop is this function's job either way.
1441    fn build_map(&mut self, wrap: Option<usize>) {
1442        // Under `MarkupMode::Full` the map is a function of the caret's *line*
1443        // as well as the text, so the line joins the key: moving within a line
1444        // still reuses the map, and crossing into another one rebuilds it. In
1445        // every other mode `reveal_line` is `None` and the key is what it was,
1446        // so caret motion goes on costing nothing.
1447        let reveal = self.reveal_line();
1448        let key = (self.revision, wrap, reveal.clone());
1449        if self.vmap_key.as_ref() != Some(&key) {
1450            self.build_map_with(wrap, reveal);
1451            self.vmap_key = Some(key);
1452            // In a hidden mode the reveal line was decided from the *previous*
1453            // build's layout, whose spans are stale across an edit: the
1454            // keystroke that closes a new `$…$` on the caret's line asked "is
1455            // there math here?" of a layout that had none, and the formula
1456            // would snap to its picture under the caret until the next
1457            // motion. Ask again of the layout just built, and go once more if
1458            // the answer moved. Between edits the first answer is exact and
1459            // this is one comparison.
1460            let again = self.reveal_line();
1461            if again != self.vmap_key.as_ref().and_then(|k| k.2.clone()) {
1462                self.build_map_with(wrap, again.clone());
1463                self.vmap_key = Some((self.revision, wrap, again));
1464            }
1465        }
1466        self.clamp_caret();
1467    }
1468
1469    /// One build of the map at `wrap` under `reveal`, incremental where it can
1470    /// be — the body of [`build_map`](Self::build_map), which decides whether
1471    /// to call it.
1472    fn build_map_with(&mut self, wrap: Option<usize>, reveal: Option<Reveal>) {
1473        {
1474            // Enumerate the top-level blocks cheaply — no whole-arena marshal.
1475            // A subtree is pulled only for the block(s) that actually changed, so
1476            // the FFI marshal shrinks from O(document) to O(edited block).
1477            let top = self.top_blocks();
1478
1479            // Fast path: when twig reports a dirty byte range, try to patch the
1480            // previous map in place — a single-block edit moves the prefix,
1481            // shifts the suffix, and re-renders only one block. `build_spliced`
1482            // returns `None` (and we fall back to the always-correct full rebuild)
1483            // whenever the edit reshaped the block structure, hit a table, or
1484            // there's no previous map to patch.
1485            // Preserve soft breaks as written when the flow preference asks for
1486            // it — the builder renders each as its own visual row instead of
1487            // folding it into the reflowed paragraph.
1488            let preserve_soft = self.line_flow == LineFlow::Preserve;
1489            let spliced = match self.editor.dirty_range() {
1490                Some(dirty) => {
1491                    let prev = std::mem::take(&mut self.vmap);
1492                    let source = &self.source;
1493                    let cache = &mut self.block_cache;
1494                    let surface = &self.surface;
1495                    let editor = &mut self.editor;
1496                    wysiwyg::build_spliced(
1497                        prev,
1498                        source,
1499                        wrap,
1500                        preserve_soft,
1501                        &top,
1502                        dirty,
1503                        surface,
1504                        reveal.clone(),
1505                        cache,
1506                        |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1507                    )
1508                }
1509                None => None,
1510            };
1511            self.vmap = spliced.unwrap_or_else(|| {
1512                let source = &self.source;
1513                let cache = &mut self.block_cache;
1514                let surface = &self.surface;
1515                let editor = &mut self.editor;
1516                wysiwyg::build_cached(
1517                    &top,
1518                    source,
1519                    wrap,
1520                    preserve_soft,
1521                    surface,
1522                    reveal,
1523                    cache,
1524                    |id| editor.subtree(NodeId(id)).unwrap_or_default(),
1525                )
1526            });
1527            // Acknowledge the dirty range so the next edit's range starts fresh.
1528            self.editor.clear_dirty();
1529        }
1530    }
1531
1532    fn nodes(&mut self) -> Vec<FlatNode> {
1533        self.editor.nodes().unwrap_or_default()
1534    }
1535
1536    /// The document's top-level blocks for the incremental render. See
1537    /// [`wysiwyg::top_blocks`] for why this isn't simply `child_spans(None)`.
1538    fn top_blocks(&mut self) -> Vec<QueryMatch> {
1539        wysiwyg::top_blocks(&mut self.editor)
1540    }
1541
1542    pub fn format_name(&self) -> &'static str {
1543        // `Format` is `#[non_exhaustive]` as of twig 3.0, so the wildcard is
1544        // required. It also covers `Asciidoc`, which twig parses but cannot
1545        // serialize — leaf never opens a document in it (see `Doc::open`).
1546        match self.format {
1547            Format::Djot => "djot",
1548            Format::Markdown => "markdown",
1549            Format::Xml => "xml",
1550            Format::Html => "html",
1551            _ => "unknown",
1552        }
1553    }
1554
1555    /// Whether this document's format offers *any* door in — `false` only for a
1556    /// wholly parse-only format (XML, AsciiDoc), where every gesture refuses and
1557    /// a frontend may as well open the file read-only.
1558    ///
1559    /// This is a much weaker claim than the name suggests, and driving per-button
1560    /// state from it is exactly the mistake to avoid: HTML answers `true` because
1561    /// it spells the inline marks with a tag pair (`<strong>`, `<em>`, `<code>`)
1562    /// while a heading, a quote, a list, a task box, a link and a code fence all
1563    /// remain unspellable there. Ask [`capabilities`](Self::capabilities) — or
1564    /// [`supports`](Self::supports) — per control.
1565    pub fn authorable(&self) -> bool {
1566        self.format.is_authorable()
1567    }
1568
1569    /// Whether this document can spell `gesture`, which is twig's own answer
1570    /// rather than a copy of it: `Format::supports_with` reads the same
1571    /// `Syntax` table the `Editor` method consults before refusing, chosen by
1572    /// the very [`parse_extensions`] this document's editor reparses with — so
1573    /// what the toolbar offers and what the splice will accept are one table.
1574    ///
1575    /// It is a fact about the *document*, not about the caret. `true` does not
1576    /// promise the gesture succeeds where it is standing — a link over a table
1577    /// border still fails — only that it will not fail with
1578    /// `UnsupportedFormat`. Gray out on `false`; don't read `true` as "this
1579    /// will work here".
1580    pub fn supports(&self, gesture: Gesture) -> bool {
1581        self.format.supports_with(parse_extensions(), gesture)
1582    }
1583
1584    /// Every control's enabled state in one read — what a toolbar builds itself
1585    /// from when a document opens or its format changes. See [`Capabilities`].
1586    pub fn capabilities(&self) -> Capabilities {
1587        Capabilities::of(self.format)
1588    }
1589
1590    /// Refuse a gesture this document's format cannot spell, saying so in the
1591    /// status line. `true` means the caller must return without calling twig.
1592    ///
1593    /// Most of these refusals duplicate one twig would make anyway, and they are
1594    /// made here regardless because a message naming the *document's* format
1595    /// reads better than one naming twig's internals. Two of them are not
1596    /// duplicates and are the reason this is a guard rather than an error
1597    /// translation:
1598    ///
1599    /// - The table family (see [`table_op`](Self::table_op)) consults no
1600    ///   `Syntax` table, so twig does not refuse it at all.
1601    /// - [`toggle`](Self::toggle) at a collapsed caret never reaches twig — it
1602    ///   arms a sticky mark for text not yet typed, which is a promise `insert`
1603    ///   could not keep.
1604    fn refuse_unsupported(&mut self, what: &str, gesture: Gesture) -> bool {
1605        self.refuse_unless(what, self.supports(gesture))
1606    }
1607
1608    /// [`refuse_unsupported`](Self::refuse_unsupported) against a capability leaf
1609    /// answers itself — today only [`spells_pipe_tables`].
1610    fn refuse_unless(&mut self, what: &str, supported: bool) -> bool {
1611        if supported {
1612            return false;
1613        }
1614        self.status = Some(format!("{what}: not supported in {}", self.format_name()));
1615        true
1616    }
1617
1618    /// The name to show for this document. An untitled one has no file to name
1619    /// it, and both frontends put this straight on screen — an empty path
1620    /// renders as an empty header, so it says so instead.
1621    pub fn file_name(&self) -> String {
1622        if self.is_untitled() {
1623            return "untitled".into();
1624        }
1625        self.path
1626            .file_name()
1627            .map(|s| s.to_string_lossy().into_owned())
1628            .unwrap_or_else(|| self.path.display().to_string())
1629    }
1630
1631    /// The selection as an ordered `[start, end)` byte range, or `None` when the
1632    /// caret and anchor coincide (an empty selection is no selection).
1633    pub fn selection(&self) -> Option<(usize, usize)> {
1634        self.anchor
1635            .map(|a| (a.min(self.caret), a.max(self.caret)))
1636            .filter(|(s, e)| s != e)
1637    }
1638
1639    /// The selected text, or `None` when there's no selection — the source
1640    /// slice a copy/cut hands to the system clipboard.
1641    pub fn selected_text(&self) -> Option<&str> {
1642        self.selection().map(|(s, e)| &self.source[s..e])
1643    }
1644
1645    /// The selection as a quote with a little of what surrounds it — the shape
1646    /// a host that cites, annotates, or searches for a passage wants, cut from
1647    /// the **source** rather than from anything rendered, so the quote is
1648    /// findable in the document again by plain string search.
1649    ///
1650    /// `context` is a count of characters (not bytes) on each side, clipped at
1651    /// the document's edges; the slices land on char boundaries by
1652    /// construction. `None` when nothing is selected.
1653    pub fn selection_quote(&self, context: usize) -> Option<Quote> {
1654        let (start, end) = self.selection()?;
1655        let mut before = start;
1656        for _ in 0..context {
1657            match self.source[..before].chars().next_back() {
1658                Some(c) => before -= c.len_utf8(),
1659                None => break,
1660            }
1661        }
1662        let mut after = end;
1663        for _ in 0..context {
1664            match self.source[after..].chars().next() {
1665                Some(c) => after += c.len_utf8(),
1666                None => break,
1667            }
1668        }
1669        Some(Quote {
1670            exact: self.source[start..end].to_string(),
1671            prefix: self.source[before..start].to_string(),
1672            suffix: self.source[end..after].to_string(),
1673            start,
1674            end,
1675        })
1676    }
1677
1678    /// Words, characters, and paragraphs over the whole document — the numbers
1679    /// a status bar or an inspector puts next to a piece of writing.
1680    ///
1681    /// Counted over the text a **reader** sees, not the markup that spells it:
1682    /// `**bold**` is one word and four characters, a link is its label and not
1683    /// its destination, a block picture's `🖼 alt` placeholder is a picture and
1684    /// counts nothing, and leading frontmatter — which the WYSIWYG view does
1685    /// not render at all — is not writing. [`crate::counts`] states the rules
1686    /// in full; [`TextCounts`] states them per field.
1687    ///
1688    /// The same numbers in both views. They have to be: a word count that fell
1689    /// when you pressed ⌘E would be telling you the view had changed, which
1690    /// you knew already. So this reads neither [`Doc::view`] nor the map the
1691    /// frontend last built — it renders the source afresh, unwrapped, with
1692    /// soft breaks folded and no line revealed, and counts that. A narrower
1693    /// window, a different [`MarkupMode`], a different [`LineFlow`], and the
1694    /// source view all give the identical answer, because none of them is an
1695    /// input.
1696    ///
1697    /// That costs a reparse and an unwrapped layout — O(document), about 4 ms
1698    /// on a 45 KB file in release and 36 ms on half a megabyte. Fine on a
1699    /// settle and wrong in a paint loop, so a frontend should ask when the
1700    /// typing stops rather than once a keystroke. Caching it against
1701    /// [`revision`](Self::revision) is the obvious next move if that is ever
1702    /// not enough; nothing has needed it yet.
1703    pub fn counts(&self) -> TextCounts {
1704        self.count_over(None)
1705    }
1706
1707    /// The same statistics over the selection alone — `None` when nothing is
1708    /// selected, since an empty selection is no selection.
1709    ///
1710    /// Same rules, over the same rendering, narrowed to the glyphs whose
1711    /// source byte falls inside [`selection`](Self::selection)'s range. A
1712    /// block the selection only clips still counts as one paragraph, and one
1713    /// it enters without catching a visible character counts as none — a
1714    /// selection that starts on a hidden `**` gains no paragraph from it.
1715    pub fn selection_counts(&self) -> Option<TextCounts> {
1716        let (start, end) = self.selection()?;
1717        Some(self.count_over(Some(start..end)))
1718    }
1719
1720    /// The rendering both counters tally, and the tally itself.
1721    ///
1722    /// A fresh parse rather than `self.editor`, because these take `&self` and
1723    /// twig's arena is reached through `&mut`. A document that will not
1724    /// reparse is a "cannot happen" — the source came out of an editor that
1725    /// had already accepted it — and answers zero rather than panicking in
1726    /// what is very likely a paint path.
1727    fn count_over(&self, range: Option<Range<usize>>) -> TextCounts {
1728        let Ok(mut editor) = new_editor(self.source.as_bytes(), self.format) else {
1729            return TextCounts::default();
1730        };
1731        let Ok(nodes) = editor.nodes() else {
1732            return TextCounts::default();
1733        };
1734        // A surface that paints pictures in a line, so an inline formula is
1735        // an atom here and never its TeX: a formula is a picture to a reader
1736        // whichever way it is written, and the count says so consistently.
1737        let surface = wysiwyg::Surface {
1738            inline_pictures: true,
1739            ..Default::default()
1740        };
1741        let map = wysiwyg::build(&nodes, &self.source, None, false, &surface, None);
1742        counts::tally(&map, range)
1743    }
1744
1745    /// Whether the document refuses to change — see the field.
1746    pub fn read_only(&self) -> bool {
1747        self.read_only
1748    }
1749
1750    /// Turn the read-only gate on or off. A frontend preference like
1751    /// [`set_markup_mode`](Self::set_markup_mode): nothing about the document
1752    /// itself changes, only what may be done to it from here on.
1753    pub fn set_read_only(&mut self, on: bool) {
1754        self.read_only = on;
1755    }
1756
1757    /// The host-painted ranges, sorted by start — see [`Highlight`].
1758    pub fn highlights(&self) -> &[Highlight] {
1759        &self.highlights
1760    }
1761
1762    /// Replace the host-painted ranges wholesale. The whole set each time,
1763    /// rather than add/remove verbs: the host owns the list (it derives it
1764    /// from its own state — annotations, search hits), and a replace can
1765    /// never leave the two disagreeing about what should be on screen.
1766    pub fn set_highlights(&mut self, mut highlights: Vec<Highlight>) {
1767        highlights.retain(|h| h.start < h.end);
1768        highlights.sort_by_key(|h| (h.start, h.end));
1769        self.highlights = highlights;
1770    }
1771
1772    /// The highlight covering source `offset`, if one does — first by start
1773    /// when several overlap, which makes overlapping washes resolvable rather
1774    /// than undefined. What a frontend asks when the reader activates a spot.
1775    ///
1776    /// [`Highlight::covering`] is the whole of it: the frontends paint by
1777    /// asking the same question per glyph, against a slice they were handed
1778    /// rather than against a `Doc`, and one answer for both is what keeps a
1779    /// wash and an activation agreeing about which range a spot is in.
1780    pub fn highlight_at(&self, offset: usize) -> Option<&Highlight> {
1781        Highlight::covering(&self.highlights, offset)
1782    }
1783
1784    /// The AST breadcrumb at the caret (root → deepest), e.g.
1785    /// `doc › para › strong`. Read live from twig via `ancestors_at`.
1786    pub fn breadcrumb(&mut self) -> String {
1787        match self.editor.ancestors_at(self.caret) {
1788            Ok(chain) => chain
1789                .iter()
1790                .map(|m| m.kind.as_str())
1791                .collect::<Vec<_>>()
1792                .join(" › "),
1793            Err(_) => String::new(),
1794        }
1795    }
1796
1797    // ── editing ──────────────────────────────────────────────────────────────
1798
1799    /// Replace the byte range `[start, end)` with `text`, re-anchoring the caret
1800    /// after it. The public form of the internal splice — a pixel frontend that
1801    /// hit-tests to a byte offset (or an IME that hands back an explicit range)
1802    /// edits through this, the same twig `edit_range` the caret ops use.
1803    pub fn edit(&mut self, start: usize, end: usize, text: &str) {
1804        self.splice(start, end, text, EditKind::Other);
1805    }
1806
1807    /// Insert typed `text` at the caret, replacing the selection if there is one.
1808    /// A single typed character coalesces with the run of typing before it; a
1809    /// newline or a multi-character insert is its own undo step.
1810    ///
1811    /// Typed input only — clipboard text goes through [`paste`](Self::paste).
1812    pub fn insert(&mut self, text: &str) {
1813        // The read-only gate, up front: the paths below reach twig by several
1814        // verbs, not all of them through the splice — see the field.
1815        if self.read_only {
1816            return;
1817        }
1818        // Typing against a block picture would dissolve it, and typing past a
1819        // table would grow it a row — see `open_paragraph_at_block_edge`. Give
1820        // the text a paragraph first, so what the caret was standing beside
1821        // stays what it was.
1822        self.open_paragraph_at_block_edge(text);
1823        // Armed sticky marks (⌘b with no selection) turn the next typed text
1824        // bold/italic/… and then retire — see `insert_with_marks`. Whitespace is
1825        // the exception: it takes no mark of its own and keeps the delta armed
1826        // for the character behind it — see `insert_space_with_marks`.
1827        let pending = self.pending_here();
1828        if !pending.is_empty() && self.selection().is_none() && !text.is_empty() {
1829            if text.trim().is_empty() {
1830                self.insert_space_with_marks(self.caret, text, pending);
1831            } else {
1832                self.insert_with_marks(self.caret, text, pending);
1833            }
1834            return;
1835        }
1836        // `MarkupMode::None`: typed syntax stays literal — twig escapes
1837        // anything that would open markup, so a Diaryx user never mints
1838        // formatting by keyboard (it comes from commands instead). The other two
1839        // rungs of the ladder author markup from what you type, which is the
1840        // whole difference between them and this one. Only in the rendered view
1841        // (source view is for typing raw markup) and only where the format has a
1842        // literal spelling at all: escaping is a backslash before a byte from the
1843        // format's own alphabet, and a format with no such alphabet (HTML escapes
1844        // with entities, XML spells nothing) would have `\&` written into it,
1845        // which is two literal characters and not an escape. Marks (⌘b) still
1846        // format — that path returned above; and leaf's own structural inserts go
1847        // through `insert_raw`, never here, so a list marker or quote gutter is
1848        // written as the markup it is.
1849        if !self.markup_mode.authors()
1850            && self.view == View::Wysiwyg
1851            && !text.is_empty()
1852            && self.supports(Gesture::InsertLiteral)
1853        {
1854            self.insert_literal_typed(text);
1855            return;
1856        }
1857        self.insert_raw(text);
1858    }
1859
1860    /// Insert `text` verbatim at the caret (replacing any selection) — the plain
1861    /// path with no Hidden-mode literal escaping. leaf's own structural inserts
1862    /// (a list marker, a quote gutter, an in-cell `<br>`) call this: they ARE
1863    /// markup by design and must not be escaped.
1864    fn insert_raw(&mut self, text: &str) {
1865        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
1866        self.splice(s, e, text, typed_edit_kind(text));
1867    }
1868
1869    /// Open a paragraph for text about to be inserted at one of a block media's
1870    /// two caret stops, or at a table's trailing stop, and leave the caret
1871    /// standing in it.
1872    ///
1873    /// A block image is a paragraph whose entire content is the picture, and the
1874    /// caret's only homes on it are in front of it and just past it (see
1875    /// [`VisualMap::block_media_stop`]). Text inserted at either offset joins
1876    /// *that* paragraph — and a paragraph holding anything besides the image is
1877    /// no longer a block image but a line of text with an inline one in it. The
1878    /// frontend that was painting a photo there paints a text run instead; the
1879    /// picture is still in the file, and nothing said a word. Those two offsets
1880    /// are also exactly where a click on the picture lands, so the whole accident
1881    /// is one tap and one keystroke.
1882    ///
1883    /// So the break goes in first and the text lands in the new empty paragraph —
1884    /// what pressing Return before typing would have done, which is a habit no
1885    /// one should have to learn from losing a photo. A no-op everywhere else, and
1886    /// over a selection (which is replaced, not joined into).
1887    ///
1888    /// A picture inside a quote or a list leaves its container, because `\n\n`
1889    /// ends the block. The alternative is worse: the `\n> ` / next-item
1890    /// continuation [`newline`](Self::newline) writes stays in the same
1891    /// *paragraph*, which is the thing being prevented.
1892    ///
1893    /// A table's trailing stop ([`VisualMap::table_end_stop`]) is the same
1894    /// accident from the other side of a different block: the stop sits at the
1895    /// end of the table's last source line, and a line glued under a table is
1896    /// a row of it — `| 1 | 2 |x` is a three-cell row, not a paragraph. So the
1897    /// break goes in there too, and the text lands under the table.
1898    ///
1899    /// Only in the rendered view. Source view is for typing raw markup, where
1900    /// putting a character against an image is exactly what it looks like.
1901    fn open_paragraph_at_block_edge(&mut self, text: &str) {
1902        if self.view != View::Wysiwyg || text.is_empty() || text == "\n" {
1903            return;
1904        }
1905        if self.selection().is_some() {
1906            return;
1907        }
1908        // The map may be a revision behind (nothing has drawn since the last
1909        // edit), and this asks it about offsets — a stale answer would splice a
1910        // break into the wrong place. Free when it is already current, which it
1911        // is whenever a frontend drew a frame between keystrokes.
1912        self.rebuild_map();
1913        let at = self.caret;
1914        let side = match self.vmap.block_media_stop(at) {
1915            Some((side, _)) => side,
1916            None if self.vmap.table_end_stop(at) => MediaStop::After,
1917            None => return,
1918        };
1919        if !self.splice(at, at, "\n\n", EditKind::Other) {
1920            return;
1921        }
1922        // The break is part of the keystroke, not an edit of its own: leave the
1923        // run marked as typing so the character about to arrive folds into it and
1924        // one undo puts the document back the way it was found. (A paste, or a
1925        // multi-character insert, is `EditKind::Other` and stays its own step —
1926        // as it would have been anywhere else in the document.)
1927        self.last_edit_kind = Some(EditKind::Insert);
1928        if side == MediaStop::Before {
1929            // The break went in above the picture and the caret rode to the end
1930            // of it — which is still hard against the picture. Step back onto the
1931            // blank line it opened, so the text lands above rather than in front.
1932            self.caret = at;
1933        }
1934    }
1935
1936    /// A delete key pressed at one of a block picture's two caret stops, handled
1937    /// as the picture being an *atom* rather than a run of bytes. Returns whether
1938    /// the key was consumed.
1939    ///
1940    /// The caret rests in front of a block image and just past it, never inside
1941    /// its markup — which the rendered view doesn't show. So the byte a delete
1942    /// key nominally takes there is one the writer cannot see, and taking it
1943    /// leaves the picture as broken markup rather than as anything anyone asked
1944    /// for: Backspace at the stop past `![](p.png)` removes the closing paren, and
1945    /// a photo becomes the literal text `![](p.png`. That is how a picture goes
1946    /// missing from a document with nobody having touched it — the same
1947    /// dissolution [`open_paragraph_at_block_edge`](Self::open_paragraph_at_block_edge)
1948    /// prevents from the typing side, and it cost this repository's own test vault
1949    /// a photo before it was found.
1950    ///
1951    /// So the key aimed *at* the picture deletes the picture, whole — Backspace
1952    /// when it is behind the caret, Delete when it is in front — which is what
1953    /// every editor does with an embed, and one undo away. The key aimed *away*
1954    /// from it would otherwise delete the paragraph break and merge a neighbour
1955    /// into the picture's own paragraph, which dissolves it just as surely; it
1956    /// steps the caret over the boundary instead and leaves the
1957    /// next press to delete in the block it has reached — the same "first press
1958    /// steps out of the atom, second press deletes" every delete key here gets,
1959    /// word-deletes included (⌥⌫ in front of a picture is aimed at the prose
1960    /// above, and reaches it on the second press rather than taking the break and
1961    /// the picture with it on the first).
1962    fn delete_around_block_media(&mut self, forward: bool) -> bool {
1963        // The map answers about offsets, so it has to be this revision's — see
1964        // the same call in `open_paragraph_at_block_edge`.
1965        self.rebuild_map();
1966        let Some((side, span)) = self.vmap.block_media_stop(self.caret) else {
1967            return false;
1968        };
1969        let aimed_at_it = side
1970            == if forward {
1971                MediaStop::Before
1972            } else {
1973                MediaStop::After
1974            };
1975        if !aimed_at_it {
1976            let over = if forward {
1977                self.vmap.stop_after(self.caret)
1978            } else {
1979                self.vmap.stop_before(self.caret)
1980            };
1981            if let Some(off) = over.filter(|&o| o >= self.caret_floor()) {
1982                self.caret = off;
1983                self.anchor = None;
1984                self.goal_col = None;
1985            }
1986            return true;
1987        }
1988        // Take the break that held the picture apart from its neighbour with it,
1989        // so the delete doesn't leave a blank paragraph standing where the
1990        // picture was. The last arm is a picture that is the whole document.
1991        let (from, to) = if self.source[..span.start].ends_with("\n\n") {
1992            (span.start - 2, span.end)
1993        } else if self.source[span.end..].starts_with("\n\n") {
1994            (span.start, span.end + 2)
1995        } else {
1996            (span.start, span.end)
1997        };
1998        self.splice(from.max(self.caret_floor()), to, "", EditKind::Other);
1999        true
2000    }
2001
2002    /// The Hidden-mode typing path: replace any selection, then insert `text`
2003    /// escaped so it stays literal. When it replaces a selection the two edits
2004    /// fold into one undo step, so an overwrite undoes atomically (and restores
2005    /// the selection) exactly as a plain one does.
2006    fn insert_literal_typed(&mut self, text: &str) {
2007        let kind = typed_edit_kind(text);
2008        match self.selection() {
2009            Some((s, e)) => {
2010                if !self.splice(s, e, "", EditKind::Other) {
2011                    return;
2012                }
2013                // Typing over a whole marked run takes its delimiters with it
2014                // (the empty content couldn't hold them — see
2015                // `repair_mark_edges`) and leaves its marks armed at the caret.
2016                // The text taking the run's place inherits them, exactly as it
2017                // would have by landing inside a run that survived.
2018                let pending = self.pending_here();
2019                if !pending.is_empty() && !text.trim().is_empty() {
2020                    self.insert_with_marks(self.caret, text, pending);
2021                    return;
2022                }
2023                self.insert_literal_at(self.caret, text, kind, true);
2024            }
2025            None => {
2026                self.insert_literal_at(self.caret, text, kind, false);
2027            }
2028        }
2029    }
2030
2031    /// The sticky-mark delta that is live right now: the marks armed by [`toggle`]
2032    /// at a collapsed caret, but only while the caret still stands where they
2033    /// were armed and nothing is selected. Empty otherwise, so a stale delta
2034    /// never styles text it wasn't meant for.
2035    fn pending_here(&self) -> InlineMarks {
2036        if self.anchor.is_none() && self.pending_at == Some(self.caret) {
2037            self.pending_marks
2038        } else {
2039            InlineMarks::empty()
2040        }
2041    }
2042
2043    /// Drop the armed sticky marks — any caret motion, selection, or edit does
2044    /// this, so "start bold here" only ever applies at the exact spot it was
2045    /// asked for.
2046    fn clear_pending(&mut self) {
2047        self.pending_marks = InlineMarks::empty();
2048        self.pending_at = None;
2049    }
2050
2051    /// Insert `text` at `at` carrying the armed sticky `marks`: a mark not yet in
2052    /// force is wrapped around the freshly typed text; a mark the caret already
2053    /// stands inside is *shed* — the text is inserted past the run's end so it
2054    /// lands unmarked ("type normally again"). The caret comes to rest inside any
2055    /// added runs, so continued typing inherits the marks with no re-wrapping,
2056    /// and the delta is cleared: the marks now live in the document, not here.
2057    fn insert_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2058        let base = self.mark_spans_at(at);
2059        let base_set: InlineMarks = base.iter().map(|(k, _)| *k).collect();
2060        // Nothing to shed, and a run of exactly these marks standing just behind
2061        // the caret: carry on writing *that* run rather than opening a second
2062        // one beside it.
2063        if base_set.is_empty() && self.rejoin_run(at, text, marks) {
2064            return;
2065        }
2066        // Shed the marks we're turning off: step the insertion point past the
2067        // end of each run the caret sits in, so the new text falls outside it.
2068        let mut ins_at = at;
2069        for (kind, span) in &base {
2070            if marks.contains(*kind) {
2071                ins_at = ins_at.max(span.end);
2072            }
2073        }
2074        if !self.splice_exact(ins_at, ins_at, text, EditKind::Other) {
2075            return;
2076        }
2077        // The plain splice inserted exactly `text` at `ins_at`; that byte range
2078        // is the content every added mark wraps.
2079        let (mut cs, mut ce) = (ins_at, ins_at + text.len());
2080        for kind in marks.iter() {
2081            if !base_set.contains(kind) {
2082                let (ncs, nce) = self.wrap_span(cs, ce, kind);
2083                cs = ncs;
2084                ce = nce;
2085            }
2086        }
2087        self.caret = ce.min(self.source.len());
2088        self.anchor = None;
2089        self.last_edit_kind = None;
2090        // Realised: the marks are in the document now, and the caret sits inside
2091        // them, so there is no delta left to carry. Arm nothing, but remember the
2092        // spot so a *further* toggle before typing starts a clean delta here.
2093        self.pending_marks = InlineMarks::empty();
2094        self.pending_at = Some(self.caret);
2095        self.clamp_caret();
2096        self.record_caret();
2097    }
2098
2099    /// Carry on the marked run just behind `at` — moving its closing delimiters
2100    /// out past the new text — instead of opening a second run of the same marks
2101    /// beside it. Returns whether it did.
2102    ///
2103    /// This is the far half of the mark-edge rule (see [`splice`](Self::splice)).
2104    /// A space typed after a bold word steps the caret out of the run, because
2105    /// `**bold **` is not bold; the next character has to step back *in*, or the
2106    /// writer who typed one bold phrase is left with `**bold** **and**` — two
2107    /// runs that read the same to a reader but spell the file in a way nobody
2108    /// wrote. Only whitespace may stand in the gap (a run doesn't reach across
2109    /// words it isn't marking), and the marks behind it must be exactly the ones
2110    /// armed — a run of *some* other kind is a neighbour, not this phrase.
2111    fn rejoin_run(&mut self, at: usize, text: &str, marks: InlineMarks) -> bool {
2112        if text.is_empty() || text.trim() != text {
2113            return false;
2114        }
2115        let gap_at = self.source[..at].trim_end_matches([' ', '\t']).len();
2116        // Walk in through the delimiters stacked at that point, innermost last:
2117        // `***both*** ` closes two runs with one `***`, and rejoining means
2118        // getting behind all of them.
2119        let (mut cut, mut kinds) = (gap_at, InlineMarks::empty());
2120        while let Some((kind, content_end)) = self
2121            .editor
2122            .ancestors_at(prev_boundary(&self.source, cut))
2123            .unwrap_or_default()
2124            .into_iter()
2125            .filter(|m| m.span.end == cut)
2126            .find_map(|m| Some((inline_kind(&m.kind)?, m.content_span.clone()?.end)))
2127        {
2128            if content_end >= cut {
2129                break; // a mark with no closing delimiter to step behind
2130            }
2131            kinds.insert(kind);
2132            cut = content_end;
2133        }
2134        if cut == gap_at || kinds != marks {
2135            return false;
2136        }
2137        // Re-spell the tail: the gap, then the new text, then the delimiters that
2138        // used to close in front of them — read out of the document rather than
2139        // written from a table, so whatever twig spells them with is what moves.
2140        let tail = format!(
2141            "{}{text}{}",
2142            &self.source[gap_at..at],
2143            &self.source[cut..gap_at]
2144        );
2145        if !self.splice_exact(cut, at, &tail, EditKind::Other) {
2146            return false;
2147        }
2148        self.caret = (cut + (at - gap_at) + text.len()).min(self.source.len());
2149        self.anchor = None;
2150        self.last_edit_kind = None;
2151        self.pending_marks = InlineMarks::empty();
2152        self.pending_at = Some(self.caret);
2153        self.clamp_caret();
2154        self.record_caret();
2155        true
2156    }
2157
2158    /// Insert typed whitespace at a caret with sticky marks armed. Whitespace is
2159    /// never itself wrapped: a mark around a space draws nothing a reader can
2160    /// see, and in Markdown and Djot it draws its own delimiters instead
2161    /// (`** **`). So the space goes in unmarked — outside any run the armed
2162    /// marks are shedding — and the marks stay armed for the character after it,
2163    /// which rejoins the run (see [`rejoin_run`](Self::rejoin_run)).
2164    fn insert_space_with_marks(&mut self, at: usize, text: &str, marks: InlineMarks) {
2165        let base = self.mark_spans_at(at);
2166        // What the *next* character carries: the armed delta resolved against the
2167        // marks in force here, which the space must not quietly drop.
2168        let want = base
2169            .iter()
2170            .map(|(k, _)| *k)
2171            .collect::<InlineMarks>()
2172            .xor(marks);
2173        let mut ins_at = at;
2174        for (kind, span) in &base {
2175            if marks.contains(*kind) {
2176                ins_at = ins_at.max(span.end);
2177            }
2178        }
2179        if !self.splice(ins_at, ins_at, text, typed_edit_kind(text)) {
2180            return;
2181        }
2182        self.rearm(want);
2183        self.record_caret();
2184    }
2185
2186    /// Wrap `[s, e)` in `kind` via twig and return the byte span the *content*
2187    /// (not the delimiters) occupies afterwards. Markdown/Djot inline delimiters
2188    /// are symmetric (`**`…`**`, `_`…`_`, `` ` ``…`` ` ``), so the bytes twig
2189    /// added split evenly around the content — half the growth on each side.
2190    fn wrap_span(&mut self, s: usize, e: usize, kind: InlineKind) -> (usize, usize) {
2191        // The read-only gate — this door reaches twig without the splice.
2192        if self.read_only {
2193            return (s, e);
2194        }
2195        match self.editor.toggle_inline(s, e, kind) {
2196            Ok(change) => {
2197                self.last_edit_kind = None;
2198                self.refresh();
2199                self.dirty = self.source != self.clean_source;
2200                let added = (change.new.end - change.new.start).saturating_sub(e - s);
2201                let half = added / 2;
2202                (change.new.start + half, change.new.end - half)
2203            }
2204            // Unsupported here (e.g. mark on Markdown): leave the text unwrapped
2205            // rather than lose the keystroke.
2206            Err(e2) => {
2207                self.status = Some(format!("{kind:?}: {e2}"));
2208                (s, e)
2209            }
2210        }
2211    }
2212
2213    /// The safe offset to splice a block-level break at, given a caret that may
2214    /// sit exactly between an inline mark's content and its own closing
2215    /// delimiter (`content_span.end == off < span.end` for some enclosing mark
2216    /// — the WYSIWYG caret's natural resting place at the end of `**bold**`
2217    /// with nothing following it on the line: the closing `**` renders no
2218    /// glyph of its own, so the caret's "end of line" offset lands right
2219    /// before it). Splicing a paragraph/list/quote break at `off` itself would
2220    /// sever the delimiter from its content, stranding it alone on the new
2221    /// line. Walks out to the *outermost* such mark's `span.end` instead, so
2222    /// nested marks closing at the same point (`**_x_**`) all clear together.
2223    /// A no-op everywhere else — mid-run, or past real trailing content, no
2224    /// mark's `content_span` ends exactly at `off`.
2225    fn skip_trailing_close_delims(&mut self, off: usize) -> usize {
2226        let off = off.min(self.source.len());
2227        let runs = self.run_span_ids();
2228        self.editor
2229            .ancestors_at(off)
2230            .unwrap_or_default()
2231            .into_iter()
2232            .filter(|m| hides_delims(m, &runs))
2233            .filter(|m| off < m.span.end && m.content_span.as_ref().is_some_and(|c| c.end == off))
2234            .map(|m| m.span.end)
2235            .max()
2236            .unwrap_or(off)
2237    }
2238
2239    /// The offset a *delete* aimed at the character before `off` should stop at,
2240    /// when `off` is the start of a run's text and the bytes behind it are that
2241    /// run's opening delimiter. The rich view draws no glyph for a `**`, so the
2242    /// byte behind the caret at the start of a bold word is not a character the
2243    /// writer can see, let alone one they aimed Backspace at: taking it leaves
2244    /// `a *bold** c` — the styling gone and a literal asterisk in its place. The
2245    /// delete steps over the whole delimiter to the visible character in front of
2246    /// it instead. Walks out to the *outermost* mark opening there, so
2247    /// `**_x_**` clears every delimiter at once, and is a no-op anywhere else.
2248    fn skip_leading_open_delims(&mut self, off: usize) -> usize {
2249        let off = off.min(self.source.len());
2250        let runs = self.run_span_ids();
2251        self.editor
2252            .ancestors_at(off)
2253            .unwrap_or_default()
2254            .into_iter()
2255            .filter(|m| hides_delims(m, &runs))
2256            .filter(|m| {
2257                m.span.start < off && m.content_span.as_ref().is_some_and(|c| c.start == off)
2258            })
2259            .map(|m| m.span.start)
2260            .min()
2261            .unwrap_or(off)
2262    }
2263
2264    /// `off` moved *inside* the run whose closing delimiters end there — the
2265    /// other offset the rich view draws in the same place, since a `**` renders
2266    /// no glyph of its own. `**bold**` has a caret home on each side of its
2267    /// closing delimiter, one column apart on screen and eight bytes and a whole
2268    /// run apart in the file, and a plain ← lands on the outer one whenever a
2269    /// space follows the phrase. The inner one is what the writer is pointing at
2270    /// there: the end of their bold word. Walks in through every mark closing at
2271    /// that point, innermost last, so `***both***` lands inside both. A no-op
2272    /// anywhere else — mid-run, or in prose, no mark's span ends at `off`.
2273    fn step_inside_close_delims(&mut self, off: usize) -> usize {
2274        let mut off = off.min(self.source.len());
2275        let runs = self.run_span_ids();
2276        loop {
2277            let inner = self
2278                .editor
2279                .ancestors_at(prev_boundary(&self.source, off))
2280                .unwrap_or_default()
2281                .into_iter()
2282                .filter(|m| hides_delims(m, &runs) && m.span.end == off)
2283                .filter_map(|m| m.content_span.clone().map(|c| c.end))
2284                .filter(|&end| end < off)
2285                .max();
2286            match inner {
2287                Some(end) => off = end,
2288                None => return off,
2289            }
2290        }
2291    }
2292
2293    /// The mirror at the opening edge: `off` moved inside the run whose
2294    /// delimiters *start* there, onto the first character of its text. See
2295    /// [`step_inside_close_delims`](Self::step_inside_close_delims).
2296    fn step_inside_open_delims(&mut self, off: usize) -> usize {
2297        let mut off = off.min(self.source.len());
2298        let runs = self.run_span_ids();
2299        loop {
2300            let inner = self
2301                .editor
2302                .ancestors_at(off)
2303                .unwrap_or_default()
2304                .into_iter()
2305                .filter(|m| hides_delims(m, &runs) && m.span.start == off)
2306                .filter_map(|m| m.content_span.clone().map(|c| c.start))
2307                .filter(|&start| start > off)
2308                .min();
2309            match inner {
2310                Some(start) => off = start,
2311                None => return off,
2312            }
2313        }
2314    }
2315
2316    /// The ids of the document's attributed run spans — the inline
2317    /// `Container`s [`wysiwyg::is_run_span`] picks out — for [`hides_delims`],
2318    /// which sees an ancestor chain and so only a kind. Read once per gesture,
2319    /// not once per step of a walk.
2320    fn run_span_ids(&mut self) -> Vec<NodeId> {
2321        self.nodes()
2322            .iter()
2323            .filter(|n| wysiwyg::is_run_span(n))
2324            .map(|n| n.id)
2325            .collect()
2326    }
2327
2328    /// The attributed span whose text is exactly `content` — the whole of
2329    /// `<span …>i</span>`'s `i`, or nothing at all when `content` is empty
2330    /// and sits between the tags of `<span …></span>` — as the whole range
2331    /// spelling the span: the node's span, widened to its attribute block
2332    /// where the format writes that outside the node, as djot's
2333    /// `[i]{data-size="large"}` does. `None` for any other range, including
2334    /// part of a span's text.
2335    ///
2336    /// An empty span has an interior of no bytes, or no known interior at
2337    /// all: twig gives Markdown's `<span …></span>` the first and djot's
2338    /// `[]{…}` the second, and the chain already says the offset is inside.
2339    fn run_span_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2340        let runs = self.run_span_ids();
2341        let m = self
2342            .editor
2343            .ancestors_at(content.start)
2344            .unwrap_or_default()
2345            .into_iter()
2346            .filter(|m| runs.contains(&NodeId(m.node_id)))
2347            .find(|m| match &m.content_span {
2348                Some(c) => *c == content,
2349                None => content.is_empty(),
2350            })?;
2351        let mut range = m.span;
2352        if let Some(attrs) = self
2353            .editor
2354            .document()
2355            .ok()
2356            .and_then(|mut d| d.attrs_span(NodeId(m.node_id)).ok().flatten())
2357        {
2358            range.start = range.start.min(attrs.start);
2359            range.end = range.end.max(attrs.end);
2360        }
2361        Some(range)
2362    }
2363
2364    /// The attributed block whose whole text is exactly `content` — the `T`
2365    /// of Markdown's `<div class="center">\n\nT\n\n</div>` or djot's
2366    /// `{.center}\nT` — as the range a delete that takes that text takes with
2367    /// it: the whole `<div>` when the block is all the div holds, or the
2368    /// `{…}` line down to the end of the text. The block version of
2369    /// [`run_span_of_content`](Self::run_span_of_content), for the same
2370    /// reason: a paragraph with no text is no block, so the div would stand
2371    /// around nothing and the `{…}` line above nothing, and a from-scratch
2372    /// map gives neither a caret home — the `T`'s row is gone with the `T`.
2373    /// `None` for a block with more text, a div holding more, a heading (an
2374    /// empty `# ` is still a heading), and a format whose attributes are the
2375    /// block's own tag (HTML's `<p class="center"></p>` is still a
2376    /// paragraph).
2377    fn attributed_block_of_content(&mut self, content: Range<usize>) -> Option<Range<usize>> {
2378        if content.is_empty() || !matches!(self.format, Format::Markdown | Format::Djot) {
2379            return None;
2380        }
2381        let nodes = self.nodes();
2382        let block = nodes
2383            .iter()
2384            .filter(|n| n.kind == Kind::Para)
2385            .find(|n| n.content_span.as_ref() == Some(&content))?;
2386        match self.format {
2387            Format::Djot => {
2388                let attrs = self
2389                    .editor
2390                    .document()
2391                    .ok()
2392                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())?;
2393                (attrs.end <= block.span.start).then_some(attrs.start..content.end)
2394            }
2395            _ => {
2396                let div = block
2397                    .parent
2398                    .and_then(|p| nodes.iter().find(|n| n.id == p))
2399                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))?;
2400                let alone = nodes.iter().filter(|n| n.parent == Some(div.id)).count() == 1;
2401                alone.then(|| div.span.clone())
2402            }
2403        }
2404    }
2405
2406    /// The inline mark kinds whose span covers `off`, each with that span — the
2407    /// span-carrying sibling of [`marks_at`](Self::marks_at), which reports node
2408    /// ids instead. Used to shed a mark by stepping past the end of its run.
2409    fn mark_spans_at(&mut self, off: usize) -> Vec<(InlineKind, std::ops::Range<usize>)> {
2410        let off = off.min(self.source.len());
2411        self.editor
2412            .ancestors_at(off)
2413            .unwrap_or_default()
2414            .into_iter()
2415            .filter(|m| off < m.span.end)
2416            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.span.clone())))
2417            .collect()
2418    }
2419
2420    /// Insert clipboard `text` at the caret, replacing the selection if there is
2421    /// one — always its own undo step, whatever its length.
2422    ///
2423    /// Provenance is the whole point, and only the caller has it. `insert` reads
2424    /// a lone character as a keystroke and folds it into the run around it,
2425    /// which is right for typing and wrong for a one-character paste: that paste
2426    /// would vanish mid-run on an undo it was never part of, and the characters
2427    /// the user actually typed would go with it. Length can't tell the two
2428    /// apart — `⌘V` of `x` and typing `x` are the same string — so the door the
2429    /// caller comes through is what says which happened.
2430    pub fn paste(&mut self, text: &str) {
2431        // Pasting against a block picture or a table's end joins the block
2432        // exactly as typing does, and for the same reason — see
2433        // `open_paragraph_at_block_edge`.
2434        self.open_paragraph_at_block_edge(text);
2435        let (s, e) = self.selection().unwrap_or((self.caret, self.caret));
2436        self.splice(s, e, text, EditKind::Other);
2437    }
2438
2439    /// Replace `[start, end)` with `text` as one step of an IME composition —
2440    /// the same splice as [`edit`](Self::edit), but marked so the run of steps
2441    /// folds into a single undo.
2442    ///
2443    /// A composition is *one* act of writing. Typing `かんじ` and picking 感じ is a
2444    /// dozen calls here, each replacing the last one's provisional bytes, and an
2445    /// undo step per call means undoing a word means pressing ⌘Z until the reading
2446    /// unspools backwards through kana — the intermediate states were never text
2447    /// the user wrote. Only the frontend knows a call is provisional (the bytes
2448    /// look like any other edit), so the door the caller comes through is what
2449    /// says so, exactly as it is for [`paste`](Self::paste) versus
2450    /// [`insert`](Self::insert).
2451    ///
2452    /// Pair with [`end_composition`](Self::end_composition), or the *next*
2453    /// composition folds into this one.
2454    pub fn edit_composing(&mut self, start: usize, end: usize, text: &str) {
2455        self.splice(start, end, text, EditKind::Compose);
2456    }
2457
2458    /// Close the open composition run, so the next one is its own undo step.
2459    /// Call when the IME commits or withdraws a composition.
2460    ///
2461    /// Only clears a *composition* run: a frontend that reports an end it never
2462    /// began (some IMEs unmark unprompted) would otherwise split the run of
2463    /// typing around it into two undo steps for no reason the user can see.
2464    pub fn end_composition(&mut self) {
2465        if self.last_edit_kind == Some(EditKind::Compose) {
2466            self.last_edit_kind = None;
2467        }
2468    }
2469
2470    // ── the clipboard's rich flavor ──────────────────────────────────────────
2471
2472    /// The selection rendered as HTML, for the clipboard's `text/html` flavor —
2473    /// what lets a paste into Docs/Mail/Slack keep its formatting. `None` when
2474    /// nothing is selected, or when the selection doesn't render (the caller
2475    /// still has [`selected_text`](Self::selected_text), which is what to publish
2476    /// as `text/plain` either way).
2477    ///
2478    /// **The fragment is a source substring, and that is the honest limit here.**
2479    /// It's parsed standalone, so a selection whose meaning depends on its
2480    /// surroundings converts as what it literally says rather than what it looks
2481    /// like on screen: half a list item is a paragraph, a row torn out of a table
2482    /// is the text of a row, the `**` of a bold run selected without its closing
2483    /// `**` is two asterisks. Every one of those still *renders* — there's no
2484    /// error to report — it just renders as the fragment and not as the document.
2485    /// Widening the range to whole blocks would publish text the user didn't
2486    /// select, which is a worse lie than a fragment being a fragment; the plain
2487    /// flavor has the same substring, so the two flavors at least agree.
2488    pub fn selection_html(&mut self) -> Option<String> {
2489        let (start, end) = self.selection()?;
2490        let inline = self.selection_is_inline(start, end);
2491        let html = html::render_fragment(&self.source[start..end], self.format)?;
2492        Some(match inline {
2493            true => html::strip_sole_paragraph(html),
2494            false => html,
2495        })
2496    }
2497
2498    /// Paste the clipboard's `text/html` flavor, converting it to this document's
2499    /// format first. Its own undo step, like any [`paste`](Self::paste).
2500    ///
2501    /// Returns whether it landed. `false` means the HTML didn't convert to
2502    /// anything worth pasting — the caller should fall back to the plain flavor
2503    /// rather than treat it as an error. The `html` module has the full list of
2504    /// what that covers: a table twig won't build, markup it doesn't recognise,
2505    /// an empty result.
2506    pub fn paste_html(&mut self, html: &str) -> bool {
2507        match html::parse_fragment(html, self.format) {
2508            Some(source) => {
2509                self.paste(&source);
2510                true
2511            }
2512            None => false,
2513        }
2514    }
2515
2516    /// Does the selection live *inside* a single top-level block?
2517    ///
2518    /// The question [`selection_html`](Self::selection_html) needs and the
2519    /// fragment can't answer: `**bold**` renders as `<p><strong>bold</strong></p>`
2520    /// whether the user selected one word of a sentence or a whole paragraph, and
2521    /// only the document knows which. Selecting a word and pasting into Docs
2522    /// should extend the line you paste into; selecting the paragraph should make
2523    /// a paragraph. So a selection strictly within one block is inline (its `<p>`
2524    /// is an artifact of standalone parsing), and one that covers a whole block —
2525    /// or spans two — keeps its structure.
2526    ///
2527    /// Reads the block from twig rather than guessing from the bytes:
2528    /// `ancestors_at` is `[doc, block, …inline]`, so index 1 is the top-level
2529    /// block containing an offset, and two ends inside the same one cannot have
2530    /// crossed a block boundary.
2531    fn selection_is_inline(&mut self, start: usize, end: usize) -> bool {
2532        // The last *character*, not `end - 1`: the selection's end is exclusive
2533        // and may sit mid-codepoint's-worth of bytes past the last char.
2534        let Some((off, _)) = self.source[start..end].char_indices().next_back() else {
2535            return false;
2536        };
2537        let (Some(head), Some(tail)) =
2538            (self.top_block_span(start), self.top_block_span(start + off))
2539        else {
2540            return false;
2541        };
2542        head == tail && !(start <= head.start && end >= head.end)
2543    }
2544
2545    /// The byte span of the top-level block containing `offset`, or `None` at an
2546    /// offset that belongs to no block (the blank line between two of them).
2547    fn top_block_span(&mut self, offset: usize) -> Option<std::ops::Range<usize>> {
2548        self.editor
2549            .ancestors_at(offset)
2550            .ok()?
2551            .get(1)
2552            .map(|m| m.span.clone())
2553    }
2554
2555    // ── indentation ──────────────────────────────────────────────────────────
2556
2557    /// One indent level.
2558    ///
2559    /// Two spaces, not the four both frontends type for Tab today, because in a
2560    /// markdown document four columns isn't a width — it's a *meaning*. Four
2561    /// spaces at the head of a line is markdown's indented-code-block marker, so
2562    /// one Tab on a paragraph would reparse it into code and style it as such;
2563    /// two cannot, and the line stays the prose it was. Two is also exactly
2564    /// where a `- ` bullet's content starts, so an indented line lands under its
2565    /// parent item's text instead of beside it — the column a list-aware indent
2566    /// has to hit anyway, which keeps this width from being relitigated later.
2567    const INDENT: &'static str = "  ";
2568
2569    /// Indent the selected lines — or the caret's line, with no selection — by
2570    /// one level (Tab).
2571    pub fn indent(&mut self) {
2572        self.reindent(true);
2573        // Nesting changes an ordered list's numbering (the nested item restarts,
2574        // its old siblings resume) — keep the source markers in step.
2575        self.renumber_here();
2576        // Nesting an empty `-` item under a text line reparses that text as a
2577        // setext heading; swap the dash for a `*` before it can (a no-op unless
2578        // the collapse actually happened).
2579        self.avoid_setext_collapse();
2580    }
2581
2582    /// Take one indent level back off the selected lines, or the caret's line
2583    /// (Shift+Tab). A line with no indentation is left exactly as it is.
2584    ///
2585    /// A line with *less* than a full level gives back what it has rather than
2586    /// refusing: outdent's job is to walk a line left, and real documents — hand
2587    /// written, or reflowed by some other editor — are full of indentation that
2588    /// was never a clean multiple of anything. Refusing there would strand the
2589    /// line at a depth Shift+Tab couldn't undo.
2590    pub fn outdent(&mut self) {
2591        self.reindent(false);
2592        self.renumber_here();
2593    }
2594
2595    /// The body of [`indent`](Self::indent) / [`outdent`](Self::outdent).
2596    ///
2597    /// One splice across the whole line range, never one per line: a Tab is one
2598    /// thing the user did, so it has to be one undo step and one reparse. Per
2599    /// line, twig would reparse the document once per line and leave a stack of
2600    /// steps that Shift+⌘Z walks back one line at a time.
2601    fn reindent(&mut self, add: bool) {
2602        let (sel_start, sel_end) = self.selection().unwrap_or((self.caret, self.caret));
2603        let start = source_line_range(&self.source, sel_start).start;
2604        let end = source_line_range(&self.source, sel_end).end;
2605        let region = self.source[start..end].to_string();
2606        let lines: Vec<&str> = region.split('\n').collect();
2607        // A blank line has no text to move, and padding it would leave nothing
2608        // but trailing whitespace — but Tab on a blank line *is* a request for
2609        // indentation to type into, so the skip only applies where the op has
2610        // other lines to do real work on.
2611        let skip_blank = add && lines.len() > 1;
2612
2613        let mut out = String::with_capacity(region.len() + lines.len() * Self::INDENT.len());
2614        let mut deltas: Vec<isize> = Vec::with_capacity(lines.len());
2615        let mut line_off = start;
2616        for (i, full) in lines.iter().enumerate() {
2617            if i > 0 {
2618                out.push('\n');
2619            }
2620            // A list item moves by having its whole leading prefix *replaced*,
2621            // never by having spaces pushed in front of the line. twig spells
2622            // both prefixes, so the quote markers, the parent's indent and an
2623            // ordered marker's extra column all come out right without leaf
2624            // measuring any of them — and a line that only looks like an item
2625            // (a Djot continuation) reports no marker and is left to the plain
2626            // path, where a Tab is just a Tab.
2627            let marker = self.list_marker_on_line(line_off);
2628            let own = marker
2629                .as_ref()
2630                .map(|m| m.marker_start - m.line_start)
2631                .unwrap_or(0);
2632            let delta = if add {
2633                if skip_blank && full.trim().is_empty() {
2634                    out.push_str(full);
2635                    0
2636                } else if marker.is_some() && self.first_item_of_list(line_off) {
2637                    // The first item of a list has no preceding sibling to nest
2638                    // under, so a Tab here can't spell a sub-list — twig would
2639                    // reparse the shoved-over marker as the same list, only
2640                    // indented, which Shift+Tab then can't cleanly undo. Leave the
2641                    // item where it is, the way every list editor refuses to
2642                    // over-indent a list's first line.
2643                    out.push_str(full);
2644                    0
2645                } else if marker.is_some() {
2646                    // Nesting means standing where a *continuation* of this line
2647                    // would stand: past the parent's marker, inside its content
2648                    // column. That is `continuation_prefix`, less a checkbox.
2649                    let new = self.nesting_prefix_at(line_off);
2650                    let delta = new.len() as isize - own as isize;
2651                    out.push_str(&new);
2652                    out.push_str(&full[own..]);
2653                    delta
2654                } else {
2655                    out.push_str(Self::INDENT);
2656                    out.push_str(full);
2657                    Self::INDENT.len() as isize
2658                }
2659            } else if marker.is_some() {
2660                // Unnesting is the mirror: stand where the parent item's own
2661                // line starts, which drops exactly the level it contributed.
2662                let new = self.outdent_prefix_at(line_off);
2663                let delta = new.len() as isize - own as isize;
2664                out.push_str(&new);
2665                out.push_str(&full[own..]);
2666                delta
2667            } else {
2668                // A plain line gives back the ordinary step.
2669                let strip = outdent_width(full, Self::INDENT.len());
2670                out.push_str(&full[strip..]);
2671                -(strip as isize)
2672            };
2673            deltas.push(delta);
2674            line_off += full.len() + 1;
2675        }
2676        // Nothing to give back. Returning before the splice keeps an outdent at
2677        // column zero from spending an undo step on a document it never changed.
2678        if deltas.iter().all(|d| *d == 0) {
2679            return;
2680        }
2681
2682        // Every line's text keeps its offset *within the line*, so the caret is
2683        // remapped by its column, not by its byte offset — which the prefixes on
2684        // the lines above it have already invalidated.
2685        let remap = |off: usize| -> usize {
2686            let (mut old_ls, mut new_ls) = (start, start);
2687            for (line, delta) in lines.iter().zip(&deltas) {
2688                let old_le = old_ls + line.len();
2689                let new_len = (line.len() as isize + delta) as usize;
2690                if off <= old_le {
2691                    let col = (off - old_ls) as isize;
2692                    return new_ls + ((col + delta).max(0) as usize).min(new_len);
2693                }
2694                old_ls = old_le + 1;
2695                new_ls += new_len + 1;
2696            }
2697            start + out.len()
2698        };
2699        let placed = match self.selection() {
2700            // Keep the rewritten region selected, the way a container toggle
2701            // keeps its own: it leaves a second Tab aimed at the same lines
2702            // rather than at whatever the shifted offsets now happen to cover.
2703            Some(_) => (start + out.len(), Some(start)),
2704            None => (remap(self.caret), None),
2705        };
2706
2707        // A rolled-back splice leaves the old source in place, where every offset
2708        // computed above addresses text that was never written.
2709        if !self.splice(start, end, &out, EditKind::Other) {
2710            return;
2711        }
2712        // `splice` re-anchors to the end of the `Change`, which for a whole-region
2713        // rewrite is the last line's end — nowhere the caret was. Place it, then
2714        // re-record the caret so this is the state redo restores, not the one
2715        // `splice` left behind from the `Change`.
2716        self.caret = placed.0.min(self.source.len());
2717        self.anchor = placed.1;
2718        self.clamp_caret();
2719        self.record_caret();
2720    }
2721
2722    /// The Enter key.
2723    ///
2724    /// In source view it's a literal newline. In WYSIWYG it's **AST-aware**: a
2725    /// bare `\n` is only a markdown soft break (same paragraph), so the block the
2726    /// caret is in decides what actually gets written.
2727    ///
2728    ///   - paragraph            → twig's [`Editor::split_block`], which parts the
2729    ///                            block at the caret and reopens its container
2730    ///   - list item            → likewise: the next item, its indent, quote
2731    ///                            prefix and `[ ]` box all reproduced by twig —
2732    ///                            except an *empty* item, which exits the list
2733    ///   - block quote          → likewise: a new paragraph inside the quote
2734    ///   - heading              → a new *paragraph*, not another heading
2735    ///   - code block           → a literal newline (stay in the block)
2736    ///   - blank line           → a literal newline (one Backspace undoes it)
2737    ///   - [`LineFlow::Preserve`] → a single soft break, which renders as a
2738    ///                            visible line
2739    ///
2740    /// Where `split_block` is used it replaces markup leaf used to spell by hand,
2741    /// and it is better at it: it drops the whitespace the caret was sitting in
2742    /// front of instead of stranding it at the head of the second half, and it
2743    /// knows continuations leaf's marker scan never covered — a checklist item
2744    /// continues as an *unchecked* checklist item rather than a plain bullet.
2745    ///
2746    /// The exceptions above are exceptions because `split_block` is either wrong
2747    /// there or refuses: parting a fence yields two fences with the code split
2748    /// between them, parting a heading yields a second heading where every editor
2749    /// gives a paragraph, and a blank line, an empty item, a setext heading and a
2750    /// table all report an error rather than a split.
2751    pub fn newline(&mut self) {
2752        if self.view == View::Source {
2753            self.insert_raw("\n");
2754            return;
2755        }
2756        // Enter over a selection replaces it with a paragraph break.
2757        if let Some((s, e)) = self.selection() {
2758            self.splice(s, e, "\n\n", EditKind::Other);
2759            return;
2760        }
2761        // A caret resting exactly between an inline mark's content and its own
2762        // closing delimiter (`**bold**` with nothing after it on the line —
2763        // the WYSIWYG caret's natural end-of-line position) must not splice a
2764        // block break there: every path below eventually does via
2765        // `insert_raw`/`self.caret`, and splicing before the hidden closing
2766        // delimiter would strand it alone on the new line.
2767        self.caret = self.skip_trailing_close_delims(self.caret);
2768        // The block the caret is in. `block_offset_for_caret` nudges off a line
2769        // end (where the caret sits at the doc level); on a bare line (e.g. an
2770        // empty list item) fall back to the caret so the enclosing list/quote is
2771        // still visible in the ancestors.
2772        let off = self.block_offset_for_caret().unwrap_or(self.caret);
2773        let kinds: Vec<Kind> = self
2774            .editor
2775            .ancestors_at(off)
2776            .map(|c| c.into_iter().map(|m| m.kind).collect())
2777            .unwrap_or_default();
2778        let has = |k: Kind| kinds.contains(&k);
2779
2780        if has(Kind::CodeBlock) {
2781            self.insert_raw("\n");
2782            return;
2783        }
2784        // An *empty* list item exits the list — the standard double-Enter — which
2785        // `split_block` reports as an error rather than a split (there is no
2786        // content to part), so it stays leaf's. `list_marker_on_line` is itself
2787        // the AST gate — it answers from the tree, so a `- ` that reads as a
2788        // marker byte-for-byte but opens no item (a setext underline, a Djot
2789        // continuation line) never reaches here.
2790        if let Some(marker) = self.list_marker_on_line(self.caret)
2791            && self.item_is_empty(&marker)
2792        {
2793            self.exit_list(&marker);
2794            return;
2795        }
2796        // On an *empty* paragraph line, a lone Enter should add a single blank line,
2797        // not another full paragraph break — so it moves down one line and one
2798        // Backspace undoes it, not two. (`split_block` errors here too.)
2799        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
2800        let line_end = self.source[self.caret..]
2801            .find('\n')
2802            .map_or(self.source.len(), |i| self.caret + i);
2803        if self.source[line_start..line_end].trim().is_empty() {
2804            self.insert_raw("\n");
2805            return;
2806        }
2807        // In `Preserve` flow a soft break is a *visible* line the author means to
2808        // make, so Enter writes a single `\n` and typing continues the same
2809        // paragraph on the next line — the behaviour of an ordinary text editor.
2810        // A second Enter then lands on the blank line above and takes the
2811        // empty-line branch, so double-Enter still promotes to a full paragraph
2812        // break; and Backspace, which deletes a lone `\n` over a soft break,
2813        // undoes a single Enter symmetrically. In `Fold` flow a lone `\n` would
2814        // render as an invisible space, so Enter keeps making the paragraph break
2815        // that actually shows.
2816        //
2817        // Only in running prose. A list or a quote has a continuation of its own
2818        // to write, and a `\n` there is not a soft line but a lost container.
2819        let in_container = has(Kind::ListItem) || has(Kind::TaskListItem) || has(Kind::BlockQuote);
2820        if self.line_flow == LineFlow::Preserve && !in_container {
2821            self.insert_raw("\n");
2822            return;
2823        }
2824        // A heading gets a *paragraph*, never a second heading: Enter at the end
2825        // of a title is how every editor is asked for the body under it, and
2826        // `split_block` would repeat the `#` instead. Whitespace at the split
2827        // point goes with the break rather than opening the new paragraph, which
2828        // is what `split_block` does everywhere else.
2829        if has(Kind::Heading) {
2830            let mut end = self.caret;
2831            while self.source.as_bytes().get(end) == Some(&b' ') {
2832                end += 1;
2833            }
2834            self.splice(self.caret, end, "\n\n", EditKind::Other);
2835            return;
2836        }
2837        self.split_block_here();
2838    }
2839
2840    /// Part the block at the caret with twig's [`Editor::split_block`], leaving
2841    /// the caret in the second half.
2842    ///
2843    /// twig reopens whatever the first half was inside of — the bullet with its
2844    /// indent, the quote's `>`, a checklist item's `[ ]` — which is the whole
2845    /// reason this replaced the markup leaf used to spell from the line's bytes.
2846    /// It renumbers nothing, though: a new item mid-list is written with its
2847    /// neighbour's number, so [`renumber_here`](Self::renumber_here) still runs
2848    /// behind it, folded into the same undo step.
2849    ///
2850    /// Falls back to a plain paragraph break if twig declines, so an unhandled
2851    /// shape still moves the caret down rather than swallowing the keystroke.
2852    fn split_block_here(&mut self) {
2853        // The read-only gate — this door reaches twig without the splice.
2854        if self.read_only {
2855            return;
2856        }
2857        match self.editor.split_block(self.caret) {
2858            Ok(change) => {
2859                self.last_edit_kind = None;
2860                self.refresh();
2861                self.anchor = None;
2862                self.caret = change.new.end;
2863                self.dirty = self.source != self.clean_source;
2864                self.status = None;
2865                self.clamp_caret();
2866                self.record_caret();
2867                // Aimed at the new block's *start*: the caret twig leaves is one
2868                // past the marker it wrote, where there is no list in reach.
2869                self.renumber_at(change.new.start);
2870            }
2871            Err(_) => self.insert_raw("\n\n"),
2872        }
2873    }
2874
2875    /// Whether the item on the marker's line carries no content — the shape
2876    /// double-Enter reads as "I'm done with this list."
2877    fn item_is_empty(&self, line: &ListMarker) -> bool {
2878        let content_start = line.content_start().min(self.source.len());
2879        let line_end = self.source[self.caret..]
2880            .find('\n')
2881            .map(|i| self.caret + i)
2882            .unwrap_or(self.source.len());
2883        self.source[content_start..line_end.max(content_start)]
2884            .trim()
2885            .is_empty()
2886    }
2887
2888    /// Leave the list: replace the empty item's marker with a blank line, so the
2889    /// caret lands in a fresh paragraph below it.
2890    ///
2891    /// Inside a quote the blank line has to stay quoted (a bare one would end the
2892    /// quote), and the caret's new line keeps the `> ` it was already behind —
2893    /// leaving the list without also leaving the quote.
2894    fn exit_list(&mut self, line: &ListMarker) {
2895        let prefix = self.quote_prefix_at(line.marker_start);
2896        let blank = prefix.trim_end();
2897        self.splice(
2898            line.line_start,
2899            self.caret,
2900            &format!("{blank}\n{prefix}"),
2901            EditKind::Other,
2902        );
2903    }
2904
2905    /// What a line continuing the containers at `off` has to open with — the
2906    /// quote markers reproduced, each enclosing item's marker as its width in
2907    /// spaces. Also the column a nested item's marker stands in, which is what
2908    /// makes it Tab's answer.
2909    fn continuation_prefix_at(&mut self, off: usize) -> String {
2910        self.editor
2911            .document()
2912            .and_then(|mut d| d.continuation_prefix(off))
2913            .map(|p| p.text)
2914            .unwrap_or_default()
2915    }
2916
2917    /// The column a *nested list* may open at inside the item at `off` — which
2918    /// is not always where the item's own text continues.
2919    ///
2920    /// twig counts a task item's `[ ] ` box as part of its marker, correctly:
2921    /// it is markup a rich view hides, and the item's own wrapped text does
2922    /// stand past it. But a nested list may only open at the *list* marker's
2923    /// column, and four columns further in is an indented continuation of the
2924    /// paragraph instead — `- [ ] a` + `      - [ ] b` is one item, not two.
2925    /// So the box's own width goes back.
2926    ///
2927    /// The one place leaf still reads a checkbox's spelling. It goes when twig
2928    /// reports the list marker's column apart from the box; `checked` is what
2929    /// says a box is there at all, so only its width is being measured here.
2930    fn nesting_prefix_at(&mut self, off: usize) -> String {
2931        let cont = self.continuation_prefix_at(off);
2932        let Some(item) = self.innermost_list_item(off) else {
2933            return cont;
2934        };
2935        if item.checked.is_none() {
2936            return cont;
2937        }
2938        let box_width = item
2939            .marker_span
2940            .and_then(|m| self.source.get(m))
2941            .and_then(|marker| marker.rfind('[').map(|i| marker.len() - i))
2942            .unwrap_or(0);
2943        // The trailing columns are the ones the item's own marker contributed,
2944        // so trimming from the end leaves any quote prefix standing.
2945        cont[..cont.len().saturating_sub(box_width)].to_string()
2946    }
2947
2948    /// Where the line of the item *containing* the item at `off` begins — the
2949    /// prefix Shift+Tab moves back to, which gives up exactly the level the
2950    /// parent contributed. The quote prefix alone for a top-level item, which
2951    /// has no level left to give.
2952    fn outdent_prefix_at(&mut self, off: usize) -> String {
2953        let items: Vec<usize> = self
2954            .editor
2955            .document()
2956            .and_then(|mut d| d.ancestors_at_caret(off))
2957            .map(|c| {
2958                c.into_iter()
2959                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
2960                    .map(|m| m.span.start)
2961                    .collect()
2962            })
2963            .unwrap_or_default();
2964        // The second-innermost item is the parent; its own line's indent is the
2965        // target. `list_marker_on_line` gives that line's prefix directly.
2966        let parent = items.len().checked_sub(2).map(|i| items[i]);
2967        match parent.and_then(|p| self.list_marker_on_line(p)) {
2968            Some(m) => self.source[m.line_start..m.marker_start].to_string(),
2969            None => self.quote_prefix_at(off),
2970        }
2971    }
2972
2973    /// The block-quote prefix in force at `off` — `""` outside a quote, `"> "`
2974    /// inside one, `"> > "` inside two.
2975    ///
2976    /// Assembled from each enclosing quote's own [`FlatNode::marker_span`], so
2977    /// the `>` and the space after it are twig's spelling rather than leaf's.
2978    /// The whole line prefix can't answer this: it also carries the indent of
2979    /// whatever the quote holds, which a blank separator line must *not* repeat.
2980    fn quote_prefix_at(&mut self, off: usize) -> String {
2981        let Ok(chain) = self
2982            .editor
2983            .document()
2984            .and_then(|mut d| d.ancestors_at_caret(off))
2985        else {
2986            return String::new();
2987        };
2988        let quotes: Vec<usize> = chain
2989            .iter()
2990            .filter(|m| m.kind == Kind::BlockQuote)
2991            .map(|m| m.node_id as usize)
2992            .collect();
2993        let Ok(nodes) = self.editor.nodes() else {
2994            return String::new();
2995        };
2996        quotes
2997            .iter()
2998            .filter_map(|id| nodes.get(*id)?.marker_span.clone())
2999            .filter_map(|s| self.source.get(s))
3000            .collect()
3001    }
3002
3003    /// Whether the item at `off` sits inside another one — the test Backspace
3004    /// uses to choose between outdenting and dropping the marker.
3005    ///
3006    /// Counted from the AST rather than from the line's leading whitespace,
3007    /// which is indentation in Markdown and, in Djot, may be nothing at all.
3008    fn item_is_nested(&mut self, off: usize) -> bool {
3009        self.editor
3010            .document()
3011            .and_then(|mut d| d.ancestors_at_caret(off))
3012            .map(|c| {
3013                c.into_iter()
3014                    .filter(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)
3015                    .count()
3016                    > 1
3017            })
3018            .unwrap_or(false)
3019    }
3020
3021    /// The innermost list item containing `probe`, under twig's **caret**
3022    /// containment rule — a block's end is inside it.
3023    ///
3024    /// Half-open containment can't answer this. An empty item's span is exactly
3025    /// its marker, so the caret sitting after `- ` is one past the end and the
3026    /// item it is plainly in tests as out of reach; that is the shape
3027    /// double-Enter has to recognise to leave the list.
3028    fn innermost_list_item(&mut self, probe: usize) -> Option<FlatNode> {
3029        let chain = self
3030            .editor
3031            .document()
3032            .and_then(|mut d| d.ancestors_at_caret(probe))
3033            .ok()?;
3034        let id = chain
3035            .iter()
3036            .rev()
3037            .find(|m| m.kind == Kind::ListItem || m.kind == Kind::TaskListItem)?
3038            .node_id as usize;
3039        self.editor.nodes().ok()?.get(id).cloned()
3040    }
3041
3042    /// The list marker opening `off`'s line, per twig — `None` when that line
3043    /// opens no list item.
3044    ///
3045    /// [`Document::line_prefix`] is the whole hidden run from the line start:
3046    /// `>   1. ` is a quote's marker, an indent, and an item's marker together,
3047    /// and it is `None` on a *continuation* line, which opens nothing. That last
3048    /// case is the one leaf could never get right by reading bytes. `- a\n  - b`
3049    /// is two items in Markdown and one in Djot, where a marker cannot interrupt
3050    /// a paragraph and `  - b` is literal text — identical bytes, and only the
3051    /// parser knows which document it is looking at.
3052    ///
3053    /// The item's own marker is separated out via its
3054    /// [`FlatNode::marker_span`], so `marker_start` splits the prefix into what
3055    /// the containers around it contribute and what the item does.
3056    fn list_marker_on_line(&mut self, off: usize) -> Option<ListMarker> {
3057        let off = off.min(self.source.len());
3058        let prefix = self.editor.document().ok()?.line_prefix(off).ok()??;
3059        // The prefix belongs to a list only when an item's marker closes it —
3060        // a heading's `# ` or a bare quote's `> ` is a prefix too.
3061        let item = self.innermost_list_item(prefix.end.min(self.source.len()))?;
3062        let marker = item.marker_span.clone()?;
3063        if marker.end != prefix.end {
3064            return None;
3065        }
3066        Some(ListMarker {
3067            line_start: prefix.start,
3068            marker_start: marker.start,
3069            text: self.source.get(prefix)?.to_string(),
3070        })
3071    }
3072
3073    /// Whether the list item on `line_start`'s line is the **first item** of its
3074    /// list — the one Tab must not nest, because nesting needs a preceding
3075    /// sibling to become the new parent and a first item has none. `false` for a
3076    /// line that isn't a list item, and for an item with a sibling above it (the
3077    /// one Tab *can* nest). Gated on the AST, not the marker bytes: `- ` reads
3078    /// the same in a setext underline that opens no list at all.
3079    fn first_item_of_list(&mut self, line_start: usize) -> bool {
3080        let Some(marker) = self.list_marker_on_line(line_start) else {
3081            return false;
3082        };
3083        // Probe just inside the marker, where the item's own node is in reach —
3084        // the marker offset itself can resolve to the enclosing list, not the
3085        // `list_item`, whose span starts at the marker.
3086        let probe = marker.content_start().min(self.source.len());
3087        let Some(item) = self.innermost_list_item(probe) else {
3088            return false;
3089        };
3090        let Ok(nodes) = self.editor.nodes() else {
3091            return false;
3092        };
3093        match item.parent {
3094            // First when the parent list opens with this very item.
3095            Some(pid) => nodes
3096                .get(pid.0 as usize)
3097                .is_some_and(|p| p.first_child == Some(item.id)),
3098            // A parentless item is trivially the first (and only) one.
3099            None => true,
3100        }
3101    }
3102
3103    pub fn backspace(&mut self) {
3104        if let Some((s, e)) = self.selection() {
3105            self.splice(s, e, "", EditKind::Other);
3106            return;
3107        }
3108        // WYSIWYG: Backspace at the very start of a list item's content is a
3109        // structural key, not a character delete — it walks the "un-indent, then
3110        // un-list" ladder every list editor gives that keystroke (outdent a
3111        // nested item, strip a top-level one's marker to a paragraph). In source
3112        // view the `- ` is visible text the user is deleting a byte of, so it
3113        // keeps its literal meaning there, like Enter does.
3114        if self.view != View::Source && self.backspace_list_start() {
3115            return;
3116        }
3117        // WYSIWYG: and the same at the start of a heading's content — the `# `
3118        // there is markup the rich view hides, not text the user typed.
3119        if self.view != View::Source && self.backspace_heading_start() {
3120            return;
3121        }
3122        // WYSIWYG: and at the start of a block whose presentation is spelled
3123        // as hidden markup before it — djot's `{.center}` line, Markdown's
3124        // `<div class="center">` — Backspace takes that markup, the way it
3125        // takes a heading's `#`, rather than a byte out of it.
3126        if self.view != View::Source && self.backspace_attributed_block_start() {
3127            return;
3128        }
3129        // WYSIWYG: at a block picture's stops, a byte-at-a-time delete would take
3130        // the markup apart under a caret that cannot see it — see
3131        // `delete_around_block_media`.
3132        if self.view != View::Source && self.delete_around_block_media(false) {
3133            return;
3134        }
3135        // WYSIWYG: Backspace at a table's trailing stop steps back into its last
3136        // cell rather than taking the byte behind the caret — the row's closing
3137        // `|`, which the rich view never drew, so the key would have looked like
3138        // it did nothing. The stop before is the last cell's end.
3139        if self.view != View::Source && self.backspace_at_table_end() {
3140            return;
3141        }
3142        // WYSIWYG: at the start of a block's content, the byte behind the caret
3143        // is a block boundary, and Backspace over one is a join — twig's, so
3144        // that what a join is in each format is not this file's to know. After
3145        // the picture and table cases, which are block starts with their own
3146        // answers.
3147        if self.view != View::Source && self.backspace_joins_block() {
3148            return;
3149        }
3150        // WYSIWYG: Backspace on a *blank line* deletes back to the previous caret
3151        // stop, not a single newline. On a line with no text of its own, the byte
3152        // before the caret is a `\n` that spells part of a block boundary — the gap
3153        // between two blocks, drawn but never a caret home. Removing just it strands
3154        // the caret in that gap and leaves an odd blank line the eye reads as one
3155        // separator but the caret can't land on: the "extra newline" left behind
3156        // after leaving a list (Enter, Enter) or a paragraph and pressing Backspace.
3157        // Deleting to the previous stop instead collapses the whole break at once,
3158        // landing the caret at the end of the block above. Two blank lines in a row
3159        // are one stop apart, so this still removes exactly one — the lone-Enter /
3160        // lone-Backspace symmetry the empty-line case is built on is untouched.
3161        if self.view != View::Source
3162            && self.caret > self.caret_floor()
3163            && self.caret_on_blank_line()
3164            && let Some(stop) = self.vmap.stop_before(self.caret)
3165        {
3166            let stop = stop.max(self.caret_floor());
3167            if stop < self.caret {
3168                if self.source[stop..self.caret].trim().is_empty() {
3169                    self.splice(stop, self.caret, "", EditKind::Delete);
3170                } else {
3171                    // Hidden markup stands between the stop and the caret — a
3172                    // `</div>`, a comment, a link reference definition — and
3173                    // collapsing to the stop would delete it. Take the blank
3174                    // line alone, with the newline that opened it, and land
3175                    // the caret where the collapse would have.
3176                    self.delete_blank_line_to(stop);
3177                }
3178                return;
3179            }
3180        }
3181        if self.caret > self.caret_floor() {
3182            // An in-cell `<br>` draws as one newline glyph, so Backspace over it
3183            // takes the whole tag — a single-byte step would leave a broken `<br`
3184            // showing in the cell. Rich view only (source view edits the literal).
3185            if self.view != View::Source
3186                && let Some((start, end)) = self.cell_break_at(BreakEdge::Backward)
3187            {
3188                let start = start.max(self.caret_floor());
3189                if start < end {
3190                    self.splice(start, end, "", EditKind::Delete);
3191                    return;
3192                }
3193            }
3194            // Aim the delete at the character the writer can *see* behind the
3195            // caret, never at a delimiter the rich view drew nothing for. Two
3196            // steps, and either can apply: from the far side of a run's closing
3197            // `**` step back into the run (the caret is drawn at the end of its
3198            // word), and at the start of a run's text step out past its opening
3199            // `**` to the character in front of it, leaving the run standing.
3200            // Without them a plain Backspace unspells the phrase it is editing
3201            // and leaves a literal asterisk on screen.
3202            let end = if self.view == View::Source {
3203                self.caret
3204            } else {
3205                let inside = self.step_inside_close_delims(self.caret);
3206                // An attributed span with no text — `<span …></span>` as the
3207                // file was written — is hidden markup around nothing, and a
3208                // byte-step here would take its `>`. Backspace takes the span
3209                // whole, with the character before it: the character the key
3210                // looks aimed at, since the span draws nothing.
3211                if let Some(span) = self.run_span_of_content(inside..inside) {
3212                    let from = if self.source[..span.start].ends_with('\n') {
3213                        span.start
3214                    } else {
3215                        prev_boundary(&self.source, span.start)
3216                    };
3217                    let from = from.max(self.caret_floor());
3218                    self.splice(from, span.end, "", EditKind::Delete);
3219                    return;
3220                }
3221                self.skip_leading_open_delims(inside)
3222                    .max(self.caret_floor())
3223            };
3224            // Never delete back across the floor — that would eat hidden
3225            // frontmatter the WYSIWYG caret can't even see.
3226            let mut prev = prev_boundary(&self.source, end).max(self.caret_floor());
3227            // Take a hidden escape backslash with the char it escapes: the rich
3228            // view draws `\*` as a single `*`, so Backspace over it must delete
3229            // both bytes, never strand the `\` as a lone visible backslash (the
3230            // mirror of the Hidden-mode typing that wrote the escape). Source view
3231            // shows the `\`, so there it is an ordinary character.
3232            if self.view != View::Source
3233                && prev > self.caret_floor()
3234                && self.is_hidden_escape(prev - 1)
3235            {
3236                prev -= 1;
3237            }
3238            // The delete that takes the last of a span's text takes the span
3239            // with it, in the same edit: `<span …>i</span>` losing its `i`
3240            // would leave an empty span the map has no stop inside, so the
3241            // caret would draw at the next stop — a line away — until a
3242            // further key removed the span. Landing on the span's start is
3243            // where the letter was.
3244            if self.view != View::Source
3245                && let Some(span) = self.run_span_of_content(prev..end)
3246            {
3247                self.splice(span.start, span.end, "", EditKind::Delete);
3248                return;
3249            }
3250            // And the same for a block: the letter that was all of a centred
3251            // paragraph's text goes with the `<div>` around it, or the `{…}`
3252            // line above it, leaving a plain blank line where the letter was.
3253            // A paragraph with no text is no block, so the markup would stand
3254            // around nothing, the map would give it no caret home, and the
3255            // next key would take the tag apart.
3256            if self.view != View::Source
3257                && let Some(block) = self.attributed_block_of_content(prev..end)
3258            {
3259                self.splice(block.start, block.end, "", EditKind::Delete);
3260                return;
3261            }
3262            if prev < end {
3263                self.splice(prev, end, "", EditKind::Delete);
3264            }
3265        }
3266    }
3267
3268    /// Remove the blank line the caret is on — its own newline and the one
3269    /// that ended the line before it — and put the caret on `stop`, the caret
3270    /// stop before it. The [`backspace`](Self::backspace) blank-line rule for a
3271    /// blank line that hidden markup separates from the block above: the
3272    /// navigable blank row after a `</div>` is always one of at least three
3273    /// newlines under the tag (the drawn separators either side of it), so
3274    /// taking two leaves the blank line the tag needs under it.
3275    fn delete_blank_line_to(&mut self, stop: usize) {
3276        let caret = self.caret;
3277        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
3278        let line_end = self.source[caret..]
3279            .find('\n')
3280            .map_or(self.source.len(), |i| caret + i);
3281        let from = line_start.saturating_sub(1).max(stop);
3282        let to = (line_end + 1).min(self.source.len());
3283        self.splice(from, to, "", EditKind::Delete);
3284        self.caret = stop;
3285        self.anchor = None;
3286        self.goal_col = None;
3287        self.record_caret();
3288    }
3289
3290    /// Move the caret to the stop before it and consume the key — what
3291    /// Backspace does where the byte behind the caret is hidden markup it
3292    /// has no structural answer for, rather than take that markup apart.
3293    fn step_back_to_stop(&mut self) {
3294        // The map answers about offsets, so it has to be this revision's — see
3295        // `open_paragraph_at_block_edge`.
3296        self.rebuild_map();
3297        if let Some(off) = self
3298            .vmap
3299            .stop_before(self.caret)
3300            .filter(|&o| o >= self.caret_floor())
3301        {
3302            self.caret = off;
3303            self.anchor = None;
3304            self.goal_col = None;
3305        }
3306    }
3307
3308    /// Backspace's presentation behaviour: with the caret exactly at the start
3309    /// of a block's content, and that block's attributes spelled as hidden
3310    /// markup before it, strip the attributes. The peer of
3311    /// [`backspace_heading_start`](Self::backspace_heading_start), and the same
3312    /// reasoning: the `{.center}` line above a djot block and the
3313    /// `<div class="center">` around a Markdown one are what the byte behind
3314    /// the caret belongs to, and the rich view draws neither. The ordinary
3315    /// delete took the newline out of `{.center}\nhello` and left
3316    /// `{.center}hello` — the attribute line fused onto the text as prose —
3317    /// and out of `<div …>\n\nhello` it took the blank line the div needs.
3318    ///
3319    /// The whole attribute set goes, the way the whole `#` marker does — the
3320    /// press is over the line that spells it, not over one key of it — and
3321    /// twig's `set_block_attrs` with an empty list is the edit: it removes the
3322    /// djot line and unwraps the Markdown div. Where the block is the first of
3323    /// several in a div, twig has no sole child to unwrap and answers with a
3324    /// no-op, so the caret steps back to the stop before instead, as it does
3325    /// at a table's end. A later child of the div has an ordinary paragraph
3326    /// above it and is not this rule's.
3327    ///
3328    /// Returns whether it acted; `false` leaves Backspace its character delete.
3329    fn backspace_attributed_block_start(&mut self) -> bool {
3330        if !matches!(self.format, Format::Markdown | Format::Djot) {
3331            return false;
3332        }
3333        let caret = self.caret;
3334        let nodes = self.nodes();
3335        let Some(block) = nodes
3336            .iter()
3337            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3338            .find(|n| n.content_span.as_ref().map_or(n.span.start, |c| c.start) == caret)
3339        else {
3340            return false;
3341        };
3342        match self.format {
3343            Format::Djot => {
3344                // twig records where the `{…}` block was written, so this is
3345                // the parser's own answer and not a scan for a `{` above the
3346                // block; `None` (a synthesized or merged set) is not a line
3347                // the caret is standing after.
3348                let spelled = self
3349                    .editor
3350                    .document()
3351                    .ok()
3352                    .and_then(|mut d| d.attrs_span(block.id).ok().flatten())
3353                    .is_some_and(|s| s.end <= caret);
3354                if !spelled {
3355                    return false;
3356                }
3357            }
3358            _ => {
3359                let Some(div) = block
3360                    .parent
3361                    .and_then(|p| nodes.iter().find(|n| n.id == p))
3362                    .filter(|p| wysiwyg::element_tag(p) == Some("div"))
3363                else {
3364                    return false;
3365                };
3366                let mut kids = nodes.iter().filter(|n| n.parent == Some(div.id));
3367                if kids.clone().any(|k| k.span.start < block.span.start) {
3368                    return false;
3369                }
3370                if kids.nth(1).is_some() {
3371                    self.step_back_to_stop();
3372                    return true;
3373                }
3374            }
3375        }
3376        self.write_block_attrs("block attributes", Vec::new());
3377        true
3378    }
3379
3380    /// Backspace at the start of a block's content: join the block into the
3381    /// block before it, as one gesture — twig's `join_blocks`, the inverse of
3382    /// the split Enter makes, spelled the format's way. Two paragraphs join
3383    /// on a soft break; a paragraph under a marker heading joins onto the
3384    /// heading's line; a paragraph after a Markdown `<div>` moves inside it,
3385    /// the hidden `</div>` carried past the joined text; HTML's `</p><p>` is
3386    /// taken as one; a quote's or an item's continuation prefix is written.
3387    /// The joined text takes the block above's presentation and containers,
3388    /// which is the rule every editor with a centred paragraph follows.
3389    ///
3390    /// Leaf used to join by deleting the one newline behind the caret, which
3391    /// is the right bytes for two Markdown paragraphs and nothing else: under
3392    /// a heading it left two blocks, in HTML it took the `>` off a tag, and
3393    /// after a div it took the newline under the hidden `</div>`, which drew
3394    /// nothing different and took the tag apart on the next press. What a
3395    /// join is in each format is twig's to know, and now it does.
3396    ///
3397    /// Where twig refuses — the block above is a code block, a table or a
3398    /// rule with no text to join into, or the caret's block would have to
3399    /// leave a div that holds more after it — the caret steps back to the
3400    /// stop before instead, as it does at a table's end: the key moves the
3401    /// caret and takes no markup apart. Where nothing precedes the block, or
3402    /// the format cannot join at all, Backspace keeps its character delete.
3403    ///
3404    /// Returns whether it acted.
3405    fn backspace_joins_block(&mut self) -> bool {
3406        let caret = self.caret;
3407        if caret <= self.caret_floor() {
3408            return false;
3409        }
3410        let Some(text) = self.text_block_opening_at(caret) else {
3411            return false;
3412        };
3413        match self.join_blocks(caret) {
3414            Ok(change) => {
3415                // The caret keeps its place at the start of the text it stood
3416                // on, wherever the join put that text — after a soft break,
3417                // a space, or a quote's prefix. Found by the bytes, as
3418                // `block_content_in` finds a re-spelled block.
3419                let region = &self.source[change.new.clone()];
3420                let at = region
3421                    .find(&text)
3422                    .map_or(change.new.start, |i| change.new.start + i);
3423                self.land_after_join(at);
3424                true
3425            }
3426            Err(twig::Error::NotEditable) => {
3427                self.step_back_to_stop();
3428                true
3429            }
3430            Err(twig::Error::NotFound | twig::Error::UnsupportedFormat) => false,
3431            Err(e) => {
3432                self.status = Some(format!("join: {e}"));
3433                true
3434            }
3435        }
3436    }
3437
3438    /// Delete at the end of a block's content: join the block after it into
3439    /// this one — [`backspace_joins_block`](Self::backspace_joins_block)'s
3440    /// mirror, and the same twig gesture aimed at the next block. The caret
3441    /// stays where it was, which is where the joined text now begins after
3442    /// the separator. Where twig refuses, the caret steps forward to the next
3443    /// stop instead; where no block follows, Delete keeps its character
3444    /// delete.
3445    fn delete_forward_joins_block(&mut self) -> bool {
3446        let caret = self.caret;
3447        let at_end = self
3448            .nodes()
3449            .iter()
3450            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3451            .any(|n| n.content_span.as_ref().is_some_and(|c| c.end == caret));
3452        if !at_end {
3453            return false;
3454        }
3455        // The next stop, across a line end, in a block: what Delete at a
3456        // block's end points at. On the same line it is a hidden delimiter's
3457        // far side, which the ordinary delete handles; on a blank line it is
3458        // the empty paragraph the byte delete has always closed.
3459        self.rebuild_map();
3460        let Some(stop) = self.vmap.stop_after(caret) else {
3461            return false;
3462        };
3463        if !self.source[caret..stop].contains('\n') || !self.has_block_at(stop) {
3464            return false;
3465        }
3466        match self.join_blocks(stop) {
3467            Ok(change) => {
3468                self.land_after_join(change.old.start);
3469                true
3470            }
3471            Err(twig::Error::NotEditable | twig::Error::NotFound) => {
3472                self.caret = stop;
3473                self.anchor = None;
3474                self.goal_col = None;
3475                true
3476            }
3477            Err(twig::Error::UnsupportedFormat) => false,
3478            Err(e) => {
3479                self.status = Some(format!("join: {e}"));
3480                true
3481            }
3482        }
3483    }
3484
3485    /// The content bytes of the paragraph or heading whose content opens
3486    /// exactly at `off` — the block a Backspace there is at the start of.
3487    fn text_block_opening_at(&mut self, off: usize) -> Option<String> {
3488        self.nodes()
3489            .into_iter()
3490            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
3491            .find_map(|n| {
3492                let c = n.content_span?;
3493                (c.start == off).then(|| self.source[c].to_string())
3494            })
3495    }
3496
3497    /// Hand the block at `offset` to twig's `join_blocks`, with the undo
3498    /// plumbing every structural gesture has; the caret is the caller's to
3499    /// place from the change, via [`land_after_join`](Self::land_after_join).
3500    fn join_blocks(&mut self, offset: usize) -> Result<Change, twig::Error> {
3501        if self.read_only {
3502            return Err(twig::Error::NotEditable);
3503        }
3504        self.record_caret();
3505        let change = self.editor.join_blocks(offset)?;
3506        self.last_edit_kind = None; // structural edit is its own undo step
3507        self.refresh();
3508        Ok(change)
3509    }
3510
3511    /// Finish a join: the caret at `at`, no selection, the map this
3512    /// revision's before the clamp — see `write_block_attrs` for why.
3513    fn land_after_join(&mut self, at: usize) {
3514        self.caret = at;
3515        self.anchor = None;
3516        self.goal_col = None;
3517        self.dirty = self.source != self.clean_source;
3518        self.status = None;
3519        self.rebuild_map();
3520        self.clamp_caret();
3521        self.record_caret();
3522    }
3523
3524    // ── moving a block ─────────────────────────────────────────────────────────
3525
3526    /// The block a move picks up at `offset` — the same one
3527    /// [`select_block_at`](Self::select_block_at) selects (the deepest node
3528    /// that is neither inline nor a multi-block container), widened to the
3529    /// list item when it is the item's first block, because that is what
3530    /// twig's `move_block` moves: a bullet's text is the bullet, and dragging
3531    /// it takes the item and everything under it. A block later in an item's
3532    /// tail moves alone. `None` on a blank line, and past the source.
3533    fn movable_block_at(&mut self, offset: usize) -> Option<FlatNode> {
3534        let off = offset.min(self.source.len());
3535        let chain = self.editor.ancestors_at(off).ok()?;
3536        let block = chain
3537            .iter()
3538            .rev()
3539            .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
3540        let nodes = self.nodes();
3541        let block = nodes.get(block.node_id as usize)?.clone();
3542        let item = block
3543            .parent
3544            .and_then(|p| nodes.get(p.0 as usize))
3545            .filter(|p| matches!(p.kind, Kind::ListItem | Kind::TaskListItem))
3546            .filter(|p| p.first_child == Some(block.id));
3547        Some(item.cloned().unwrap_or(block))
3548    }
3549
3550    /// The source range of the block a move would pick up at `offset` — for
3551    /// the outline of the block being carried, without moving the caret. The
3552    /// range [`select_block_at`](Self::select_block_at) would select, except
3553    /// that it is the whole item for a bullet's text, as
3554    /// [`move_block`](Self::move_block) is.
3555    pub fn block_range_at(&mut self, offset: usize) -> Option<Range<usize>> {
3556        self.movable_block_at(offset).map(|b| b.span)
3557    }
3558
3559    /// Move the block at `from` to the boundary `to` — twig's `move_block`,
3560    /// with the undo plumbing every structural gesture has and the caret
3561    /// riding the block to its new place. `from` is any offset inside the
3562    /// block (the one [`block_range_at`](Self::block_range_at) finds); `to`
3563    /// is a position *between* blocks — a block's first byte lands before it,
3564    /// its last after it, a blank line is itself a boundary, and the source's
3565    /// length is the document's end. The block takes the line prefixes of
3566    /// the container the boundary is inside — a `> ` on the way into a quote,
3567    /// none on the way out — and the blank lines a person would have typed
3568    /// are written and removed; twig spells all of that.
3569    ///
3570    /// A move that would move nothing — `to` inside the block, or on the
3571    /// boundary it already sits on — is a quiet no-op rather than an error,
3572    /// since it is what a block dropped back where it was asks for. Any other
3573    /// refusal reaches the status line: a `to` interior to a fence or a table,
3574    /// or a format with no blocks a caret could name.
3575    ///
3576    /// In WYSIWYG the frontmatter is hidden, and a boundary above it is not
3577    /// one a person can see: `to` is raised to the first rendered offset.
3578    ///
3579    /// `true` when the document changed. A move twig accepts that rewrites
3580    /// nothing — a one-item list sent past a paragraph is still a one-item
3581    /// list past that paragraph — is taken back rather than left as an undo
3582    /// step with no difference in it, and is `false` too.
3583    pub fn move_block(&mut self, from: usize, to: usize) -> bool {
3584        if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
3585            return false;
3586        }
3587        let len = self.source.len();
3588        let from = from.min(len);
3589        let to = to.clamp(self.caret_floor().min(len), len);
3590        let Some(block) = self.movable_block_at(from) else {
3591            self.status = Some("move block: no block here".into());
3592            return false;
3593        };
3594        // Where the caret stands in the block, as (line within the block,
3595        // bytes back from that line's end) — the shape that survives a
3596        // prefix being added or stripped on the way through a container.
3597        let caret = self.caret.clamp(block.span.start, block.span.end);
3598        let block_line = self.source[block.span.start..caret].matches('\n').count();
3599        let line_end = self.source[caret..block.span.end]
3600            .find('\n')
3601            .map_or(block.span.end, |i| caret + i);
3602        let tail = line_end - caret;
3603        let block_lines = self.source[block.span.start..block.span.end]
3604            .matches('\n')
3605            .count()
3606            + 1;
3607        let upward = to <= block.span.start;
3608        self.record_caret();
3609        match self.editor.move_block(from, to) {
3610            Ok(_) if self.editor.source_str().ok().as_deref() == Some(self.source.as_str()) => {
3611                let _ = self.editor.undo();
3612                self.status = None;
3613                false
3614            }
3615            Ok(change) => {
3616                self.last_edit_kind = None; // structural edit is its own undo step
3617                self.refresh();
3618                let at = self.moved_block_caret(&change.new, upward, block_lines, block_line, tail);
3619                self.caret = at;
3620                self.anchor = None;
3621                self.goal_col = None;
3622                self.dirty = self.source != self.clean_source;
3623                self.status = None;
3624                self.rebuild_map();
3625                self.clamp_caret();
3626                self.record_caret();
3627                true
3628            }
3629            // Nothing to move: the block dropped back onto its own boundary.
3630            Err(twig::Error::InvalidArgument) => {
3631                self.status = None;
3632                false
3633            }
3634            Err(e) => {
3635                self.status = Some(format!("move block: {e}"));
3636                false
3637            }
3638        }
3639    }
3640
3641    /// The caret's place in the rewritten region after a move: the moved
3642    /// block's lines open the region when it went up (below the blank line it
3643    /// was dropped on, when it was) and close it (above the separator twig
3644    /// wrote after it) when it went down, and within them the
3645    /// caret keeps its line and its distance from that line's end. Clamped
3646    /// into the region rather than trusted, since a block can lose a line on
3647    /// the way — a quote it was the only content of goes with it.
3648    fn moved_block_caret(
3649        &self,
3650        new: &Range<usize>,
3651        upward: bool,
3652        block_lines: usize,
3653        block_line: usize,
3654        tail: usize,
3655    ) -> usize {
3656        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
3657        let mut lines: Vec<(usize, usize)> = Vec::new();
3658        let mut start = 0;
3659        loop {
3660            match region[start..].find('\n') {
3661                Some(i) => {
3662                    lines.push((start, start + i));
3663                    start += i + 1;
3664                }
3665                None => {
3666                    lines.push((start, region.len()));
3667                    break;
3668                }
3669            }
3670        }
3671        // A separator line is blank, or a quote's bare `>`; the region can
3672        // open with one (the blank line the block was dropped on) or close
3673        // with one (the one twig wrote after it).
3674        let separator = |&(s, e): &(usize, usize)| {
3675            region[s..e]
3676                .trim()
3677                .trim_start_matches('>')
3678                .trim()
3679                .is_empty()
3680        };
3681        let first = if upward {
3682            lines.iter().position(|l| !separator(l)).unwrap_or(0)
3683        } else {
3684            let filled = lines
3685                .iter()
3686                .rposition(|l| !separator(l))
3687                .map_or(0, |i| i + 1);
3688            filled.saturating_sub(block_lines)
3689        };
3690        let (s, e) = lines[(first + block_line).min(lines.len() - 1)];
3691        new.start + e.saturating_sub(tail).max(s)
3692    }
3693
3694    /// Move the caret's block one place up — Alt+↑: above the block before it,
3695    /// and out of its container, to just above it, when it is the first block
3696    /// there. Nothing above the document's first block, and nothing to do for
3697    /// a caret on a blank line; both say so in the status line.
3698    pub fn move_block_up(&mut self) {
3699        self.move_block_by(true);
3700    }
3701
3702    /// Move the caret's block one place down — Alt+↓, the mirror of
3703    /// [`move_block_up`](Self::move_block_up): below the block after it, and
3704    /// out of its container when it is the last block there.
3705    pub fn move_block_down(&mut self) {
3706        self.move_block_by(false);
3707    }
3708
3709    fn move_block_by(&mut self, up: bool) {
3710        if self.read_only || self.refuse_unsupported("move block", Gesture::MoveBlock) {
3711            return;
3712        }
3713        let Some(from) = self.block_offset_for_caret() else {
3714            self.status = Some("move block: no block here".into());
3715            return;
3716        };
3717        let Some(block) = self.movable_block_at(from) else {
3718            self.status = Some("move block: no block here".into());
3719            return;
3720        };
3721        let nodes = self.nodes();
3722        let to = if up {
3723            self.boundary_above(&nodes, &block)
3724        } else {
3725            self.boundary_below(&nodes, &block)
3726        };
3727        if to.is_none_or(|to| !self.move_block(from, to)) && self.status.is_none() {
3728            self.status = Some(if up {
3729                "move block: nothing above".into()
3730            } else {
3731                "move block: nothing below".into()
3732            });
3733        }
3734    }
3735
3736    /// The boundary one step above `block`: before its previous sibling, or
3737    /// — for the first block in a container — before the container itself.
3738    /// `None` for the document's first block.
3739    fn boundary_above(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
3740        let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
3741        let previous = nodes
3742            .iter()
3743            .filter(|n| n.parent == Some(parent.id))
3744            .take_while(|n| n.id != block.id)
3745            .last();
3746        match previous {
3747            Some(p) => self.before(p),
3748            None if parent.kind == Kind::Doc => None,
3749            // An item leaving a nested list upward goes before the item
3750            // holding that list, as an item of the outer one; the list's own
3751            // first byte is inside the holding item's tail.
3752            None if matches!(
3753                parent.kind,
3754                Kind::BulletList | Kind::OrderedList | Kind::TaskList
3755            ) =>
3756            {
3757                match parent.parent.and_then(|p| nodes.get(p.0 as usize)) {
3758                    Some(item) if matches!(item.kind, Kind::ListItem | Kind::TaskListItem) => {
3759                        self.before(item)
3760                    }
3761                    _ => self.before(parent),
3762                }
3763            }
3764            None => self.before(parent),
3765        }
3766    }
3767
3768    /// The boundary one step below `block`: after its next sibling, or — for
3769    /// the last block in a container — just past the container, at its
3770    /// parent's level ([`exit_below`](Self::exit_below)). `None` for the
3771    /// document's last block.
3772    fn boundary_below(&self, nodes: &[FlatNode], block: &FlatNode) -> Option<usize> {
3773        let parent = block.parent.and_then(|p| nodes.get(p.0 as usize))?;
3774        if let Some(n) = block.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
3775            return Some(self.after(n));
3776        }
3777        match parent.kind {
3778            Kind::Doc => None,
3779            _ => self.exit_below(nodes, parent),
3780        }
3781    }
3782
3783    /// The boundary just past `container` at its parent's level: before its
3784    /// next sibling, or past its parent when it is the last thing there —
3785    /// the document's end at the top. Leaving a list item is the exception
3786    /// that keeps a block in the list: the next item's tail, which is what
3787    /// [`after`](Self::after) an item names.
3788    fn exit_below(&self, nodes: &[FlatNode], container: &FlatNode) -> Option<usize> {
3789        let item = matches!(container.kind, Kind::ListItem | Kind::TaskListItem);
3790        match container.next_sibling.and_then(|n| nodes.get(n.0 as usize)) {
3791            Some(n) if item => Some(self.after(n)),
3792            Some(n) => self.before(n),
3793            None => {
3794                let parent = container.parent.and_then(|p| nodes.get(p.0 as usize))?;
3795                match parent.kind {
3796                    Kind::Doc => Some(self.source.len()),
3797                    _ => self.exit_below(nodes, parent),
3798                }
3799            }
3800        }
3801    }
3802
3803    /// The boundary before `node`: its first byte. twig reads a container's
3804    /// opening as before it — a quote's marker, a fence's first byte — so the
3805    /// span's start is the boundary at the parent's level for every kind.
3806    fn before(&self, node: &FlatNode) -> Option<usize> {
3807        Some(node.span.start)
3808    }
3809
3810    /// The boundary after `node`: its own end, which for a container proper
3811    /// (a quote, a list, a fenced or tagged container) is the end of its last
3812    /// line — after its last block, still inside it, where a block arriving
3813    /// from outside joins it. Past the container is the next line's start: a
3814    /// blank line, the next block, or the document's end.
3815    fn after(&self, node: &FlatNode) -> usize {
3816        let container = is_block_container(&node.kind)
3817            && !matches!(node.kind, Kind::ListItem | Kind::TaskListItem);
3818        let end = node.span.end.min(self.source.len());
3819        if container && self.source.as_bytes().get(end) == Some(&b'\n') {
3820            end + 1
3821        } else {
3822            end
3823        }
3824    }
3825
3826    /// Where a block dragged over rendered row `row` would land — the
3827    /// boundary before the row's block when the row is in its upper half, the
3828    /// boundary after it otherwise, and the document's end for a row below
3829    /// everything. `None` for a row that holds no block (a decoration row is
3830    /// resolved to the block under it, so this is a table's bottom rule with
3831    /// nothing after it, or a map with no rows). The frontend draws its
3832    /// indicator above [`DropTarget::row`] and hands
3833    /// [`DropTarget::offset`] to [`move_block`](Self::move_block).
3834    ///
3835    /// The block is the deepest one, not the widened item: a drop on a
3836    /// bullet's text lands before or after that bullet, and its nested
3837    /// children — rows of their own — answer for themselves.
3838    pub fn drop_target_at(&mut self, row: usize) -> Option<DropTarget> {
3839        let rows = self.vmap.rows.len();
3840        if row >= rows {
3841            return Some(DropTarget {
3842                offset: self.source.len(),
3843                row: rows,
3844            });
3845        }
3846        // A gap or a rule is not a block; the block under it is the one meant.
3847        let row = (row..rows).find(|&r| self.vmap.row_is_navigable(r))?;
3848        let start = self.vmap.row_start(row)?;
3849        let chain = self.editor.ancestors_at(start).ok()?;
3850        let block = chain
3851            .iter()
3852            .rev()
3853            .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))?;
3854        let span = block.span.clone();
3855        let (first, last) = self.vmap.row_range_for(span.clone());
3856        if row <= usize::midpoint(first, last) {
3857            Some(DropTarget {
3858                offset: span.start,
3859                row: first,
3860            })
3861        } else {
3862            Some(DropTarget {
3863                offset: span.end.min(self.source.len()),
3864                row: last + 1,
3865            })
3866        }
3867    }
3868
3869    /// Backspace at a table's trailing stop: move onto the stop before it (the
3870    /// last cell's end) and consume the key. `false` anywhere else. See
3871    /// [`VisualMap::table_end_stop`] for why the byte behind the caret there is
3872    /// not one to delete.
3873    fn backspace_at_table_end(&mut self) -> bool {
3874        // The map answers about offsets, so it has to be this revision's — see
3875        // `open_paragraph_at_block_edge`.
3876        self.rebuild_map();
3877        if !self.vmap.table_end_stop(self.caret) {
3878            return false;
3879        }
3880        if let Some(off) = self
3881            .vmap
3882            .stop_before(self.caret)
3883            .filter(|&o| o >= self.caret_floor())
3884        {
3885            self.caret = off;
3886            self.anchor = None;
3887            self.goal_col = None;
3888        }
3889        true
3890    }
3891
3892    /// Whether the caret's own source line holds nothing but whitespace — an
3893    /// empty paragraph, or the blank line a block boundary is spelled with. The
3894    /// test for [`backspace`](Self::backspace)'s stop-wise delete: such a line has
3895    /// no text of its own, so the newline before the caret belongs to the gap
3896    /// between blocks rather than to any word the caret is editing.
3897    fn caret_on_blank_line(&self) -> bool {
3898        let line_start = self.source[..self.caret].rfind('\n').map_or(0, |i| i + 1);
3899        let line_end = self.source[self.caret..]
3900            .find('\n')
3901            .map_or(self.source.len(), |i| self.caret + i);
3902        self.source[line_start..line_end].trim().is_empty()
3903    }
3904
3905    /// The source span of an in-cell hard break (`<br>`) touching the caret on the
3906    /// `edge` side — the byte range to delete whole. A table row is one source
3907    /// line, so its break is spelled `<br>` yet drawn as a single newline glyph
3908    /// (see `wysiwyg.rs`); a delete over it must take every byte, or a one-byte
3909    /// step strands a broken `<br` in the cell. `Backward` matches a break ending
3910    /// at the caret (Backspace), `Forward` one starting at it (Delete). `None`
3911    /// when no such break is adjacent. Only the in-cell break is spelled `<br>`
3912    /// (an ordinary hard break is `  \n`), so the leading `<` alone tells them
3913    /// apart — no ancestor walk needed. Rich view only; source view shows the
3914    /// literal tag and deletes it a byte at a time.
3915    fn cell_break_at(&mut self, edge: BreakEdge) -> Option<(usize, usize)> {
3916        let caret = self.caret;
3917        let nodes = self.nodes();
3918        let src = self.source.as_bytes();
3919        nodes
3920            .iter()
3921            .find(|n| {
3922                n.kind == Kind::HardBreak
3923                    && n.span.start < n.span.end
3924                    && src.get(n.span.start) == Some(&b'<')
3925                    && match edge {
3926                        BreakEdge::Backward => n.span.end == caret,
3927                        BreakEdge::Forward => n.span.start == caret,
3928                    }
3929            })
3930            .map(|n| (n.span.start, n.span.end))
3931    }
3932
3933    /// Whether the source byte at `off` is a backslash twig consumed as an escape
3934    /// (hidden in the rich view), as against a literal backslash (drawn). A
3935    /// backslash escapes exactly an ASCII-punctuation character (the CommonMark /
3936    /// Djot rule twig follows), so `\` + punctuation is the whole test — no AST
3937    /// round-trip needed.
3938    fn is_hidden_escape(&self, off: usize) -> bool {
3939        let b = self.source.as_bytes();
3940        b.get(off) == Some(&b'\\') && b.get(off + 1).is_some_and(u8::is_ascii_punctuation)
3941    }
3942
3943    /// Backspace's list behaviour: when the caret sits exactly at the start of a
3944    /// list item's content (right after its marker), outdent the item if it's
3945    /// nested, else strip the marker so it becomes a paragraph. Returns whether
3946    /// it acted — `false` leaves Backspace its ordinary character delete.
3947    fn backspace_list_start(&mut self) -> bool {
3948        let Some(marker) = self.list_marker_on_line(self.caret) else {
3949            return false;
3950        };
3951        // Only right after the marker. That the line opens a real item is
3952        // already settled: `list_marker_on_line` answers from the tree.
3953        if self.caret != marker.content_start() {
3954            return false;
3955        }
3956        if self.item_is_nested(marker.marker_start) {
3957            // Nested: give back one level, keeping the marker and carrying the
3958            // caret with it.
3959            self.outdent();
3960        } else {
3961            // Top level: drop the marker, leaving a paragraph, then renumber the
3962            // siblings the removed item was counted among. Only the marker goes —
3963            // a quote prefix in front of it still has a quote to hold up.
3964            self.splice(marker.marker_start, self.caret, "", EditKind::Other);
3965            self.renumber_here();
3966        }
3967        true
3968    }
3969
3970    /// Backspace's heading behaviour: with the caret exactly at the start of an
3971    /// ATX heading's content — right after the `#` marker the rich view hides —
3972    /// strip the marker so the line becomes a paragraph. The peer of
3973    /// [`backspace_list_start`](Self::backspace_list_start)'s ladder, and the same
3974    /// reasoning: hidden block markup is structure, so the keystroke over it is
3975    /// structural.
3976    ///
3977    /// Without this the ordinary delete takes the space out of `# Title` and
3978    /// leaves `#Title`, which is no longer a heading at all — the hash the view
3979    /// had been hiding surfaces as literal text the user has to delete a second
3980    /// time, having never typed it. A closing sequence (`# Title #`, hidden at the
3981    /// other end) goes with the marker for the same reason.
3982    ///
3983    /// Returns whether it acted; `false` leaves Backspace its character delete.
3984    fn backspace_heading_start(&mut self) -> bool {
3985        let caret = self.caret;
3986        // The heading whose content opens exactly at the caret. A bare `#` has no
3987        // content span at all — its content starts (and ends) where the line does.
3988        let Some((span, content_end, marker)) = self.nodes().iter().find_map(|n| {
3989            let (start, end) = match &n.content_span {
3990                Some(c) => (c.start, c.end),
3991                None => (n.span.end, n.span.end),
3992            };
3993            (n.kind == Kind::Heading && start == caret)
3994                .then(|| (n.span.clone(), end, n.marker_span.clone()))
3995        }) else {
3996            return false;
3997        };
3998        // twig reports the marker's own extent, so there is nothing to walk back
3999        // over and no `#` in this file. A setext heading has no marker — its
4000        // content opens the line — so it falls through to the ordinary delete,
4001        // as does anything else sitting at a content start.
4002        // `m.end == caret` is what excludes a setext heading, whose marker is the
4003        // underline *after* the content rather than a prefix before it.
4004        let Some(marker) = marker.filter(|m| m.end == caret) else {
4005            return false;
4006        };
4007        let start = marker.start;
4008        // A closing `#` sequence is hidden too, so it can't be left behind. Only
4009        // when the tail really is one: trailing spaces alone are nothing to strip.
4010        let tail = &self.source[content_end..span.end];
4011        if tail.contains('#') && tail.chars().all(|c| c == '#' || c.is_whitespace()) {
4012            let kept = self.source[caret..content_end].to_string();
4013            self.splice(start, span.end, &kept, EditKind::Other);
4014            // The splice leaves the caret past the text it re-wrote; the caret
4015            // belongs where the content now starts, which is where it already was.
4016            self.caret = start;
4017            self.record_caret();
4018        } else {
4019            self.splice(start, caret, "", EditKind::Other);
4020        }
4021        true
4022    }
4023
4024    pub fn delete_forward(&mut self) {
4025        if let Some((s, e)) = self.selection() {
4026            self.splice(s, e, "", EditKind::Other);
4027        } else if self.caret < self.source.len() {
4028            // The mirror of Backspace's: forward-delete in front of a picture
4029            // would eat the `!` off its markup and leave a link where a photo was.
4030            if self.view != View::Source && self.delete_around_block_media(true) {
4031                return;
4032            }
4033            // And of Backspace's join: at the end of a block's content, Delete
4034            // joins the next block into this one.
4035            if self.view != View::Source && self.delete_forward_joins_block() {
4036                return;
4037            }
4038            // Delete forward over an in-cell `<br>` takes the whole tag, the mirror
4039            // of Backspace's swallow (see `cell_break_at`) — else a byte-step
4040            // strands a broken `<br` in the cell.
4041            if self.view != View::Source
4042                && let Some((start, end)) = self.cell_break_at(BreakEdge::Forward)
4043            {
4044                self.splice(start, end, "", EditKind::Delete);
4045                return;
4046            }
4047            // The mirror of Backspace's two steps: from in front of a run's
4048            // opening `**` step into it, onto the first letter of its text, and
4049            // at the end of a run's text step out past its closing `**` to the
4050            // character beyond. Either way Delete takes the character it looks
4051            // like it is pointing at, and never a delimiter drawn as nothing.
4052            // The caret then settles back inside the run it was standing in —
4053            // see `settle_inside_close_delims`.
4054            let from = if self.view == View::Source {
4055                self.caret
4056            } else {
4057                let inside = self.step_inside_open_delims(self.caret);
4058                // The mirror of Backspace's empty-span rule: an attributed
4059                // span with no text goes whole, with the character after it.
4060                if let Some(span) = self.run_span_of_content(inside..inside) {
4061                    let to = if self.source[span.end..].starts_with('\n') {
4062                        span.end
4063                    } else {
4064                        next_boundary(&self.source, span.end)
4065                    };
4066                    self.splice(span.start, to, "", EditKind::Delete);
4067                    return;
4068                }
4069                self.skip_trailing_close_delims(inside)
4070            };
4071            let next = next_boundary(&self.source, from);
4072            // And of its emptying rules: the span goes with its last letter,
4073            // and so does the block's div or `{…}` line.
4074            if self.view != View::Source
4075                && let Some(span) = self.run_span_of_content(from..next)
4076            {
4077                self.splice(span.start, span.end, "", EditKind::Delete);
4078                return;
4079            }
4080            if self.view != View::Source
4081                && let Some(block) = self.attributed_block_of_content(from..next)
4082            {
4083                self.splice(block.start, block.end, "", EditKind::Delete);
4084                return;
4085            }
4086            if from < next {
4087                self.splice(from, next, "", EditKind::Delete);
4088            }
4089        }
4090    }
4091
4092    /// Delete from the caret back to the start of the previous word (⌥⌫ /
4093    /// Ctrl+⌫). Deletes the selection instead when one is active.
4094    pub fn delete_word_back(&mut self) {
4095        if let Some((s, e)) = self.selection() {
4096            self.splice(s, e, "", EditKind::Other);
4097        } else {
4098            // A word back from just past a picture is a word *of its markup*, and
4099            // a word back from in front of one runs through the paragraph break
4100            // into the prose above — dissolving the picture either way. See
4101            // `delete_around_block_media`.
4102            if self.view != View::Source && self.delete_around_block_media(false) {
4103                return;
4104            }
4105            let start = self.word_left_from(self.caret).max(self.caret_floor());
4106            if start < self.caret {
4107                let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4108                self.splice(s, e, "", EditKind::Delete);
4109            }
4110        }
4111    }
4112
4113    /// Delete from the caret forward to the end of the next word (⌥⌦ /
4114    /// Ctrl+Del). Deletes the selection instead when one is active.
4115    pub fn delete_word_forward(&mut self) {
4116        if let Some((s, e)) = self.selection() {
4117            self.splice(s, e, "", EditKind::Other);
4118        } else {
4119            // The mirror: a word forward from in front of a picture is its markup.
4120            if self.view != View::Source && self.delete_around_block_media(true) {
4121                return;
4122            }
4123            let end = self.word_right_from(self.caret);
4124            if end > self.caret {
4125                let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4126                self.splice(s, e, "", EditKind::Delete);
4127            }
4128        }
4129    }
4130
4131    /// Delete from the caret back to the start of its line (⌘⌫). Deletes the
4132    /// selection instead when one is active, as every other delete here does.
4133    ///
4134    /// The line is the view's own — the one Home and End work on, so in WYSIWYG
4135    /// a soft-wrapped row is a line. It is not Home's *target*, though: Home
4136    /// stops at the first character and this takes the indentation with it, the
4137    /// way Cocoa's `deleteToBeginningOfLine:` does. Stopping at the text would
4138    /// leave an indent behind that nothing can then ask to delete, where a caret
4139    /// left at column 0 is one press of Home away from either.
4140    pub fn delete_to_line_start(&mut self) {
4141        if let Some((s, e)) = self.selection() {
4142            self.splice(s, e, "", EditKind::Other);
4143            return;
4144        }
4145        // Never back across the floor: hidden frontmatter isn't on this line, or
4146        // on any line the WYSIWYG caret can see.
4147        let (start, _) = self.line_span();
4148        let start = start.max(self.caret_floor());
4149        if start < self.caret {
4150            let (s, e) = self.widen_over_emptied_inlines(start, self.caret);
4151            self.splice(s, e, "", EditKind::Delete);
4152        }
4153    }
4154
4155    /// Kill from the caret to the end of its line (^K). Deletes the selection
4156    /// instead when one is active.
4157    ///
4158    /// At the end of the line it does nothing, rather than pulling the line
4159    /// below up into this one. Joining has no meaning to give it in both views
4160    /// at once: a WYSIWYG line ends at a soft wrap as often as at a newline, and
4161    /// there is nothing there to delete, while the newline a *source* line ends
4162    /// with is only half of the blank line that separates two paragraphs —
4163    /// deleting one leaves a soft break, which is not the join it looks like.
4164    /// The views agreeing is worth more than emacs' second press, and Delete is
4165    /// already the key that joins.
4166    pub fn delete_to_line_end(&mut self) {
4167        if let Some((s, e)) = self.selection() {
4168            self.splice(s, e, "", EditKind::Other);
4169            return;
4170        }
4171        let (_, end) = self.line_span();
4172        if end > self.caret {
4173            let (s, e) = self.widen_over_emptied_inlines(self.caret, end);
4174            self.splice(s, e, "", EditKind::Delete);
4175        }
4176    }
4177
4178    /// Grow a WYSIWYG word-delete to swallow any inline node it empties.
4179    ///
4180    /// A glyph-space range covers what the user can see, which for `**bold**` is
4181    /// the word and never the delimiters around it — so deleting the word on its
4182    /// own leaves `a **** c`, markup wrapped around nothing. They asked for the
4183    /// word, and the styling was the word's; the two go together. Only the
4184    /// node's delimiters are taken, and those are hidden here anyway, so nothing
4185    /// visible outside the range is lost.
4186    ///
4187    /// Repeated to a fixed point: emptying `***bold***` empties the emph inside
4188    /// the strong, and only then is the strong empty too.
4189    fn widen_over_emptied_inlines(&mut self, start: usize, end: usize) -> (usize, usize) {
4190        if self.view == View::Source {
4191            return (start, end);
4192        }
4193        let nodes = self.nodes();
4194        let (mut s, mut e) = (start, end);
4195        loop {
4196            let mut grew = false;
4197            for n in nodes.iter().filter(|n| wysiwyg::is_inline(n)) {
4198                let Some(text) = inline_content_span(n, &self.source) else {
4199                    continue;
4200                };
4201                // Some of its text survives, so the node still has a job.
4202                if text.start < s || text.end > e {
4203                    continue;
4204                }
4205                if n.span.start < s || n.span.end > e {
4206                    s = s.min(n.span.start);
4207                    e = e.max(n.span.end);
4208                    grew = true;
4209                }
4210            }
4211            if !grew {
4212                return (s, e);
4213            }
4214        }
4215    }
4216
4217    /// One splice of document text, keeping the **mark-edge rule**: an inline
4218    /// mark's content never begins or ends with whitespace. In Markdown and Djot
4219    /// a delimiter standing against a space is not a delimiter at all — `**bold **`
4220    /// is four literal asterisks around a word, and a rich view drawing the
4221    /// document faithfully has no choice but to show them. That is correct
4222    /// rendering of what the file says, and nobody typing a space after a bold
4223    /// word meant to say it.
4224    ///
4225    /// So the space goes *outside* the run instead — `**bold** ` — which is the
4226    /// same document to a reader and a live one to a parser. The caret follows it
4227    /// out and keeps the marks armed (see [`rearm`](Self::rearm)), so the next
4228    /// character rejoins the run (see [`rejoin_run`](Self::rejoin_run)) and the
4229    /// writer sees one unbroken bold phrase, never a flash of raw syntax.
4230    ///
4231    /// Every ordinary edit — typing, deleting, pasting, an IME step — comes
4232    /// through here, so the rule holds however the whitespace arrives at the
4233    /// edge. The repair is decided *after* the plain edit, by asking whether the
4234    /// mark actually died: a code span's backticks aren't whitespace-sensitive
4235    /// (`` `code ` `` is still code), and nothing is re-spelled when nothing broke.
4236    fn splice(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4237        let fix = self.mark_edge_fix(start, end, text);
4238        if !self.splice_exact(start, end, text, kind) {
4239            return false;
4240        }
4241        if let Some(fix) = fix {
4242            self.repair_mark_edges(fix);
4243        }
4244        if text.is_empty() && end > start {
4245            self.settle_inside_close_delims();
4246        }
4247        true
4248    }
4249
4250    /// After a delete, take a caret left standing past a run's closing delimiters
4251    /// back inside the run.
4252    ///
4253    /// A delete leaves the caret where the deleted bytes began, and when those
4254    /// bytes were the last thing after a marked phrase — the space the mark-edge
4255    /// rule pushed out of `**bold** `, say — that spot is the far side of the
4256    /// closing `**`. The rich view has nothing to draw there: the delimiters are
4257    /// hidden, so the caret shows at the end of the word either way, and the two
4258    /// offsets are one place on screen with two different meanings. Typing at the
4259    /// outer one lands past the run, so the writer who backspaced a space out of
4260    /// their bold phrase watches the next character come out plain, and the
4261    /// toolbar button go dark, with the caret never appearing to move.
4262    ///
4263    /// The end of the run's text is the caret's home there — a delete that took
4264    /// away everything after a phrase leaves the caret at the end of that phrase,
4265    /// which is inside it — so it settles onto that
4266    /// ([`step_inside_close_delims`](Self::step_inside_close_delims) does the
4267    /// walk, through every mark closing at the point): the word stays bold, the
4268    /// button stays lit, and the next character carries on the phrase.
4269    ///
4270    /// Rich view only, and only where a mark really closes at the caret — mid-run
4271    /// or in plain prose no span ends there and the caret stays put. The opening
4272    /// edge is left alone on purpose: a caret in front of a run inherits from the
4273    /// text on its left, which is the plain text outside.
4274    fn settle_inside_close_delims(&mut self) {
4275        if self.view != View::Wysiwyg {
4276            return;
4277        }
4278        let at = self.step_inside_close_delims(self.caret);
4279        if at != self.caret {
4280            self.caret = at;
4281            self.clear_pending();
4282            self.record_caret();
4283        }
4284    }
4285
4286    /// The splice exactly as asked, with no mark-edge repair — for the callers
4287    /// that are *writing* the delimiters themselves ([`insert_with_marks`](Self::insert_with_marks)
4288    /// and [`rejoin_run`](Self::rejoin_run)) and place their own offsets around
4289    /// the bytes they inserted.
4290    ///
4291    /// One `edit_range` through twig, then re-anchor the caret from the returned
4292    /// `Change` and refresh the cached source. A reparse-breaking edit (rare for
4293    /// Markdown/Djot) leaves the document untouched and reports.
4294    ///
4295    /// Returns whether the edit landed — for a caller that has offsets of its
4296    /// own to place afterwards, which a rolled-back splice would leave pointing
4297    /// into text that never came to exist.
4298    fn splice_exact(&mut self, start: usize, end: usize, text: &str, kind: EditKind) -> bool {
4299        // The read-only gate, for every edit at once — see the field.
4300        if self.read_only {
4301            return false;
4302        }
4303        // twig records an undo step for every edit; when this one continues a
4304        // run of the same kind (typing, deleting), tell twig to fold it into the
4305        // step before it so the whole run undoes at once.
4306        let coalesce = kind != EditKind::Other && self.last_edit_kind == Some(kind);
4307        // Hand twig the pre-edit caret before the splice, so the undo step it
4308        // retires carries where the caret was standing.
4309        self.record_caret();
4310        match self.editor.edit_range(start, end, text) {
4311            Ok(change) => {
4312                if coalesce {
4313                    let _ = self.editor.coalesce_last_undo();
4314                }
4315                self.last_edit_kind = Some(kind);
4316                self.refresh();
4317                self.caret = change.new.end;
4318                self.anchor = None;
4319                self.goal_col = None;
4320                self.clear_pending();
4321                self.dirty = self.source != self.clean_source;
4322                self.status = None;
4323                // And the post-edit caret, so a later redo restores it.
4324                self.record_caret();
4325                true
4326            }
4327            // The edit was rolled back, so twig's history did not move and
4328            // neither may ours: pushing here would leave a step with no edit
4329            // under it and shift every later undo onto the wrong caret.
4330            Err(e) => {
4331                self.status = Some(format!("edit: {e}"));
4332                false
4333            }
4334        }
4335    }
4336
4337    /// The re-spelling that would keep the mark-edge rule for the edit
4338    /// `[start, end)` → `text`, or `None` when the edit leaves no whitespace
4339    /// against a delimiter and the plain splice is already right. Computed
4340    /// *before* the edit, while the run's spans and delimiters can still be read
4341    /// off the document; applied afterwards, and only if the mark really died —
4342    /// see [`repair_mark_edges`](Self::repair_mark_edges).
4343    ///
4344    /// Rich view only. Source view is for typing raw markup, where a space put
4345    /// against a `**` is exactly the character it looks like.
4346    fn mark_edge_fix(&mut self, start: usize, end: usize, text: &str) -> Option<MarkEdgeFix> {
4347        if self.view != View::Wysiwyg || start > end || end > self.source.len() {
4348            return None;
4349        }
4350        // Every inline mark standing over the edit, outermost first, with the
4351        // content span that says where its delimiters are.
4352        let chain: Vec<(InlineKind, std::ops::Range<usize>, std::ops::Range<usize>)> = self
4353            .editor
4354            .ancestors_at(start)
4355            .unwrap_or_default()
4356            .into_iter()
4357            .filter_map(|m| {
4358                let kind = inline_kind(&m.kind)?;
4359                let content = m.content_span.clone()?;
4360                Some((kind, m.span.clone(), content))
4361            })
4362            .collect();
4363        // The innermost run whose *content* holds the whole edit: the one whose
4364        // text is being changed, rather than one the edit merely sits under.
4365        let (kind, span, content) = chain
4366            .iter()
4367            .rev()
4368            .find(|(_, _, c)| c.start <= start && end <= c.end)?
4369            .clone();
4370        // What that content becomes. Whitespace at either end of it is what
4371        // would put out the mark.
4372        let body = format!(
4373            "{}{text}{}",
4374            &self.source[content.start..start],
4375            &self.source[end..content.end]
4376        );
4377        let (lead, trail) = if body.trim().is_empty() {
4378            // Nothing but whitespace left: there is no content to mark at all,
4379            // and the delimiters go with it rather than closing on a space.
4380            (body.len(), 0)
4381        } else {
4382            (
4383                body.len() - body.trim_start().len(),
4384                body.len() - body.trim_end().len(),
4385            )
4386        };
4387        // Nothing against a delimiter, and something still between them: the
4388        // plain edit stands. An emptied run is broken just as surely (`**b**`
4389        // with the `b` deleted is the literal `****`) and is re-spelt as the
4390        // nothing it now says.
4391        if lead == 0 && trail == 0 && !body.is_empty() {
4392            return None;
4393        }
4394        // Marks that open or close exactly where this one does — `***both***` is
4395        // two runs sharing an edge — spell their delimiters as one run of bytes,
4396        // so the whitespace has to clear all of them together.
4397        let (mut open_at, mut close_at) = (span.start, span.end);
4398        for _ in 0..chain.len() {
4399            match chain.iter().find(|(_, _, c)| c.start == open_at) {
4400                Some((_, s, _)) => open_at = s.start,
4401                None => break,
4402            }
4403        }
4404        for _ in 0..chain.len() {
4405            match chain.iter().find(|(_, _, c)| c.end == close_at) {
4406                Some((_, s, _)) => close_at = s.end,
4407                None => break,
4408            }
4409        }
4410        let open = &self.source[open_at..content.start];
4411        let close = &self.source[content.end..close_at];
4412        let core = &body[lead..body.len() - trail];
4413        let respelt = if core.is_empty() {
4414            body.clone()
4415        } else {
4416            format!(
4417                "{}{open}{core}{close}{}",
4418                &body[..lead],
4419                &body[body.len() - trail..]
4420            )
4421        };
4422        // The caret sits just past the inserted text within the new content —
4423        // which, when that lands in the whitespace, is now outside the delimiters.
4424        let pos = (start - content.start) + text.len();
4425        let caret = if core.is_empty() || pos <= lead {
4426            open_at + pos
4427        } else if pos >= lead + core.len() {
4428            open_at + lead + open.len() + core.len() + close.len() + (pos - lead - core.len())
4429        } else {
4430            open_at + lead + open.len() + (pos - lead)
4431        };
4432        Some(MarkEdgeFix {
4433            kind,
4434            probe: content.start,
4435            start: open_at,
4436            end: close_at + text.len() - (end - start),
4437            text: respelt,
4438            caret,
4439            // The marks in force here, resolved against any armed sticky delta —
4440            // what the writer is typing in, and so what has to still be true on
4441            // the far side of the delimiter the caret just stepped over.
4442            want: chain
4443                .iter()
4444                .filter(|(_, s, _)| start < s.end)
4445                .map(|(k, _, _)| *k)
4446                .collect::<InlineMarks>()
4447                .xor(self.pending_here()),
4448        })
4449    }
4450
4451    /// Apply a [`MarkEdgeFix`] — but only if the edit it was computed for really
4452    /// did break the mark. Whether whitespace at a delimiter is fatal is the
4453    /// format's business, not leaf's: `**bold **` is no longer strong, while
4454    /// `` `code ` `` is still perfectly good verbatim, and Djot's braced spellings
4455    /// don't care either. Asking the parser afterwards settles it for every kind
4456    /// and format at once, and costs a re-spelling only where one is due.
4457    ///
4458    /// The repair rides along with the edit that caused it — one undo step puts
4459    /// back what the writer typed, not a delimiter shuffle they never saw.
4460    fn repair_mark_edges(&mut self, fix: MarkEdgeFix) {
4461        if fix.end > self.source.len() {
4462            return;
4463        }
4464        if self.marks_at(fix.probe).iter().any(|(k, _)| *k == fix.kind) {
4465            return; // still a mark: these delimiters don't mind the whitespace
4466        }
4467        let resumed = self.last_edit_kind;
4468        if !self.splice_exact(fix.start, fix.end, &fix.text, EditKind::Other) {
4469            return;
4470        }
4471        let _ = self.editor.coalesce_last_undo();
4472        // The keystroke owns the undo step, so the run of typing it belongs to
4473        // keeps coalescing over the repair rather than breaking in two here.
4474        self.last_edit_kind = resumed;
4475        self.caret = fix.caret.min(self.source.len());
4476        self.anchor = None;
4477        self.goal_col = None;
4478        self.rearm(fix.want);
4479        self.clamp_caret();
4480        self.record_caret();
4481    }
4482
4483    /// Arm whatever sticky delta reproduces `want` at the caret — the marks the
4484    /// writer is typing in, carried across an edit that moved the caret out of
4485    /// the run holding them. Arms nothing when the caret already stands in
4486    /// exactly those marks, but still remembers the spot, so a further ⌘b starts
4487    /// a clean delta here (see [`toggle`](Self::toggle)).
4488    fn rearm(&mut self, want: InlineMarks) {
4489        let here: InlineMarks = self
4490            .marks_at(self.caret)
4491            .into_iter()
4492            .map(|(k, _)| k)
4493            .collect();
4494        self.pending_marks = want.xor(here);
4495        self.pending_at = Some(self.caret);
4496    }
4497
4498    /// Insert `text` at `at` as a *literal* run via twig's `insert_literal`,
4499    /// which backslash-escapes any character that would otherwise open markup in
4500    /// this format and position (`*` → `\*`, a line-start `#` → `\#`). The mirror
4501    /// of [`splice`](Self::splice) for the Hidden reveal mode's typing path, with
4502    /// the same caret re-anchor, coalescing, and rollback contract. `at` must be
4503    /// a collapsed point — a selection is deleted by the caller first, since
4504    /// `insert_literal` inserts rather than replaces.
4505    fn insert_literal_at(
4506        &mut self,
4507        at: usize,
4508        text: &str,
4509        kind: EditKind,
4510        force_coalesce: bool,
4511    ) -> bool {
4512        // The read-only gate: this door goes to twig directly, not through
4513        // `splice_exact`, so it guards itself — see the field.
4514        if self.read_only {
4515            return false;
4516        }
4517        // `force_coalesce` folds this into the immediately preceding edit (the
4518        // selection-delete of an overwrite) so the pair is one undo step; else it
4519        // coalesces only when it continues a run of the same-kind typing.
4520        let coalesce =
4521            force_coalesce || (kind != EditKind::Other && self.last_edit_kind == Some(kind));
4522        // The mark-edge rule holds for typed text however it is spelled — see
4523        // `splice`. Only an insert twig passed through unchanged can use it,
4524        // since a fix is measured in the bytes that actually land, and an escape
4525        // adds bytes this couldn't have counted.
4526        let fix = self.mark_edge_fix(at, at, text);
4527        self.record_caret();
4528        match self.editor.insert_literal(at, text) {
4529            Ok(change) => {
4530                if coalesce {
4531                    let _ = self.editor.coalesce_last_undo();
4532                }
4533                self.last_edit_kind = Some(kind);
4534                self.refresh();
4535                self.caret = change.new.end;
4536                self.anchor = None;
4537                self.goal_col = None;
4538                self.clear_pending();
4539                self.dirty = self.source != self.clean_source;
4540                self.status = None;
4541                self.record_caret();
4542                if let Some(fix) = fix.filter(|_| change.new.end - change.new.start == text.len()) {
4543                    self.repair_mark_edges(fix);
4544                }
4545                true
4546            }
4547            Err(e) => {
4548                self.status = Some(format!("edit: {e}"));
4549                false
4550            }
4551        }
4552    }
4553
4554    /// After a structural list edit (a new item, a nest/unnest), renumber the
4555    /// ordered list the caret sits in so its source markers run `1, 2, 3, …`
4556    /// again — a raw splice leaves them stale (`1. 2. 2. 3.`). twig does the
4557    /// renumber as its own edit; fold it into the edit that triggered it so the
4558    /// two undo as one, and only when it actually changed the source (a no-op or
4559    /// a caret outside any ordered list must not coalesce the real edit into the
4560    /// step before it).
4561    fn renumber_here(&mut self) {
4562        self.renumber_at(self.caret);
4563    }
4564
4565    /// [`renumber_here`](Self::renumber_here) aimed somewhere other than the
4566    /// caret — for an edit that leaves the caret one past the item it just wrote,
4567    /// where twig resolves no list to renumber.
4568    fn renumber_at(&mut self, off: usize) {
4569        // The read-only gate — this door reaches twig without the splice.
4570        if self.read_only {
4571            return;
4572        }
4573        let before = self.source.clone();
4574        if self.editor.renumber_ordered_lists(off).is_err() {
4575            return; // not inside an ordered list — nothing to renumber
4576        }
4577        self.refresh();
4578        if self.source != before {
4579            let _ = self.editor.coalesce_last_undo();
4580            self.dirty = self.source != self.clean_source;
4581            self.clamp_caret();
4582            self.record_caret();
4583        }
4584    }
4585
4586    /// Repair the one trap a list edit can spring on itself. An *empty* `-`
4587    /// sub-item written directly beneath a text line reparses that text as a
4588    /// setext heading — `- hello\n  - ` is `<h2>hello</h2>`, because a lone `-`
4589    /// is also a setext-H2 underline (twig is right; pandoc agrees). `*` and `+`
4590    /// bullets can't underline anything, so swap the dash for a `*`: the item
4591    /// stays an empty nested bullet, the parent stays prose, and the source
4592    /// round-trips instead of hiding a heading the user never asked for. Folded
4593    /// into the triggering edit's undo step, the way renumbering is.
4594    ///
4595    /// Gated on the collapse having actually happened (the swapped dash was
4596    /// swallowed into a `heading`), so a real setext heading the author wrote —
4597    /// or a `- x` with content, which can't underline anything — is never
4598    /// touched. This has to live in the *edit*, not the renderer: leaving the
4599    /// hazardous bytes on disk and only painting over them would ship a file
4600    /// every other CommonMark tool reads as a heading.
4601    ///
4602    /// This one keeps its own byte scan, and has to: the hazard is precisely
4603    /// that the dash stopped being a list marker, so [`list_marker_on_line`] —
4604    /// which asks twig which lines open an item — reports nothing here. There is
4605    /// no node to ask about. It is also the last Markdown spelling leaf writes on
4606    /// purpose rather than for want of an answer; once twig spells continuations
4607    /// itself, avoiding the trap becomes twig's, and this goes.
4608    ///
4609    /// [`list_marker_on_line`]: Self::list_marker_on_line
4610    fn avoid_setext_collapse(&mut self) {
4611        let caret = self.caret.min(self.source.len());
4612        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
4613        let bytes = self.source.as_bytes();
4614        let mut dash = line_start;
4615        while matches!(bytes.get(dash), Some(b' ' | b'\t')) {
4616            dash += 1;
4617        }
4618        // A dash bullet is the only marker that doubles as a setext underline.
4619        if bytes.get(dash) != Some(&b'-') {
4620            return;
4621        }
4622        // Only an *empty* item is a bare underline; `- x` carries content and
4623        // can't fold the line above into a heading.
4624        let line_end = self.source[dash..]
4625            .find('\n')
4626            .map_or(self.source.len(), |i| dash + i);
4627        if !self.source[dash + 1..line_end].trim().is_empty() {
4628            return;
4629        }
4630        // The tell: that dash was swallowed into a `heading`. A properly nested
4631        // empty item sits under a `list_item`, with no heading in reach. Probe
4632        // the dash byte itself (well inside the heading), not the caret, whose
4633        // end-of-line offset can fall on the half-open span boundary.
4634        let collapsed = self
4635            .editor
4636            .ancestors_at(dash)
4637            .map(|c| c.into_iter().any(|m| m.kind == Kind::Heading))
4638            .unwrap_or(false);
4639        if !collapsed {
4640            return;
4641        }
4642        let caret = self.caret;
4643        if self.splice(dash, dash + 1, "*", EditKind::Other) {
4644            // Same width, so the caret keeps its column; fold into the edit that
4645            // triggered this so Tab stays one undo step.
4646            let _ = self.editor.coalesce_last_undo();
4647            self.caret = caret.min(self.source.len());
4648            self.clamp_caret();
4649            self.record_caret();
4650        }
4651    }
4652
4653    fn snapshot(&self) -> CaretState {
4654        CaretState {
4655            caret: self.caret,
4656            anchor: self.anchor,
4657        }
4658    }
4659
4660    /// Hand twig the current caret and selection as the blob for the live
4661    /// document state. Called before an edit — so the step twig retires records
4662    /// where the caret was, and undo can restore it — and again once the op has
4663    /// placed the caret, so redo restores where the edit left it.
4664    ///
4665    /// This is the whole of leaf's undo-caret bookkeeping now. twig carries the
4666    /// caret through its own history, so coalescing falls out for free (folding
4667    /// two twig steps into one drops the intermediate blob, keeping the run's
4668    /// first) and the parallel stacks that had to march in lockstep — and could
4669    /// silently drift out of it — are gone.
4670    fn record_caret(&mut self) {
4671        let _ = self.editor.set_caret_blob(&self.snapshot().to_blob());
4672    }
4673
4674    /// Toggle an inline mark over the selection (Bold / Italic / Code / …). Keeps
4675    /// the toggled region selected so a second press cleanly reverses it.
4676    pub fn toggle(&mut self, kind: InlineKind) {
4677        // The read-only gate — this door reaches twig without the splice.
4678        if self.read_only {
4679            return;
4680        }
4681        // Ahead of the no-selection branch below: arming a mark for text not yet
4682        // typed is a promise `insert` cannot keep in a format with no delimiters
4683        // to spell it with. Per *kind*, not per format — Markdown spells five
4684        // of the eight marks (highlight among them, under the `highlight`
4685        // extension leaf parses with), djot all eight, HTML seven.
4686        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleInline(kind)) {
4687            return;
4688        }
4689        let Some((s, e)) = self.selection() else {
4690            // No selection: arm the mark for the next text typed here, the way a
4691            // word processor does. `⌘b`, type, `⌘b` again toggles bold on and off
4692            // in the flow of typing without ever selecting anything — the delta
4693            // is realised onto the freshly typed text by `insert`. A fresh caret
4694            // position starts the delta over from the marks actually in force.
4695            if self.pending_at != Some(self.caret) {
4696                self.pending_marks = InlineMarks::empty();
4697                self.pending_at = Some(self.caret);
4698            }
4699            self.pending_marks.flip(kind);
4700            self.status = None;
4701            return;
4702        };
4703        // Whitespace at the edge of a selection is not part of what was chosen —
4704        // a double-click takes the space after the word with it — and a mark
4705        // cannot close against one anyway: `**word **` is four literal asterisks
4706        // (the mark-edge rule, see `splice`). Mark the words, leave the spaces.
4707        let picked = &self.source[s..e];
4708        let (s, e) = (
4709            s + (picked.len() - picked.trim_start().len()),
4710            e - (picked.len() - picked.trim_end().len()),
4711        );
4712        if s >= e {
4713            self.status = Some(format!("{kind:?}: nothing selected to mark"));
4714            return;
4715        }
4716        // Styling a selection is a one-shot act, not a sticky mode.
4717        self.clear_pending();
4718        self.record_caret();
4719        match self.editor.toggle_inline(s, e, kind) {
4720            Ok(change) => {
4721                self.last_edit_kind = None; // structural edit is its own undo step
4722                self.refresh();
4723                self.anchor = Some(change.new.start);
4724                self.caret = change.new.end;
4725                self.dirty = self.source != self.clean_source;
4726                self.status = None;
4727                self.record_caret();
4728            }
4729            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
4730        }
4731    }
4732
4733    /// Whether the caret stands in a highlight — what a frontend asks to enable
4734    /// or disable its highlight-colour controls, the way
4735    /// [`caret_in_table`](Self::caret_in_table) gates the grid ones.
4736    ///
4737    /// A fact about the *caret*, and the other half of
4738    /// [`Capabilities::mark_color`], which is the fact about the format. A
4739    /// frontend needs both: djot spells a highlight and no colour for it, so a
4740    /// caret standing in `{=word=}` answers `true` here and still has no palette
4741    /// to offer.
4742    ///
4743    /// The rule is [`active_inline_marks`](Self::active_inline_marks)' rule, so
4744    /// the palette appears exactly where the Highlight button is lit — with one
4745    /// deliberate exception: a mark *armed* at a bare caret and not yet typed
4746    /// into lights the button and answers `false` here, because there is no node
4747    /// to colour until the text exists.
4748    pub fn caret_in_mark(&mut self) -> bool {
4749        self.mark_offset().is_some()
4750    }
4751
4752    /// The offset [`set_mark_color`](Self::set_mark_color) speaks for — the one
4753    /// standing in the highlight the gesture means — or `None` when neither end
4754    /// of what is selected is in one.
4755    ///
4756    /// The caret first, and the selection's *start* after it, because of what
4757    /// [`toggle`](Self::toggle) leaves behind: a fresh `==word==` is selected
4758    /// whole, with the caret at its far edge, one past the closing `==` and so
4759    /// (by `marks_at`' half-open rule) not in the mark at all. Highlight a word
4760    /// and colour it — the two presses a coloured highlight is made of — would
4761    /// otherwise refuse on the second, having just written the highlight the
4762    /// author is pointing at.
4763    fn mark_offset(&mut self) -> Option<usize> {
4764        let in_mark = |d: &mut Self, off: usize| {
4765            d.marks_at(off)
4766                .into_iter()
4767                .any(|(k, _)| k == InlineKind::Mark)
4768                .then_some(off)
4769        };
4770        let caret = self.caret.min(self.source.len());
4771        in_mark(self, caret).or_else(|| {
4772            let start = self.selection()?.0;
4773            in_mark(self, start)
4774        })
4775    }
4776
4777    /// The colour of the highlight at the caret — `None` both when the caret is
4778    /// in no highlight and when the highlight it is in names no colour, which
4779    /// are the same answer to "which swatch is lit".
4780    ///
4781    /// The innermost mark, by span, for the same reason
4782    /// [`current_heading_level`](Self::current_heading_level) walks the tree:
4783    /// what the caret is *in* is the deepest node containing it. A `data-color`
4784    /// naming a colour this build has no variant for reads as `None` — the
4785    /// renderer already draws that as a plain highlight rather than guessing,
4786    /// and the toolbar agrees with the renderer.
4787    pub fn mark_color_at_caret(&mut self) -> Option<MarkColor> {
4788        let at = self.mark_offset()?;
4789        self.mark_color_at(at)
4790    }
4791
4792    /// [`mark_color_at_caret`](Self::mark_color_at_caret) at a given offset —
4793    /// the innermost `mark` covering it, and the colour it names.
4794    fn mark_color_at(&mut self, off: usize) -> Option<MarkColor> {
4795        self.nodes()
4796            .into_iter()
4797            .filter(|n| n.kind == Kind::Mark)
4798            .filter(|n| n.span.start <= off && off < n.span.end)
4799            .min_by_key(|n| n.span.end - n.span.start)
4800            .and_then(|n| MarkColor::from_attrs(&n.attrs))
4801    }
4802
4803    /// Colour the highlight at the caret, or clear its colour with `None` — the
4804    /// palette behind a toolbar's Highlight button.
4805    ///
4806    /// Markdown only, and the one gesture whose availability is a fact about the
4807    /// *parse extensions* rather than about the format alone: the colour is
4808    /// spelled `==🔴 text==`, an emoji twig reads back out of the content and
4809    /// records as the mark's `data-color`, and only an editor parsing with
4810    /// `highlight_colors` (which [`parse_extensions`] turns on for every leaf
4811    /// document) reads it back that way. Djot spells the highlight and no colour
4812    /// for it, so this refuses there — see [`Capabilities::mark_color`].
4813    ///
4814    /// **A colour is a property of a highlight that already exists.** There is
4815    /// no "highlight this in red" here, because that is two splices and would be
4816    /// two undo steps under one press; a frontend that wants it calls
4817    /// [`toggle`](Self::toggle) with [`InlineKind::Mark`] first, which is the
4818    /// order the two buttons already sit in. With no highlight at the caret this
4819    /// says so in the status line and writes nothing.
4820    ///
4821    /// The caret keeps its place in the *text*: the splice is entirely in the
4822    /// prefix between the opening `==` and the first word, so an offset past it
4823    /// rides the emoji's width, and one standing on the prefix itself lands
4824    /// where the prefix now ends.
4825    pub fn set_mark_color(&mut self, color: Option<MarkColor>) {
4826        // The read-only gate — this door reaches twig without the splice.
4827        if self.read_only {
4828            return;
4829        }
4830        if self.refuse_unsupported("highlight colour", Gesture::SetMarkColor) {
4831            return;
4832        }
4833        let Some(at) = self.mark_offset() else {
4834            self.status = Some("highlight colour: no highlight at the caret".into());
4835            return;
4836        };
4837        // Clearing a colour a highlight hasn't got is twig's one *successful*
4838        // no-op, and the `Change` it hands back then describes whatever edit came
4839        // before it — a stale span that would drag the caret somewhere it never
4840        // was. Answer it here, where the question is cheap, rather than trusting
4841        // a change that isn't one.
4842        if color.is_none() && self.mark_color_at(at).is_none() {
4843            self.status = None;
4844            return;
4845        }
4846        self.record_caret();
4847        match self.editor.set_mark_color(at, color.map(twig_mark_color)) {
4848            Ok(change) => {
4849                // Re-anchored from the offsets as they were, *before* `refresh`
4850                // sees the new bytes: the caret it clamps is one standing inside
4851                // a prefix that didn't exist a moment ago, and walking it back to
4852                // a char boundary of the emoji loses the place this is restoring.
4853                let caret = reanchor(self.caret, &change);
4854                let anchor = self.anchor.map(|a| reanchor(a, &change));
4855                self.last_edit_kind = None; // structural edit is its own undo step
4856                self.refresh();
4857                self.caret = caret;
4858                self.anchor = anchor;
4859                self.dirty = self.source != self.clean_source;
4860                self.status = None;
4861                self.clamp_caret();
4862                self.record_caret();
4863            }
4864            Err(e) => self.status = Some(format!("highlight colour: {e}")),
4865        }
4866    }
4867
4868    /// One press of a colour swatch: colour the highlight at the caret, or —
4869    /// over a selection that isn't highlighted yet — highlight it and colour it,
4870    /// as **one** undo step.
4871    ///
4872    /// [`set_mark_color`](Self::set_mark_color) is the exact gesture and stays
4873    /// one splice; this is the compound every toolbar actually presses, and it
4874    /// lives here rather than in each frontend because the rule it encodes —
4875    /// what a swatch means when there is no highlight under it yet — is one
4876    /// answer, not one per frontend. The two splices are folded into a single
4877    /// history step, so the press that made a red highlight is taken back by a
4878    /// single undo rather than leaving an uncoloured one behind.
4879    ///
4880    /// `None` clears the colour, and over an unhighlighted selection means
4881    /// simply "highlight this" — the same thing the Highlight button does.
4882    /// A bare caret in no highlight is left alone with a status line, because
4883    /// [`toggle`](Self::toggle) there arms a mark for text not yet typed and a
4884    /// colour cannot be armed with it.
4885    pub fn highlight(&mut self, color: Option<MarkColor>) {
4886        if self.caret_in_mark() || self.selection().is_none() {
4887            self.set_mark_color(color);
4888            return;
4889        }
4890        self.toggle(InlineKind::Mark);
4891        // The format may not spell a highlight at all (`toggle` said so), and
4892        // there is nothing to colour if it doesn't.
4893        if self.status.is_some() {
4894            return;
4895        }
4896        let before = self.revision;
4897        self.set_mark_color(color);
4898        // Only fold when the colour really spliced. `highlight(None)` over a
4899        // fresh highlight is a no-op by design, and coalescing there would eat
4900        // the *previous* edit into the toggle instead.
4901        if self.revision != before {
4902            let _ = self.editor.coalesce_last_undo();
4903        }
4904    }
4905
4906    // ── the presentation vocabulary ─────────────────────────────────────────
4907    //
4908    // Six gestures and five queries over twig's two attribute ops. Each gesture
4909    // edits **one key and keeps the rest**: it reads the node's attributes,
4910    // removes its own key (and, for alignment, its own tokens out of `class`),
4911    // adds the new value or nothing, and passes the list back whole — twig's
4912    // contract is replace-not-merge, so the read is the caller's job. A
4913    // paragraph that came in as `class="lead center" id="intro"
4914    // data-line-height="1.5"` and is right-aligned goes out as `class="lead
4915    // right" id="intro" data-line-height="1.5"`. Nothing leaf did not write is
4916    // touched, which is what lets a document from elsewhere pass through the
4917    // editor unharmed.
4918    //
4919    // Clearing is the same gesture with `None`: the key goes, and an empty list
4920    // at the end unwraps the span or the Markdown div, which twig does.
4921
4922    /// Set — or with `None` clear — the alignment of the block the caret is in.
4923    ///
4924    /// A block property, so the gesture is `set_block_attrs` on the caret's
4925    /// block **whatever is selected**: a line is a block's, and "centre this"
4926    /// with three words selected means the paragraph, not the words. The
4927    /// vocabulary is [`Align`], written as `class` tokens; other tokens on the
4928    /// same `class` are kept.
4929    ///
4930    /// In Markdown the attributes live on a `<div>` around the block — twig has
4931    /// no paragraph attribute syntax to write — and this reads them back off
4932    /// that div when the block is its sole child, so a second press rewrites
4933    /// the div rather than nesting a second one.
4934    pub fn set_alignment(&mut self, align: Option<Align>) {
4935        let attrs = self.block_attrs_at_caret();
4936        if align.is_none()
4937            && self.refuse_clear_from_div("alignment", &attrs, |a| Align::from_attrs(a).is_some())
4938        {
4939            return;
4940        }
4941        let attrs = with_class_token(
4942            &attrs,
4943            |t| Align::from_token(t).is_some(),
4944            align.map(Align::name),
4945        );
4946        self.write_block_attrs("alignment", attrs);
4947    }
4948
4949    /// Set — or with `None` clear — the line spacing of the block the caret is
4950    /// in. [`set_alignment`](Self::set_alignment)'s peer in every respect but
4951    /// the key: [`LineHeight`] under `data-line-height`, one of the menu's
4952    /// three names or an exact ratio, written in its canonical spelling.
4953    pub fn set_line_spacing(&mut self, spacing: Option<LineHeight>) {
4954        let attrs = self.block_attrs_at_caret();
4955        if spacing.is_none()
4956            && self.refuse_clear_from_div("line spacing", &attrs, |a| {
4957                LineHeight::from_attrs(a).is_some()
4958            })
4959        {
4960            return;
4961        }
4962        let spelling = spacing.map(LineHeight::name);
4963        let attrs = with_attr(&attrs, "data-line-height", spelling.as_deref());
4964        self.write_block_attrs("line spacing", attrs);
4965    }
4966
4967    /// Set — or with `None` clear — the size of the selected run, or of the
4968    /// caret's whole block when nothing is selected.
4969    ///
4970    /// Size, face and colour are the *run's*, and the block's when no run is
4971    /// chosen. With a selection the gesture is `wrap_range_attrs`, which wraps
4972    /// the range in an attributed span or re-styles the span it already lies in
4973    /// (never nesting a second, and unwrapping it when the last key goes). With
4974    /// no selection it is `set_block_attrs` on the caret's block, so that "make
4975    /// this paragraph larger" is a click with the caret in it rather than a
4976    /// select-all first.
4977    ///
4978    /// The walker reads the key at both levels with the nearer winning, so a
4979    /// span's `data-size` inside a block carrying its own applies to the span.
4980    ///
4981    /// The vocabulary is [`FontSize`]: one of CSS's seven keywords, which is
4982    /// what a menu offers first because a step reads as a step up under every
4983    /// theme, or the point size an author asked for, which is exact and is all
4984    /// it is. Either is written in its canonical spelling, so a size set twice
4985    /// from the same field writes the same bytes both times.
4986    pub fn set_font_size(&mut self, size: Option<FontSize>) {
4987        let spelling = size.map(FontSize::name);
4988        self.set_run_attr("size", "data-size", spelling.as_deref());
4989    }
4990
4991    /// Set — or with `None` clear — the face of the selected run, or of the
4992    /// caret's whole block. [`set_font_size`](Self::set_font_size)'s peer, with
4993    /// [`FontFace`] under `data-font` — one of the four generics, or the family
4994    /// the author named, which the frontends resolve through the platform's
4995    /// font registry and fall back to the body face without.
4996    pub fn set_font_family(&mut self, font: Option<FontFace>) {
4997        let spelling = font.as_ref().map(FontFace::name);
4998        self.set_run_attr("font", "data-font", spelling.as_deref());
4999    }
5000
5001    /// Set — or with `None` clear — the *text* colour of the selected run, or of
5002    /// the caret's whole block. [`set_font_size`](Self::set_font_size)'s peer,
5003    /// with [`TextColor`] under `data-color` — one of the seven names, whose
5004    /// two inks the theme owns, or the triple the author picked, which is
5005    /// painted as written in both appearances.
5006    ///
5007    /// The same key and the same seven names [`set_mark_color`](Self::set_mark_color)
5008    /// writes, and a different thing: that one colours a highlight's
5009    /// *background* and rides the `mark` node twig owns the spelling of, this
5010    /// one colours the letters and rides an attributed span. The two never
5011    /// collide, because a `mark` is a `mark` and a span is a span — and they
5012    /// share a vocabulary on purpose, so that a frontend with a red for a
5013    /// highlight has a red for text and both are *that* red.
5014    pub fn set_text_color(&mut self, color: Option<TextColor>) {
5015        let spelling = color.map(TextColor::name);
5016        self.set_run_attr("text colour", "data-color", spelling.as_deref());
5017    }
5018
5019    /// Insert a page break at the caret — `::page-break`, a leaf directive with
5020    /// no label and no attributes, which twig spells in every format that names
5021    /// a leaf container (Markdown under the `directives` extension
5022    /// [`parse_extensions`] turns on, and djot, where it is an empty `:::
5023    /// page-break` fence).
5024    ///
5025    /// Placed exactly as [`insert_thematic_break`](Self::insert_thematic_break)
5026    /// places a rule, and for the same reason: a directive is a block, so twig
5027    /// alone has nowhere to put one mid-paragraph and lands it after the
5028    /// caret's whole block. A bare paragraph is therefore parted at the caret
5029    /// first and the break aimed at the *first* half. See that method for the
5030    /// whole of the rule, including why a code block, a list item, a table and
5031    /// a setext heading are left unsplit.
5032    ///
5033    /// The frontends that paginate read the row's
5034    /// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) and open a page there;
5035    /// the ones that do not draw the `⧉ page-break` placeholder every leaf
5036    /// directive gets.
5037    pub fn insert_page_break(&mut self) {
5038        if self.read_only || self.refuse_unsupported("page break", Gesture::InsertDirective) {
5039            return;
5040        }
5041        self.caret = self.skip_trailing_close_delims(self.caret);
5042        // A selection is replaced by the break, as a rule replaces one.
5043        if let Some((s, e)) = self.selection() {
5044            self.splice(s, e, "", EditKind::Other);
5045        }
5046        self.anchor = None;
5047        self.record_caret();
5048        let at = self.caret;
5049        if self.caret_parts_bare_paragraph() {
5050            // A failure here is not fatal: the break still lands after the
5051            // block, which is what this call was trying to improve on.
5052            let _ = self.editor.split_block(at);
5053        }
5054        match self.editor.insert_directive(at, PAGE_BREAK, None, &[]) {
5055            Ok(change) => {
5056                self.last_edit_kind = None;
5057                self.refresh();
5058                self.anchor = None;
5059                self.caret = change.new.end;
5060                self.dirty = self.source != self.clean_source;
5061                self.status = None;
5062                self.clamp_caret();
5063                self.record_caret();
5064            }
5065            Err(e) => self.status = Some(format!("page break: {e}")),
5066        }
5067    }
5068
5069    /// The alignment in force at the caret, or `None` for the theme's default —
5070    /// which swatch of an alignment control is lit.
5071    ///
5072    /// Read off the nearest node that names one: the block the caret is in, and
5073    /// the `div`s around it after that. [`mark_color_at_caret`](Self::mark_color_at_caret)'s
5074    /// shape, one property along.
5075    pub fn alignment_at_caret(&mut self) -> Option<Align> {
5076        self.presentation_chain()
5077            .iter()
5078            .find_map(|attrs| Align::from_attrs(attrs))
5079    }
5080
5081    /// The line spacing in force at the caret, or `None` for the theme's own.
5082    /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
5083    pub fn line_spacing_at_caret(&mut self) -> Option<LineHeight> {
5084        self.presentation_chain()
5085            .iter()
5086            .find_map(|attrs| LineHeight::from_attrs(attrs))
5087    }
5088
5089    /// The size in force at the caret, or `None` for the theme's own — the
5090    /// entry a size menu shows ticked.
5091    ///
5092    /// Run-level, so the chain starts one node deeper: the attributed span the
5093    /// caret stands in, then its block, then the `div`s around it. The nearest
5094    /// wins, which is the rule the walker draws by.
5095    ///
5096    /// A name or a value, whichever the nearest node wrote. A `data-size` the
5097    /// grammar does not cover — a `huge` from elsewhere — is not a size this
5098    /// can answer, so the answer is `None` and the menu ticks *Default*, the
5099    /// same thing it did before the vocabulary opened.
5100    pub fn font_size_at_caret(&mut self) -> Option<FontSize> {
5101        self.presentation_chain()
5102            .iter()
5103            .find_map(|attrs| FontSize::from_attrs(attrs))
5104    }
5105
5106    /// The face in force at the caret, or `None` for the theme's body face.
5107    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer.
5108    pub fn font_family_at_caret(&mut self) -> Option<FontFace> {
5109        self.presentation_chain()
5110            .iter()
5111            .find_map(|attrs| FontFace::from_attrs(attrs))
5112    }
5113
5114    /// The *text* colour in force at the caret, or `None` for the theme's.
5115    /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
5116    /// [`mark_color_at_caret`](Self::mark_color_at_caret) — that one reads a
5117    /// highlight's background off a `mark`, and a `mark` is never in this chain.
5118    pub fn text_color_at_caret(&mut self) -> Option<TextColor> {
5119        self.presentation_chain()
5120            .iter()
5121            .find_map(|attrs| TextColor::from_attrs(attrs))
5122    }
5123
5124    /// The selection-or-caret half of the three run-level gestures: a span over
5125    /// a real selection, the caret's block over none.
5126    fn set_run_attr(&mut self, what: &str, key: &str, value: Option<&str>) {
5127        match self.selection() {
5128            Some((start, end)) => {
5129                let attrs = with_attr(&self.run_attrs_over(start, end), key, value);
5130                self.write_run_attrs(what, start, end, attrs);
5131            }
5132            None => {
5133                let own = self.block_attrs_at_caret();
5134                if value.is_none()
5135                    && self.refuse_clear_from_div(what, &own, |a| a.iter().any(|(k, _)| k == key))
5136                {
5137                    return;
5138                }
5139                let attrs = with_attr(&own, key, value);
5140                self.write_block_attrs(what, attrs);
5141            }
5142        }
5143    }
5144
5145    /// A clear this gesture cannot carry out, said out loud instead of written:
5146    /// the node it rewrites — the caret's block, or the `<div>` around it that
5147    /// [`block_attrs_at_caret`](Self::block_attrs_at_caret) folds to in Markdown
5148    /// — does not name the property at all, and a `div` further out does.
5149    ///
5150    /// Handing twig the block's attributes with the key already absent changes
5151    /// no byte, and the query goes on answering `Some` off the div: the menu
5152    /// entry the author pressed stays unticked, and nothing says why. Twig's
5153    /// `set_block_attrs` reaches one node, so leaf cannot clear a key it did not
5154    /// write on a node it is not rewriting — the honest answer is the status
5155    /// line, in the voice the other refusals use.
5156    ///
5157    /// `names` is the property's own reading of an attribute list, because
5158    /// alignment lives in a `class` token rather than a key of its own. Spans
5159    /// are skipped: one inside the block is not what a *block* gesture writes
5160    /// either, but neither is it "the div around the block", and the run-level
5161    /// gestures reach it through a selection.
5162    fn refuse_clear_from_div(
5163        &mut self,
5164        what: &str,
5165        own: &Attrs,
5166        names: impl Fn(&Attrs) -> bool,
5167    ) -> bool {
5168        if names(own) {
5169            return false;
5170        }
5171        let caret = self.caret.min(self.source.len());
5172        if !self
5173            .attr_chain_at(caret)
5174            .iter()
5175            .any(|(span, attrs)| !span && names(attrs))
5176        {
5177            return false;
5178        }
5179        self.status = Some(format!("{what}: set on the div around the block"));
5180        true
5181    }
5182
5183    /// Hand `attrs` to twig as the caret's block's whole attribute set, with the
5184    /// status, undo and caret plumbing [`set_mark_color`](Self::set_mark_color)
5185    /// has.
5186    ///
5187    /// **The caret keeps its place in the text, not its byte offset.** How a
5188    /// format spells a block's attributes is markup written *around* the block
5189    /// — djot's `{…}` line above it, a `<div …>` and two blank lines in front of
5190    /// it in Markdown, a longer opening tag in HTML — and every one of those
5191    /// grows or shrinks above the author's own bytes. Where twig's change
5192    /// rewrites the block whole (Markdown's div is spliced as one region, block
5193    /// included) the plain arithmetic of [`reanchor`] has nothing to shift by
5194    /// and parks the caret at the end of the splice, past the closing `</div>`:
5195    /// the caret is then in no block at all, so a second press of the same menu
5196    /// answers "no block at the caret" and the toolbar's queries read nothing.
5197    /// [`reanchor_in_block`] is what carries it across instead — the block's
5198    /// content span before and after, which is the one thing the respelling
5199    /// leaves alone.
5200    ///
5201    /// Read *before* the splice and applied *after* `refresh`, because both
5202    /// halves of that mapping are facts about a tree twig is between: the
5203    /// block's old bytes are gone once the edit lands, and its new ones are not
5204    /// in `self.source` until the refresh puts them there.
5205    fn write_block_attrs(&mut self, what: &str, attrs: Attrs) {
5206        if self.read_only || self.refuse_unsupported(what, Gesture::SetBlockAttrs) {
5207            return;
5208        }
5209        // A blank line has no block to carry an attribute, and twig answers
5210        // `NotFound` there — say so in leaf's own words instead.
5211        let Some(at) = self.block_offset_for_caret() else {
5212            self.status = Some(format!("{what}: no block at the caret"));
5213            return;
5214        };
5215        self.record_caret();
5216        let pairs = attr_pairs(&attrs);
5217        let was = self.block_content_at(at);
5218        let text = was.clone().map(|s| self.source[s].to_string());
5219        match self.editor.set_block_attrs(at, &pairs) {
5220            Ok(change) => {
5221                let (caret, anchor) = (self.caret, self.anchor);
5222                self.last_edit_kind = None; // structural edit is its own undo step
5223                self.refresh();
5224                let now = self.block_content_in(&change.new, text.as_deref());
5225                // A block the two halves cannot both name — a code block, a
5226                // caret in a list's marker — takes the plain arithmetic, which
5227                // is what it had before.
5228                let block = was.as_ref().zip(now.as_ref());
5229                self.caret = reanchor_in_block(caret, &change, block);
5230                self.anchor = anchor.map(|a| reanchor_in_block(a, &change, block));
5231                self.dirty = self.source != self.clean_source;
5232                self.status = None;
5233                // The clamp reads the caret floor off the map, and this edit
5234                // can move the floor: taking the `{…}` line off a djot
5235                // document's first block moves the first rendered offset to 0,
5236                // and a floor read from the old map stood the caret past the
5237                // block's text. So the map is this revision's before the clamp
5238                // — see `open_paragraph_at_block_edge`.
5239                self.rebuild_map();
5240                self.clamp_caret();
5241                self.record_caret();
5242            }
5243            Err(e) => self.status = Some(format!("{what}: {e}")),
5244        }
5245    }
5246
5247    /// The content span of the innermost paragraph or heading covering `off` —
5248    /// the author's own bytes, without the `# ` or the `<p>` that spells the
5249    /// block around them.
5250    ///
5251    /// The same two kinds [`block_attrs_at_caret`](Self::block_attrs_at_caret)
5252    /// reads, so that what a gesture re-anchors by is the block it wrote to.
5253    fn block_content_at(&mut self, off: usize) -> Option<Range<usize>> {
5254        self.nodes()
5255            .into_iter()
5256            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5257            .filter(|n| n.span.start <= off && off <= n.span.end)
5258            .min_by_key(|n| n.span.end - n.span.start)
5259            .map(|n| n.content_span.unwrap_or(n.span))
5260    }
5261
5262    /// [`block_content_at`](Self::block_content_at)'s other half: the content
5263    /// span of the block `region` holds now, found by the bytes it held before.
5264    ///
5265    /// Matched on the text rather than taken as the first block in the region,
5266    /// because a rewritten region is markup and all — `<div class="center">`
5267    /// carries words of its own — and because the block this gesture moved is
5268    /// the one whose content the respelling did not touch. `None` where the
5269    /// region holds no block at all, which is djot's every case: the `{…}` line
5270    /// is spliced above the block and the block itself never moves through the
5271    /// change at all, only past it.
5272    fn block_content_in(
5273        &mut self,
5274        region: &Range<usize>,
5275        text: Option<&str>,
5276    ) -> Option<Range<usize>> {
5277        let text = text?;
5278        let spans: Vec<Range<usize>> = self
5279            .nodes()
5280            .into_iter()
5281            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5282            .filter(|n| region.start <= n.span.start && n.span.end <= region.end)
5283            .map(|n| n.content_span.unwrap_or(n.span))
5284            .collect();
5285        spans
5286            .into_iter()
5287            .find(|s| self.source.get(s.clone()) == Some(text))
5288    }
5289
5290    /// Hand `attrs` to twig as the attribute set of the span over `[start,
5291    /// end)` — wrapping one, or re-styling the one the range already lies in,
5292    /// or unwrapping it when `attrs` is empty.
5293    ///
5294    /// What the splice leaves selected is the span's **content** — the author's
5295    /// words — and not the whole of `change.new`, which is markup and all:
5296    /// `[big]{data-size="large"}` in djot, `<span …>big</span>` in Markdown. A
5297    /// selection reaching past the node's own span lies in no span at all, so a
5298    /// second press of the menu would nest a fresh one instead of re-styling
5299    /// the one just written.
5300    fn write_run_attrs(&mut self, what: &str, start: usize, end: usize, attrs: Attrs) {
5301        if self.read_only || self.refuse_unsupported(what, Gesture::WrapRangeAttrs) {
5302            return;
5303        }
5304        self.record_caret();
5305        let pairs = attr_pairs(&attrs);
5306        match self.editor.wrap_range_attrs(start, end, &pairs) {
5307            Ok(change) => {
5308                self.last_edit_kind = None;
5309                self.refresh();
5310                let content = self.span_content_in(&change.new);
5311                self.anchor = Some(content.start);
5312                self.caret = content.end;
5313                self.dirty = self.source != self.clean_source;
5314                self.status = None;
5315                self.clamp_caret();
5316                self.record_caret();
5317            }
5318            Err(e) => self.status = Some(format!("{what}: {e}")),
5319        }
5320    }
5321
5322    /// The content range of the attributed span `spliced` now holds — the
5323    /// outermost one inside it, since that is the one just written — or
5324    /// `spliced` itself where the splice left no span, which is what an unwrap
5325    /// leaves behind.
5326    fn span_content_in(&mut self, spliced: &Range<usize>) -> Range<usize> {
5327        self.nodes()
5328            .into_iter()
5329            .filter(wysiwyg::is_run_span)
5330            .filter(|n| spliced.start <= n.span.start && n.span.end <= spliced.end)
5331            .max_by_key(|n| n.span.end - n.span.start)
5332            .and_then(|n| n.content_span)
5333            .unwrap_or_else(|| spliced.clone())
5334    }
5335
5336    /// The attribute set `set_block_attrs` is about to **replace** at the caret
5337    /// — which is the block's own, except in Markdown, where twig writes a
5338    /// block's attributes onto a `<div>` around it and rewrites that div when
5339    /// the block is its sole child. Reading the paragraph there would hand back
5340    /// an empty list and quietly drop everything the div said.
5341    ///
5342    /// Empty when the caret is in no block at all, which is the same list a
5343    /// block carrying no attributes gives — and the right one either way, since
5344    /// the gesture then refuses on its own.
5345    fn block_attrs_at_caret(&mut self) -> Attrs {
5346        let Some(off) = self.block_offset_for_caret() else {
5347            return Vec::new();
5348        };
5349        let nodes = self.nodes();
5350        let Some(block) = nodes
5351            .iter()
5352            .filter(|n| matches!(n.kind, Kind::Para | Kind::Heading))
5353            .filter(|n| n.span.start <= off && off <= n.span.end)
5354            .min_by_key(|n| n.span.end - n.span.start)
5355        else {
5356            return Vec::new();
5357        };
5358        if self.format == Format::Markdown
5359            && let Some(parent) = block.parent.and_then(|p| nodes.iter().find(|n| n.id == p))
5360            && wysiwyg::element_tag(parent) == Some("div")
5361            && nodes.iter().filter(|n| n.parent == Some(parent.id)).count() == 1
5362        {
5363            return parent.attrs.clone();
5364        }
5365        block.attrs.clone()
5366    }
5367
5368    /// The attribute set `wrap_range_attrs` is about to **replace** over
5369    /// `[start, end)` — the innermost attributed span the range lies inside,
5370    /// which twig re-styles rather than nesting a second one in. Empty when the
5371    /// range lies in no span, where the gesture mints a fresh one.
5372    fn run_attrs_over(&mut self, start: usize, end: usize) -> Attrs {
5373        self.nodes()
5374            .into_iter()
5375            .filter(wysiwyg::is_run_span)
5376            .filter(|n| n.span.start <= start && end <= n.span.end)
5377            .min_by_key(|n| n.span.end - n.span.start)
5378            .map(|n| n.attrs)
5379            .unwrap_or_default()
5380    }
5381
5382    /// The attribute lists that bear on a presentation query, **nearest first**:
5383    /// the attributed spans the caret stands in (innermost first), then its
5384    /// block, then the `div`s around it. A `find_map` down this is the whole of
5385    /// each query, and the order is the rule the walker draws by.
5386    ///
5387    /// Read at the caret, and at the selection's *start* when the caret stands
5388    /// in no span there. [`write_run_attrs`](Self::write_run_attrs) leaves the
5389    /// caret one past the span it just wrote — `toggle`'s convention — so
5390    /// asking the menu which entry that press just ticked must not answer
5391    /// `None`. Exactly the reason [`mark_offset`](Self::mark_offset) tries both.
5392    fn presentation_chain(&mut self) -> Vec<Attrs> {
5393        let caret = self.caret.min(self.source.len());
5394        let mut chain = self.attr_chain_at(caret);
5395        if !chain.iter().any(|(span, _)| *span)
5396            && let Some((start, _)) = self.selection()
5397        {
5398            let alt = self.attr_chain_at(start);
5399            if alt.iter().any(|(span, _)| *span) {
5400                chain = alt;
5401            }
5402        }
5403        chain.into_iter().map(|(_, attrs)| attrs).collect()
5404    }
5405
5406    /// [`presentation_chain`](Self::presentation_chain) at one offset — every
5407    /// node bearing the vocabulary that covers it, innermost first, each paired
5408    /// with whether it is an attributed span (which is what tells the caller
5409    /// its run-level answer came from a run).
5410    ///
5411    /// Sorted by span length, which *is* the nesting order: a span lies inside
5412    /// its block and a block inside its div, so shortest-first is
5413    /// nearest-first without a second tree walk.
5414    fn attr_chain_at(&mut self, off: usize) -> Vec<(bool, Attrs)> {
5415        let off = off.min(self.source.len());
5416        let mut hits: Vec<(usize, bool, Attrs)> = Vec::new();
5417        for n in self.nodes() {
5418            let span = wysiwyg::is_run_span(&n);
5419            let block = matches!(n.kind, Kind::Para | Kind::Heading);
5420            let div = wysiwyg::element_tag(&n) == Some("div");
5421            if !(span || block || div) {
5422                continue;
5423            }
5424            // A span is half-open, the way a mark is: the offset one past it is
5425            // the text after it. A block and a div claim their end too, so a
5426            // caret resting at the end of a line still reads its paragraph.
5427            let inside = if span {
5428                n.span.start <= off && off < n.span.end
5429            } else {
5430                n.span.start <= off && off <= n.span.end
5431            };
5432            if !inside {
5433                continue;
5434            }
5435            hits.push((n.span.end - n.span.start, span, n.attrs));
5436        }
5437        hits.sort_by_key(|(len, _, _)| *len);
5438        hits.into_iter()
5439            .map(|(_, span, attrs)| (span, attrs))
5440            .collect()
5441    }
5442
5443    /// Convert the block at the caret to a heading level or paragraph.
5444    pub fn set_block(&mut self, kind: BlockKind) {
5445        // The read-only gate — this door reaches twig without the splice.
5446        if self.read_only {
5447            return;
5448        }
5449        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::SetBlock) {
5450            return;
5451        }
5452        self.record_caret();
5453        // A blank line has no node to convert, and twig opens a block there
5454        // rather than declining — so the caret's own offset is the right thing
5455        // to hand it when `block_offset_for_caret` finds nothing.
5456        let offset = self.block_offset_for_caret().unwrap_or(self.caret);
5457        match self.editor.set_block(offset, kind) {
5458            Ok(change) => {
5459                self.last_edit_kind = None;
5460                self.refresh();
5461                // Opening a block on a blank line writes a marker the caret
5462                // belongs *after*; converting an existing one moves nothing.
5463                self.caret = self.caret.max(change.new.end);
5464                self.clamp_caret();
5465                self.anchor = None;
5466                self.dirty = self.source != self.clean_source;
5467                self.status = None;
5468                self.record_caret();
5469            }
5470            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
5471        }
5472    }
5473
5474    /// Whether `off` is inside a text block (paragraph, heading, code block…).
5475    fn has_block_at(&mut self, off: usize) -> bool {
5476        self.editor.ancestors_at(off).ok().is_some_and(|chain| {
5477            chain
5478                .iter()
5479                .any(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
5480        })
5481    }
5482
5483    /// The offset to hand twig's `set_block`: the caret when it is already inside
5484    /// a block, otherwise nudged onto the previous character (a caret at a line
5485    /// end sits at the doc level, outside the block). `None` when the caret is on
5486    /// a blank line — a new paragraph with no block node to convert.
5487    fn block_offset_for_caret(&mut self) -> Option<usize> {
5488        let caret = self.caret.min(self.source.len());
5489        if self.has_block_at(caret) {
5490            return Some(caret);
5491        }
5492        // Nudge to the previous character — but never across a newline: that would
5493        // target the previous block, and a blank line genuinely has no block.
5494        if let Some((i, ch)) = self.source[..caret].char_indices().next_back()
5495            && ch != '\n'
5496            && self.has_block_at(i)
5497        {
5498            return Some(i);
5499        }
5500        None
5501    }
5502
5503    /// The heading level of the text block at the caret, or `None` when that
5504    /// block is not a heading.
5505    pub fn current_heading_level(&mut self) -> Option<u32> {
5506        let caret = self.caret;
5507        self.nodes()
5508            .into_iter()
5509            .filter(|n| n.kind == Kind::Heading)
5510            .find(|n| n.span.start <= caret && caret <= n.span.end)
5511            .and_then(|n| n.level)
5512    }
5513
5514    /// The inline marks in force at the caret (or over the selection) — what a
5515    /// toolbar draws lit, and the block-level [`Doc::current_heading_level`]'s
5516    /// inline counterpart. Cheap enough to call every frame: one twig
5517    /// `ancestors_at` query per caret (two with a selection), each walking root
5518    /// → deepest node at one offset. It never snapshots the tree the way
5519    /// `current_heading_level` does, and the returned set is a `Copy` bitset, so
5520    /// the only allocation is twig's own small ancestor `Vec`.
5521    ///
5522    /// **A selection reports a mark only when the mark covers *all* of it.**
5523    /// That's what every real toolbar means by an active button — Bold lit over
5524    /// a half-bold selection would claim a press turns bold *off*, when
5525    /// [`Doc::toggle`] hands the range to twig and gets the whole thing bolded.
5526    /// Whole-coverage is asked as "is the same mark node standing over both the
5527    /// first and the last character?": inline nodes are contiguous, so one node
5528    /// covering both ends covers every byte between them. Two touching runs
5529    /// (`**a****b**`) are two nodes, and correctly light nothing.
5530    ///
5531    /// At a bare caret a mark is active when the caret stands inside the mark's
5532    /// span — `span.start <= caret < span.end`, delimiters included, which is
5533    /// what makes the boundaries behave. In `a **bold** b` the offsets from the
5534    /// opening `*` (2) through the last byte of the closing `**` (9) are all
5535    /// bold, so the WYSIWYG caret both before `b` and after `d` (the delimiters
5536    /// are hidden, and those offsets are 4 and 8) reports bold — matching where
5537    /// typing would actually land inside the marked run. The offset one past the
5538    /// mark (10) is the text after it and reports nothing, at the end of the
5539    /// buffer exactly as in the middle.
5540    pub fn active_inline_marks(&mut self) -> InlineMarks {
5541        let Some((start, end)) = self.selection() else {
5542            // The marks actually in force at the caret, flipped by any armed
5543            // sticky delta — so `⌘b` at a bare caret lights the Bold button
5544            // immediately, before a single character is typed.
5545            let base: InlineMarks = self
5546                .marks_at(self.caret)
5547                .into_iter()
5548                .map(|(k, _)| k)
5549                .collect();
5550            return base.xor(self.pending_here());
5551        };
5552        // The selection's *last character*, not its exclusive end: `end` is the
5553        // offset one past the selection, which for a selection ending exactly at
5554        // a mark's close is already outside it (`[4,10)` of `a **bold** b` is
5555        // entirely bold, but offset 10 is the space after).
5556        let last = prev_boundary(&self.source, end);
5557        let head = self.marks_at(start);
5558        let tail = self.marks_at(last);
5559        head.into_iter()
5560            .filter(|m| tail.contains(m))
5561            .map(|(k, _)| k)
5562            .collect()
5563    }
5564
5565    /// The inline marks whose span covers `off`, each with the id of the node
5566    /// carrying it — the id is what lets a selection tell one mark node from
5567    /// another of the same kind.
5568    fn marks_at(&mut self, off: usize) -> Vec<(InlineKind, u32)> {
5569        let off = off.min(self.source.len());
5570        self.editor
5571            .ancestors_at(off)
5572            .unwrap_or_default()
5573            .into_iter()
5574            // `span.end` is the offset one *past* the mark, so it isn't in it.
5575            // twig already resolves a boundary to whatever starts there — in
5576            // `**bold** x` offset 8 is the following text, not the strong — but
5577            // when nothing follows, the tie has nobody to break for and the
5578            // chain still ends at the mark. That would make the answer at the
5579            // last offset of the document depend on whether the file happens to
5580            // end in a newline; the rule is `span.start <= off < span.end`, and
5581            // it's the same rule at the end of a buffer as in the middle.
5582            .filter(|m| off < m.span.end)
5583            .filter_map(|m| inline_kind(&m.kind).map(|k| (k, m.node_id)))
5584            .collect()
5585    }
5586
5587    /// Toggle a heading at the caret: if the block is already this heading level,
5588    /// revert it to a paragraph; otherwise convert it to this heading level.
5589    /// This gives the heading commands the same toggle feel as bold/italic/code —
5590    /// re-applying a heading a line already has turns it back into body text.
5591    pub fn toggle_heading(&mut self, level: u32) {
5592        if self.current_heading_level() == Some(level) {
5593            self.set_block(BlockKind::Paragraph);
5594        } else {
5595            self.set_block(BlockKind::Heading(level));
5596        }
5597    }
5598
5599    /// Toggle a block quote around the selection, or around the block at the
5600    /// caret — the toolbar's Quote button.
5601    pub fn toggle_blockquote(&mut self) {
5602        self.toggle_container(BlockContainerKind::BlockQuote);
5603    }
5604
5605    /// Toggle a numbered (`ordered`) or bulleted list over the selection, or
5606    /// over the block at the caret — one op with the kind as a flag, the way
5607    /// `toggle_heading` takes its level, so a frontend needs no twig type to
5608    /// name the two buttons.
5609    ///
5610    /// Pressing the *other* list's button while in a list converts in place
5611    /// rather than nesting, so the pair reads as one three-state control
5612    /// (bulleted / numbered / neither) rather than two independent wrappers.
5613    pub fn toggle_list(&mut self, ordered: bool) {
5614        self.toggle_container(if ordered {
5615            BlockContainerKind::OrderedList
5616        } else {
5617            BlockContainerKind::BulletList
5618        });
5619    }
5620
5621    /// Toggle a fenced code block over the selection, or over the block at the
5622    /// caret — the toolbar's Code Block button, and the only way the rich view
5623    /// offers to open one: a typed backtick is escaped there, since it is a
5624    /// character the author wrote and not markup they meant.
5625    ///
5626    /// Fencing is twig's ([`Editor::toggle_code_block`]): it measures the fence
5627    /// against the body, keeps a quote's `> ` on every line, and peels a fence
5628    /// (or dedents an indented block) on the way back. What is leaf's is the
5629    /// shape twig declines: a blank line holds no block to fence, and the
5630    /// gesture nobody should have to learn is "type something first" — so leaf
5631    /// spells the empty fence itself, with the caret on the empty line inside it
5632    /// and a blank line either side, which is what typing into a new block and
5633    /// then Backspacing out of it would have left.
5634    ///
5635    /// Inside a list item twig refuses in both directions (a fence at column
5636    /// zero would swallow the item's marker), and the refusal is reported rather
5637    /// than worked around.
5638    pub fn toggle_code_block(&mut self) {
5639        // The read-only gate — this door reaches twig without the splice.
5640        if self.read_only {
5641            return;
5642        }
5643        if self.refuse_unsupported("code block", Gesture::ToggleCodeBlock) {
5644            return;
5645        }
5646        let selected = self.selection();
5647        // The caret at a code block's rows resolves to its *content*, and twig
5648        // finds the block from any offset inside its span — but a caret parked
5649        // on the closing fence's own line end is past it, so the block's start
5650        // is handed over whenever the caret is in one at all.
5651        let (start, end) = match selected {
5652            Some(range) => range,
5653            None => match self.code_block_start_at_caret() {
5654                Some(start) => (start, start),
5655                None => match self.block_offset_for_caret() {
5656                    Some(off) => (off, off),
5657                    None => return self.open_empty_code_block(),
5658                },
5659            },
5660        };
5661        self.record_caret();
5662        match self.editor.toggle_code_block(start, end, None) {
5663            Ok(change) => {
5664                // Read the caret's place out of the *pre-edit* source, before
5665                // `refresh` swaps it out — and how many lines the region held,
5666                // which is what says whether a fence line went in above the
5667                // caret's line or came off it.
5668                let place = selected
5669                    .is_none()
5670                    .then(|| self.caret_line_tail(&change.old));
5671                let old_lines = self.source[change.old.clone()].matches('\n').count();
5672                self.last_edit_kind = None; // structural edit is its own undo step
5673                self.refresh();
5674                match place {
5675                    // From a selection: keep the block selected, so a second
5676                    // press reverses the first (twig unfences from any offset in
5677                    // the fence's span, the region's start included).
5678                    None => {
5679                        self.anchor = Some(change.new.start);
5680                        self.caret = change.new.end;
5681                    }
5682                    // From a caret: the same line, the same distance from its
5683                    // end, shifted by the opening fence that was written above
5684                    // it (or peeled off). Dedenting an indented block keeps the
5685                    // lines one-to-one, and shifts nothing.
5686                    Some((line, tail)) => {
5687                        let new_lines = self.source[change.new.start.min(self.source.len())
5688                            ..change.new.end.min(self.source.len())]
5689                            .matches('\n')
5690                            .count();
5691                        let line = match new_lines.cmp(&old_lines) {
5692                            std::cmp::Ordering::Greater => line + 1,
5693                            std::cmp::Ordering::Less => line.saturating_sub(1),
5694                            std::cmp::Ordering::Equal => line,
5695                        };
5696                        self.anchor = None;
5697                        self.caret = self.line_tail_offset(&change.new, (line, tail));
5698                    }
5699                }
5700                self.dirty = self.source != self.clean_source;
5701                self.status = None;
5702                self.clamp_caret();
5703                self.record_caret();
5704            }
5705            Err(e) => self.status = Some(format!("code block: {e}")),
5706        }
5707    }
5708
5709    /// Write an empty fenced block on the blank line the caret stands on, and
5710    /// put the caret on the empty line inside it — the half of
5711    /// [`toggle_code_block`](Self::toggle_code_block) that is leaf's, because
5712    /// twig reports `NotFound` for a range no block covers.
5713    ///
5714    /// Inside a quote the fence lines and the empty line keep the quote's
5715    /// prefix, as twig's own fencing does. A blank line goes in on whichever
5716    /// side has text against it, since a fence may interrupt a paragraph in
5717    /// Markdown but a block standing tight against its neighbours is not the
5718    /// document any editor writes.
5719    fn open_empty_code_block(&mut self) {
5720        let caret = self.caret.min(self.source.len());
5721        let prefix = self.quote_prefix_at(caret);
5722        let line_start = self.source[..caret].rfind('\n').map_or(0, |i| i + 1);
5723        let line_end = self.source[caret..]
5724            .find('\n')
5725            .map_or(self.source.len(), |i| caret + i);
5726        let prev_blank = line_start == 0
5727            || self.source[..line_start - 1]
5728                .rsplit('\n')
5729                .next()
5730                .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
5731        let next_blank = line_end >= self.source.len()
5732            || self.source[line_end + 1..]
5733                .split('\n')
5734                .next()
5735                .is_some_and(|l| l.trim_start_matches(['>', ' ']).is_empty());
5736        // A blank line inside a quote is a bare `>`: the space after it is
5737        // the content's, and there is none.
5738        let blank = prefix.trim_end();
5739        let mut text = String::new();
5740        if !prev_blank {
5741            text.push_str(blank);
5742            text.push('\n');
5743        }
5744        text.push_str(&prefix);
5745        text.push_str("```\n");
5746        text.push_str(&prefix);
5747        let inside = text.len();
5748        text.push('\n');
5749        text.push_str(&prefix);
5750        text.push_str("```");
5751        if !next_blank {
5752            text.push('\n');
5753            text.push_str(blank);
5754        }
5755        // Over whatever the blank line held (its quote prefix, trailing
5756        // spaces), so the block's own prefix is the one twig will read back.
5757        let at = line_start;
5758        if !self.splice(at, line_end, &text, EditKind::Other) {
5759            return;
5760        }
5761        self.caret = at + inside;
5762        self.anchor = None;
5763        self.clamp_caret();
5764        self.record_caret();
5765    }
5766
5767    /// Whether the caret stands in a code block, fenced or indented — what
5768    /// lights the Code Block button. Wider than
5769    /// [`caret_in_fenced_code`](Self::caret_in_fenced_code), which asks the
5770    /// narrower question a language prompt needs.
5771    pub fn caret_in_code_block(&mut self) -> bool {
5772        self.code_block_start_at_caret().is_some()
5773    }
5774
5775    // ── Task list items ──────────────────────────────────────────────────────
5776    // The checkbox in `- [x] done`. twig owns all three gestures: the box is
5777    // inline content of the item's first paragraph rather than part of its
5778    // marker, so adding or removing one must leave the item's continuation
5779    // indentation alone, and an item inside a quote is found past the quote
5780    // markers. leaf names the gesture and the offset; the spelling is twig's.
5781
5782    /// Whether the list item at the caret carries a checkbox, and which way it
5783    /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain list
5784    /// item or no item at all. What a toolbar reads to light its checkbox button.
5785    pub fn task_checked_at_caret(&mut self) -> Option<bool> {
5786        self.task_checked_at(self.caret)
5787    }
5788
5789    /// [`task_checked_at_caret`](Self::task_checked_at_caret) for an arbitrary
5790    /// offset — what a frontend asks before deciding a click landed on a box.
5791    pub fn task_checked_at(&mut self, offset: usize) -> Option<bool> {
5792        self.innermost_list_item(offset.min(self.source.len()))?
5793            .checked
5794    }
5795
5796    /// Tick or untick the task item at the caret (the checkbox's keyboard half).
5797    /// A no-op with a reported reason when the caret is in no task item — minting
5798    /// a box here is [`toggle_task_item`](Self::toggle_task_item)'s job.
5799    pub fn toggle_task_checked(&mut self) {
5800        self.toggle_task_at(self.caret);
5801    }
5802
5803    /// Tick or untick the task item covering `offset` — what a *click* on a
5804    /// rendered checkbox is. Separate from the caret form because a click carries
5805    /// its own offset and must not first move the caret there: ticking a box
5806    /// three paragraphs away should not take the cursor with it.
5807    pub fn toggle_task_at(&mut self, offset: usize) {
5808        // The read-only gate — this door reaches twig without the splice.
5809        if self.read_only {
5810            return;
5811        }
5812        if self.refuse_unsupported("task", Gesture::ToggleTaskChecked) {
5813            return;
5814        }
5815        let offset = offset.min(self.source.len());
5816        self.record_caret();
5817        match self.editor.toggle_task_checked(offset) {
5818            Ok(_) => self.after_task_edit(),
5819            Err(e) => self.status = Some(format!("task: {e}")),
5820        }
5821    }
5822
5823    /// Give the list item at the caret a checkbox, or take its checkbox away —
5824    /// the gesture that converts between a plain bullet and a task. A new box
5825    /// arrives unticked.
5826    pub fn toggle_task_item(&mut self) {
5827        // The read-only gate — this door reaches twig without the splice.
5828        if self.read_only {
5829            return;
5830        }
5831        if self.refuse_unsupported("task", Gesture::ToggleTaskItem) {
5832            return;
5833        }
5834        let caret = self.caret.min(self.source.len());
5835        self.record_caret();
5836        match self.editor.toggle_task_item(caret) {
5837            Ok(_) => self.after_task_edit(),
5838            Err(e) => self.status = Some(format!("task: {e}")),
5839        }
5840    }
5841
5842    /// Settle after a task gesture. The caret rides its old byte offset and is
5843    /// clamped back in: a box is three or four bytes on the item's first line, so
5844    /// text after it shifts by that much at most, and `clamp_caret` lands it on a
5845    /// real stop either way.
5846    fn after_task_edit(&mut self) {
5847        self.last_edit_kind = None;
5848        self.refresh();
5849        self.anchor = None;
5850        self.dirty = self.source != self.clean_source;
5851        self.status = None;
5852        self.clamp_caret();
5853        self.record_caret();
5854    }
5855
5856    // ── Tables ───────────────────────────────────────────────────────────────
5857    // A table is a grid, and twig edits it as one — add/remove/move a row or
5858    // column, set a column's alignment — re-spelling the whole table in a single
5859    // splice. Every gesture is anchored at the caret's cell. leaf just names the
5860    // gesture and re-reads the result; the whole table's numbering, borders, and
5861    // delimiter are twig's to keep straight.
5862
5863    /// Whether the caret is inside a table — what a frontend asks to enable or
5864    /// disable its table controls.
5865    ///
5866    /// An HTML `<table>` still answers `true`: the caret really is in a table,
5867    /// and the reason the grid controls stay dark there is
5868    /// [`Capabilities::table`], which is a fact about the document's format
5869    /// rather than about the caret. A frontend needs both.
5870    pub fn caret_in_table(&mut self) -> bool {
5871        let caret = self.caret.min(self.source.len());
5872        self.editor
5873            .ancestors_at(caret)
5874            .map(|c| c.into_iter().any(|m| m.kind == Kind::Table))
5875            .unwrap_or(false)
5876    }
5877
5878    /// One grid op, guarded and settled — the shared body of the seven below.
5879    ///
5880    /// The guard is why this exists rather than seven copies of the same three
5881    /// lines, and it is the one guard leaf cannot delegate to twig. The table
5882    /// editor is the gesture family that consults no `Syntax` table (it spells a
5883    /// grid, not a delimiter) and therefore the one twig's `Format::supports`
5884    /// deliberately has no variant for: handed an HTML `<table>` it rebuilds the
5885    /// grid as a *pipe table* and reports success, swapping the element out for
5886    /// `| a | b |` and taking the rest of the document's markup with it. Nothing
5887    /// downstream could tell that from a successful edit — the splice is real,
5888    /// the reparse succeeds, `dirty` is honest — which is what makes it worth
5889    /// stopping at the door rather than detecting after the fact. See
5890    /// [`spells_pipe_tables`].
5891    fn table_op(
5892        &mut self,
5893        what: &str,
5894        op: impl FnOnce(&mut Editor, usize) -> Result<(), twig::Error>,
5895    ) {
5896        if self.refuse_unless(what, spells_pipe_tables(self.format)) {
5897            return;
5898        }
5899        self.record_caret();
5900        let at = self.caret;
5901        let r = op(&mut self.editor, at);
5902        self.apply_table(r, what);
5903    }
5904
5905    /// Insert an empty row below (`below`) or above the caret's row.
5906    pub fn table_insert_row(&mut self, below: bool) {
5907        self.table_op("table row", |e, at| e.table_insert_row(at, below));
5908    }
5909
5910    /// Delete the caret's row (not the header, not the last body row).
5911    pub fn table_delete_row(&mut self) {
5912        self.table_op("table row", |e, at| e.table_delete_row(at));
5913    }
5914
5915    /// Insert an empty column right (`right`) or left of the caret's column.
5916    pub fn table_insert_column(&mut self, right: bool) {
5917        self.table_op("table column", |e, at| e.table_insert_column(at, right));
5918    }
5919
5920    /// Delete the caret's column (unless it is the only one).
5921    pub fn table_delete_column(&mut self) {
5922        self.table_op("table column", |e, at| e.table_delete_column(at));
5923    }
5924
5925    /// Set the caret's column to `alignment`.
5926    pub fn table_set_alignment(&mut self, alignment: Alignment) {
5927        self.table_op("table alignment", |e, at| {
5928            e.table_set_alignment(at, alignment)
5929        });
5930    }
5931
5932    /// Move the caret's row one place down (`down`) or up, within the body rows.
5933    pub fn table_move_row(&mut self, down: bool) {
5934        self.table_op("table row", |e, at| e.table_move_row(at, down));
5935    }
5936
5937    /// Move the caret's column one place right (`right`) or left.
5938    pub fn table_move_column(&mut self, right: bool) {
5939        self.table_op("table column", |e, at| e.table_move_column(at, right));
5940    }
5941
5942    /// Settle the caret and document flags after a table op (or report its
5943    /// error). twig re-spells the whole table, so the caret rides its old byte
5944    /// offset and is clamped back into the rebuilt bytes — near enough to where
5945    /// it was, since the op preserves the cells' content and order around it.
5946    fn apply_table(&mut self, result: Result<(), twig::Error>, what: &str) {
5947        match result {
5948            Ok(()) => {
5949                self.last_edit_kind = None;
5950                self.refresh();
5951                self.anchor = None;
5952                self.clamp_caret();
5953                self.dirty = self.source != self.clean_source;
5954                self.status = None;
5955                self.record_caret();
5956            }
5957            Err(e) => self.status = Some(format!("{what}: {e}")),
5958        }
5959    }
5960
5961    /// One `toggle_block_container` over the block-level target.
5962    ///
5963    /// leaf says *where*; twig decides everything else — which blocks the range
5964    /// covers, whether that means wrapping, unwrapping, nesting or converting,
5965    /// and how this document's format spells the prefix. The rule that a
5966    /// container only comes off when the range covers every block it holds is
5967    /// what the re-anchoring below is built around.
5968    fn toggle_container(&mut self, kind: BlockContainerKind) {
5969        // The read-only gate — this door reaches twig without the splice.
5970        if self.read_only {
5971            return;
5972        }
5973        if self.refuse_unsupported(&format!("{kind:?}"), Gesture::ToggleBlockContainer(kind)) {
5974            return;
5975        }
5976        let selected = self.selection();
5977        // A blank line holds no block, and twig opens an *empty* container on one
5978        // — since 3.2.0; it used to decline the range with `NotFound`, which is
5979        // why this used to lend it a scratch paragraph to wrap. Worth knowing
5980        // here because the line-for-line caret mapping below cannot describe it:
5981        // opening one under a paragraph writes the blank line the format needs
5982        // above the marker too, so the rewritten region has a line the old one
5983        // didn't, and "the same line, the same distance from its end" lands on
5984        // that new blank instead of in the container.
5985        let opened_empty = selected.is_none() && self.block_offset_for_caret().is_none();
5986        // Without a selection the target is the caret's own block, resolved the
5987        // way `set_block` resolves it — a caret at a line end sits at the doc
5988        // level and has to be nudged back onto the block it looks like it's in.
5989        // An empty range is enough: twig widens to the whole lines it touches.
5990        let (start, end) = match selected {
5991            Some(range) => range,
5992            None => {
5993                let off = self.block_offset_for_caret().unwrap_or(self.caret);
5994                (off, off)
5995            }
5996        };
5997        self.record_caret();
5998        match self.editor.toggle_block_container(start, end, kind) {
5999            Ok(change) => {
6000                // Read the caret's place out of the *pre-edit* source, before
6001                // `refresh` swaps that source out from under it.
6002                let place = (selected.is_none() && !opened_empty)
6003                    .then(|| self.caret_line_tail(&change.old));
6004                self.last_edit_kind = None; // structural edit is its own undo step
6005                self.refresh();
6006                match place {
6007                    // Both land the caret at the far end of what twig wrote, and
6008                    // differ only in what they leave selected.
6009                    //
6010                    // From a selection: select what the container now holds, the
6011                    // way `toggle` keeps its marked region selected — and for a
6012                    // stronger reason than symmetry: a container comes *off* only
6013                    // a range covering every block it holds, so a selection left
6014                    // on its old bytes (now short by a prefix per line) would nest
6015                    // on the second press instead of reversing the first.
6016                    //
6017                    // From a blank line: nothing to select, and the end of the
6018                    // region is exactly past the bare `> ` / `- ` twig wrote —
6019                    // the caret standing inside the container that was asked for.
6020                    None => {
6021                        self.anchor = (!opened_empty).then_some(change.new.start);
6022                        self.caret = change.new.end;
6023                    }
6024                    Some(place) => {
6025                        self.anchor = None;
6026                        self.caret = self.line_tail_offset(&change.new, place);
6027                    }
6028                }
6029                self.dirty = self.source != self.clean_source;
6030                self.status = None;
6031                self.clamp_caret();
6032                self.record_caret();
6033            }
6034            Err(e) => self.status = Some(format!("{kind:?}: {e}")),
6035        }
6036    }
6037
6038    /// The caret's place inside the region a container toggle is rewriting, in
6039    /// the only terms the rewrite preserves: which of the region's lines it sits
6040    /// on, and how many bytes of that line lie ahead of it.
6041    ///
6042    /// A container's markup goes in at column 0 and never touches what follows
6043    /// on the line, so that pair survives the edit exactly where a byte offset
6044    /// does not — a caret left on its old offset slides back by one prefix per
6045    /// line above it, which on a hard-wrapped paragraph parks it *inside* the
6046    /// `> ` it just asked for.
6047    fn caret_line_tail(&self, old: &std::ops::Range<usize>) -> (usize, usize) {
6048        let caret = self.caret.clamp(old.start, old.end);
6049        let line = self.source[old.start..caret].matches('\n').count();
6050        let end = self.source[caret..old.end]
6051            .find('\n')
6052            .map_or(old.end, |i| caret + i);
6053        (line, end - caret)
6054    }
6055
6056    /// [`caret_line_tail`](Self::caret_line_tail) undone against the rewritten
6057    /// region: the offset `tail` bytes back from the end of the region's `line`.
6058    ///
6059    /// Both walks are clamped rather than trusted, because the one op that does
6060    /// *not* keep a region's lines one-to-one is stripping a list — twig blows
6061    /// the items back apart with blank lines between them — and a caret landing
6062    /// on the nearest line of the right item beats one landing out of the region
6063    /// entirely.
6064    fn line_tail_offset(
6065        &self,
6066        new: &std::ops::Range<usize>,
6067        (line, tail): (usize, usize),
6068    ) -> usize {
6069        let region = &self.source[new.start.min(self.source.len())..new.end.min(self.source.len())];
6070        let mut start = 0;
6071        for _ in 0..line {
6072            match region[start..].find('\n') {
6073                Some(i) => start += i + 1,
6074                None => break,
6075            }
6076        }
6077        let end = region[start..]
6078            .find('\n')
6079            .map_or(region.len(), |i| start + i);
6080        new.start + end.saturating_sub(tail).max(start)
6081    }
6082
6083    /// Link the selection to `destination` — the toolbar's Link button. With no
6084    /// selection it acts at the caret, which re-points a link the caret is
6085    /// already standing in (twig replaces an existing link's destination and
6086    /// keeps its text) and otherwise spells a link that has no text of its own:
6087    /// an autolink (`<https://x.dev>`) where the destination is one, and
6088    /// `[destination](destination)` where it isn't.
6089    ///
6090    /// `destination` reaches twig raw. Escaping it is format knowledge and the
6091    /// two formats genuinely disagree — Markdown ends a destination at the first
6092    /// space and moves it into `<…>`, djot reads that `<…>` as part of the URL
6093    /// itself — so the side holding the document is the side that gets to spell
6094    /// it. A destination twig can't carry at all (one with a newline) comes back
6095    /// as an error rather than a quietly rewritten URL.
6096    pub fn insert_link(&mut self, destination: &str) {
6097        if self.read_only || self.refuse_unsupported("link", Gesture::InsertLink) {
6098            return;
6099        }
6100        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6101        self.record_caret();
6102        match self.editor.insert_link(start, end, destination) {
6103            Ok(change) => {
6104                self.last_edit_kind = None;
6105                self.refresh();
6106                match self.link_text_span(change.new.start) {
6107                    // A link with text of its own: select it, so typing replaces
6108                    // a `[dest](dest)`'s stand-in label and a second press
6109                    // re-points what the first one linked.
6110                    Some(text) => {
6111                        self.anchor = (text.start != text.end).then_some(text.start);
6112                        self.caret = text.end;
6113                    }
6114                    // An autolink is finished the moment it's written — its text
6115                    // *is* the URL. Leaving it selected would aim the next press
6116                    // at the one shape twig still wraps instead of re-points.
6117                    None => {
6118                        self.anchor = None;
6119                        self.caret = change.new.end;
6120                    }
6121                }
6122                self.dirty = self.source != self.clean_source;
6123                self.status = None;
6124                self.clamp_caret();
6125                self.record_caret();
6126            }
6127            Err(e) => self.status = Some(format!("link: {e}")),
6128        }
6129    }
6130
6131    /// Insert a block-level image at the caret: `![alt](destination)`. Any
6132    /// selection becomes the alt text (so "select a caption, insert image" labels
6133    /// it); with no selection, `alt` is used — empty for none. The caret lands
6134    /// just past the inserted image.
6135    ///
6136    /// Both halves go through twig (`insert_literal` for the alt text,
6137    /// `insert_image` for the image), so neither is spelled here. That used to be a
6138    /// `format!`, and it was wrong the first time an app inserted a real filename:
6139    /// Markdown ends a destination at the first space, so `![](my photo.png)` is
6140    /// not an image at all — and the fix is per-format, since moving into the
6141    /// `<…>` form is exactly wrong for Djot, where `<…>` becomes the URL itself.
6142    pub fn insert_image(&mut self, destination: &str, alt: &str) {
6143        if self.read_only || self.refuse_unsupported("image", Gesture::InsertImage) {
6144            return;
6145        }
6146        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6147        self.record_caret();
6148        // With no selection and an explicit `alt`, the alt text has to exist in the
6149        // document before it can be the image's — and it is raw caller input, so
6150        // it goes in through `insert_literal`, which escapes it for the format
6151        // rather than letting a `]` in someone's caption close the image early.
6152        let (start, end) = if start == end && !alt.is_empty() {
6153            match self.editor.insert_literal(start, alt) {
6154                Ok(change) => (change.new.start, change.new.end),
6155                Err(e) => {
6156                    self.status = Some(format!("image: {e}"));
6157                    return;
6158                }
6159            }
6160        } else {
6161            (start, end)
6162        };
6163        match self.editor.insert_image(start, end, destination) {
6164            Ok(change) => {
6165                self.last_edit_kind = None;
6166                self.refresh();
6167                // Just past the image, nothing selected — where a caret belongs
6168                // after inserting one.
6169                self.anchor = None;
6170                self.caret = change.new.end;
6171                self.dirty = self.source != self.clean_source;
6172                self.status = None;
6173                self.clamp_caret();
6174                self.record_caret();
6175            }
6176            Err(e) => self.status = Some(format!("image: {e}")),
6177        }
6178    }
6179
6180    /// Insert a block-level image, video, or audio at the caret. The image case
6181    /// is [`insert_image`](Self::insert_image); video and audio are spelled as
6182    /// HTML elements, which is the only spelling Markdown and Djot have for them:
6183    ///
6184    /// ```text
6185    /// <video src="clip.mp4" controls>alt</video>
6186    /// <audio src="take.mp3" controls>alt</audio>
6187    /// ```
6188    ///
6189    /// HTML rather than a `::video{…}` directive deliberately. A directive means
6190    /// something only to an app that knows the vocabulary, so the document would
6191    /// read as literal punctuation everywhere else; `<video>` is what every other
6192    /// renderer already understands, and what leaf's own reader picks back up
6193    /// through `html_elements` promotion (see [`parse_extensions`]).
6194    ///
6195    /// The one-line spelling needs twig ≥ 2.5.1, which widened CommonMark's
6196    /// HTML-block tag list to cover `<video>`/`<audio>`/`<picture>` under
6197    /// `html_elements`. Before that only the multi-line form parsed as a block at
6198    /// all, and this wrote three lines to work around it.
6199    ///
6200    /// `controls` is always written: a player with no transport is a still frame
6201    /// the reader can't do anything with. Any selection becomes the element's
6202    /// fallback text, exactly as it becomes an image's alt.
6203    ///
6204    /// The same verbatim-insertion caveat as [`insert_image`](Self::insert_image)
6205    /// applies, and bites harder here: a `"` in `destination` closes the
6206    /// attribute. A frontend taking these from a file picker is fine; one taking
6207    /// them from free text should keep them tame.
6208    ///
6209    /// [`MediaInfo`]: crate::MediaInfo
6210    pub fn insert_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6211        if kind == MediaKind::Image {
6212            return self.insert_image(destination, alt);
6213        }
6214        // Gated on the *image* gesture, not on one of its own — there isn't one,
6215        // since the bytes below are spelled here rather than by twig, and an HTML
6216        // document would in fact parse them. The button is one control with three
6217        // kinds behind it, and two of them working in a format where the third
6218        // cannot is a worse surface than three that agree — especially as
6219        // `insert_image` is the kind anyone reaches for first.
6220        if self.refuse_unsupported("media", Gesture::InsertImage) {
6221            return;
6222        }
6223        let (start, end) = self.selection().unwrap_or((self.caret, self.caret));
6224        let alt_text = self
6225            .selected_text()
6226            .map(str::to_string)
6227            .unwrap_or_else(|| alt.to_string());
6228        let tag = match kind {
6229            MediaKind::Audio => "audio",
6230            _ => "video",
6231        };
6232        let markup = format!("<{tag} src=\"{destination}\" controls>{alt_text}</{tag}>");
6233        self.edit(start, end, &markup);
6234    }
6235
6236    /// Append media at the **end** of the document, as a block of its own —
6237    /// the verb for a picture that *arrives* rather than one the writer
6238    /// places: an attachment imported while the caret was wherever it last
6239    /// was, a drawing placed from a tray under the body. At the caret it
6240    /// would land inline in front of whatever word the caret happened to be
6241    /// beside, which for an editor nobody has tapped yet is the first word
6242    /// of the document.
6243    ///
6244    /// The document is ended with a blank line first, where it does not
6245    /// already end with one, so the media is a paragraph of its own rather
6246    /// than a lazy continuation of the last one; an empty document needs no
6247    /// separator. Then [`insert_media`](Self::insert_media) at the new end,
6248    /// with everything that means: the same markup per kind, the same
6249    /// refusal on a format without images. The selection is dropped — the
6250    /// verb is about the end of the document, not about what was selected —
6251    /// and the caret is left past the media, as `insert_media` leaves it.
6252    pub fn append_media(&mut self, kind: MediaKind, destination: &str, alt: &str) {
6253        if self.read_only || self.refuse_unsupported("media", Gesture::InsertImage) {
6254            return;
6255        }
6256        let separator = if self.source.is_empty() || self.source.ends_with("\n\n") {
6257            ""
6258        } else if self.source.ends_with('\n') {
6259            "\n"
6260        } else {
6261            "\n\n"
6262        };
6263        let end = self.source.len();
6264        self.anchor = None;
6265        self.caret = end;
6266        if !separator.is_empty() {
6267            self.edit(end, end, separator);
6268        }
6269        self.anchor = None;
6270        self.caret = self.source.len();
6271        self.insert_media(kind, destination, alt);
6272    }
6273
6274    /// Insert a thematic break at the caret — the toolbar's Horizontal Rule
6275    /// button. Spelling and placement are both twig's; leaf used to write `---`
6276    /// itself, which was the Markdown spelling in a djot document too.
6277    ///
6278    /// A rule is a block, so `insert_thematic_break` alone has nowhere to put one
6279    /// mid-paragraph and lands it after the caret's whole block. To get a rule
6280    /// *at* the caret — the paragraph parted in two around it, which is what a
6281    /// rule button is understood to do — the paragraph is first divided with
6282    /// `split_block` and the rule then aimed at the **first** half. Aiming it at
6283    /// the offset `split_block` returns puts the rule after the *second* half
6284    /// instead, which is a rule in the right document and the wrong place.
6285    ///
6286    /// Only a plain paragraph is split, and only where there is something to
6287    /// part: at the paragraph's end the split has no second half to mint and
6288    /// would write the separator anyway — a blank line and the empty slot Enter
6289    /// leaves for the next paragraph, which the rule then lands above and
6290    /// nothing fills — so there the rule goes straight after the paragraph,
6291    /// which is where the split-and-aim was sending it regardless. At the
6292    /// paragraph's *start* the split is kept, though it parts nothing either:
6293    /// `|para` becomes `\npara` with the caret on the new blank line, and a
6294    /// rule aimed at a blank line is written on it (twig ≥ 3.5.2), which is how
6295    /// "before the paragraph" is said through a gesture that only knows
6296    /// "after" — `---\n\npara`, and `prev\n\n---\n\npara` mid-document. Everywhere
6297    /// else the rule simply lands after the block, which is both twig's own
6298    /// answer and the better one: splitting a fenced code block would leave two
6299    /// fences with a rule between them, and splitting a list item would mint an
6300    /// item nobody asked for on the way to a rule that lands after the list
6301    /// regardless. A table and a setext heading refuse the split outright, so
6302    /// they take the same path by themselves.
6303    pub fn insert_thematic_break(&mut self) {
6304        if self.read_only || self.refuse_unsupported("thematic break", Gesture::InsertThematicBreak)
6305        {
6306            return;
6307        }
6308        self.caret = self.skip_trailing_close_delims(self.caret);
6309        // A selection is replaced by the rule, so collapse it first and let the
6310        // split-and-rule below run from the caret it leaves behind.
6311        if let Some((s, e)) = self.selection() {
6312            self.splice(s, e, "", EditKind::Other);
6313        }
6314        self.anchor = None;
6315        self.record_caret();
6316        let at = self.caret;
6317        if self.caret_parts_bare_paragraph() {
6318            // A failure here is not fatal: the rule still lands after the block,
6319            // which is exactly what this call was trying to improve on.
6320            let _ = self.editor.split_block(at);
6321        }
6322        match self.editor.insert_thematic_break(at) {
6323            Ok(change) => {
6324                self.last_edit_kind = None;
6325                self.refresh();
6326                self.anchor = None;
6327                self.caret = change.new.end;
6328                self.dirty = self.source != self.clean_source;
6329                self.status = None;
6330                self.clamp_caret();
6331                self.record_caret();
6332            }
6333            Err(e) => self.status = Some(format!("thematic break: {e}")),
6334        }
6335    }
6336
6337    /// Insert a fresh table at the caret — the toolbar's Table button. One
6338    /// header row, `rows` empty body rows, `cols` columns, spelled by twig in
6339    /// the document's own dialect and placed the way its thematic break is:
6340    /// after the caret's block, blank-separated. A bare paragraph is parted
6341    /// around the caret first, exactly as
6342    /// [`insert_thematic_break`](Self::insert_thematic_break) parts it, so the
6343    /// table lands *at* the caret rather than after everything the caret's
6344    /// paragraph says.
6345    ///
6346    /// The caret ends in the first header cell, selected the way Tab selects
6347    /// a cell — the natural next act is to type the heading, and Tab then
6348    /// walks the grid. That cell is read back from the rebuilt table map
6349    /// rather than computed from the splice, because twig's blank line and
6350    /// quote prefix put the first bar at an offset only the reparse knows.
6351    ///
6352    /// The shape is the caller's: a menu offers a few, a dialog asks. Zero
6353    /// rows or columns is twig's refusal (a header with nothing under it is
6354    /// what its row delete refuses to leave), reported through `status`.
6355    pub fn insert_table(&mut self, rows: usize, cols: usize) {
6356        if self.read_only || self.refuse_unsupported("table", Gesture::InsertTable) {
6357            return;
6358        }
6359        self.caret = self.skip_trailing_close_delims(self.caret);
6360        if let Some((s, e)) = self.selection() {
6361            self.splice(s, e, "", EditKind::Other);
6362        }
6363        self.anchor = None;
6364        self.record_caret();
6365        let at = self.caret;
6366        if self.caret_parts_bare_paragraph() {
6367            let _ = self.editor.split_block(at);
6368        }
6369        match self.editor.insert_table(at, rows, cols) {
6370            Ok(change) => {
6371                self.last_edit_kind = None;
6372                self.refresh();
6373                self.anchor = None;
6374                self.caret = change.new.end;
6375                self.dirty = self.source != self.clean_source;
6376                self.status = None;
6377                self.clamp_caret();
6378                // Into the first header cell of the table just written: the
6379                // first table whose grid begins inside the splice.
6380                self.rebuild_map();
6381                let first_cell = self
6382                    .vmap
6383                    .tables
6384                    .iter()
6385                    .filter_map(|t| t.grid.first().and_then(|row| row.cells.first()))
6386                    .find(|cell| cell.start >= change.new.start && cell.start < change.new.end)
6387                    .map(|cell| (cell.start, cell.end));
6388                if let Some((start, end)) = first_cell {
6389                    self.select_cell(start, end);
6390                }
6391                self.record_caret();
6392            }
6393            Err(e) => self.status = Some(format!("table: {e}")),
6394        }
6395    }
6396
6397    /// Whether the caret sits in a paragraph and nothing else — no list item, no
6398    /// quote, no fence, no table — with paragraph text still ahead of it. The
6399    /// one shape where parting the block around the caret is unambiguously what
6400    /// a rule button means; see
6401    /// [`insert_thematic_break`](Self::insert_thematic_break) for why every other
6402    /// container is left to take the rule after itself.
6403    ///
6404    /// The "text ahead" half is what keeps `split_block` from running at the
6405    /// one edge where its output composes badly. At a paragraph's end twig
6406    /// cannot mint the empty second half (no format spells an empty
6407    /// paragraph), so it writes only the separator — a blank line and the
6408    /// slot Enter leaves for the paragraph to come — and a block then aimed at
6409    /// the first half lands above a slot that nothing fills: `para\n` with the
6410    /// caret at 4 came out as `para\n\n* * *\n\n\n`. Trailing whitespace counts
6411    /// as nothing ahead, since the split would shed it as the second half's
6412    /// leading indent and leave the same slot. Which end of the newline a
6413    /// paragraph's span stops at differs between the formats (Markdown before
6414    /// it, djot after), which is why this reads the remaining bytes rather
6415    /// than comparing offsets. The paragraph's
6416    /// start is deliberately not the same case — see
6417    /// [`insert_thematic_break`](Self::insert_thematic_break) for why that
6418    /// split is kept.
6419    fn caret_parts_bare_paragraph(&mut self) -> bool {
6420        let caret = self.caret.min(self.source.len());
6421        let Ok(chain) = self.editor.ancestors_at(caret) else {
6422            return false;
6423        };
6424        let mut para_end = None;
6425        for m in chain {
6426            match m.kind {
6427                Kind::Para => para_end = Some(m.span.end.min(self.source.len())),
6428                Kind::ListItem
6429                | Kind::TaskListItem
6430                | Kind::BlockQuote
6431                | Kind::CodeBlock
6432                | Kind::Table => return false,
6433                _ => {}
6434            }
6435        }
6436        match para_end {
6437            Some(end) if end > caret => !self.source[caret..end].trim().is_empty(),
6438            _ => false,
6439        }
6440    }
6441
6442    /// The destination of the link under the caret — what a Link prompt shows so
6443    /// ⌘K on an existing link edits its URL instead of asking for it again.
6444    /// `None` when the caret stands in no link.
6445    ///
6446    /// An autolink carries no separate destination: its text *is* the URL, so
6447    /// that's what comes back for one.
6448    pub fn link_destination_at_caret(&mut self) -> Option<String> {
6449        self.link_destination_at(self.caret)
6450    }
6451
6452    /// The destination of the link at `off`.
6453    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
6454    /// the caret isn't.
6455    ///
6456    /// The offset form exists for the same reason
6457    /// [`footnote_at`](Self::footnote_at)'s does: a frontend drawing a *piece* of
6458    /// the document somewhere else — a footnote's text in a popover, say — has
6459    /// rows and runs but no caret in them, and still needs to know which of those
6460    /// runs a reader can follow.
6461    pub fn link_destination_at(&mut self, off: usize) -> Option<String> {
6462        self.nodes()
6463            .into_iter()
6464            .filter(|n| matches!(n.kind.as_str(), "link" | "url" | "email"))
6465            .filter(|n| n.span.start <= off && off < n.span.end)
6466            .max_by_key(|n| n.span.start)
6467            .and_then(|n| n.destination.or(n.text))
6468    }
6469
6470    /// Where the locator `id` lands in this document — the `#v2` half of a
6471    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
6472    /// answers to it.
6473    ///
6474    /// The other end of a link, and the reason this exists: without it a
6475    /// destination has only file granularity, so following a citation into a
6476    /// chapter drops the reader at the top of it to hunt for the verse. Which is
6477    /// also why it is a *document* query rather than a caret one — the document
6478    /// being asked is usually not the one the reader is in.
6479    ///
6480    /// Three readings, tried in order, because the same `#some-heading` is
6481    /// written three ways across the formats leaf opens:
6482    ///
6483    /// 1. **A declared id**, exactly as written: djot's `{#v1}` on a block, and
6484    ///    the auto-ids djot mints for its headings. The only exact answer, so it
6485    ///    goes first — a document that says `{#v1}` has settled the question.
6486    /// 2. **A declared id, slugged.** djot spells a heading's auto-id
6487    ///    `Some-Heading-Here`; nearly every tool that *writes* a link to one
6488    ///    spells it `#some-heading-here`. Comparing slugs is what lets a link
6489    ///    authored anywhere land on a djot heading.
6490    /// 3. **A heading's text, slugged.** Markdown has no ids at all — twig mints
6491    ///    none and `{#custom}` is literal text in a Markdown heading — so for
6492    ///    the format most vaults are written in, the heading's own words are the
6493    ///    only thing a fragment can name. This is the rule every Markdown
6494    ///    renderer already follows, which is what makes `#a-heading` mean in
6495    ///    diaryx what it means on the web.
6496    ///
6497    /// Ties go to the earliest match, then to the widest: a duplicated id is the
6498    /// document's mistake and the first one is the answer every anchor
6499    /// implementation gives, while preferring the wider span picks the section
6500    /// over the heading that opens it — more for a peek to show, same place to
6501    /// land.
6502    pub fn locate(&mut self, id: &str) -> Option<Landing> {
6503        let id = id.trim();
6504        if id.is_empty() {
6505            return None;
6506        }
6507        let nodes = self.nodes();
6508
6509        // Earliest wins, then widest. `Reverse` on the end because `min_by_key`
6510        // is picking, among nodes that start together, the one that ends last.
6511        let pick = |matches: &mut dyn Iterator<Item = &FlatNode>| {
6512            matches
6513                .min_by_key(|n| (n.span.start, std::cmp::Reverse(n.span.end)))
6514                .map(|n| Landing {
6515                    start: n.span.start,
6516                    end: n.span.end,
6517                })
6518        };
6519
6520        if let Some(landing) = pick(&mut nodes.iter().filter(|n| declared_id(n) == Some(id))) {
6521            return Some(landing);
6522        }
6523        let want = slug(id);
6524        if want.is_empty() {
6525            return None;
6526        }
6527        if let Some(landing) = pick(
6528            &mut nodes
6529                .iter()
6530                .filter(|n| declared_id(n).map(slug).as_deref() == Some(&*want)),
6531        ) {
6532            return Some(landing);
6533        }
6534
6535        // A heading by its words. Its span is one line, so the end comes from
6536        // where the *section* it opens gives out — the next heading that is not
6537        // under it, or the end of the document. A Markdown heading has no
6538        // section node to ask (twig only builds those for djot), and a peek that
6539        // showed the heading alone would answer "what does that say" with the
6540        // title of the thing it says.
6541        let heading = nodes
6542            .iter()
6543            .filter(|n| n.kind == Kind::Heading)
6544            .filter(|n| {
6545                n.content_span
6546                    .clone()
6547                    .and_then(|s| self.source.get(s))
6548                    .is_some_and(|text| slug(text) == want)
6549            })
6550            .min_by_key(|n| n.span.start)?;
6551        let level = heading.level.unwrap_or(u32::MAX);
6552        let end = nodes
6553            .iter()
6554            .filter(|n| n.kind == Kind::Heading)
6555            .filter(|n| n.span.start > heading.span.start)
6556            .filter(|n| n.level.unwrap_or(u32::MAX) <= level)
6557            .map(|n| n.span.start)
6558            .min()
6559            .unwrap_or(self.source.len());
6560        Some(Landing {
6561            start: heading.span.start,
6562            end,
6563        })
6564    }
6565
6566    /// Write a footnote at the caret — the toolbar's Footnote button, and the
6567    /// one gesture in the footnote story that *authors* rather than follows.
6568    ///
6569    /// Both halves go in as one twig edit: the `[^1]` where the caret is, and
6570    /// the `[^1]:` definition at the end of the document. Half a footnote is not
6571    /// a footnote — a bare reference with nothing defining it renders as literal
6572    /// brackets — so a single button that wrote only the reference would leave
6573    /// the author to hand-spell the other half in a document that had just
6574    /// stopped showing them what the first half meant. One edit also means one
6575    /// undo takes both back.
6576    ///
6577    /// The definition's body is left empty and **the caret lands in it**, which
6578    /// is the whole point of pressing the button: nobody wants a reference to a
6579    /// note they have not written yet. Getting back to where they were writing
6580    /// is [`footnote_definition_at_caret`](Self::footnote_definition_at_caret) —
6581    /// the same return leg a reader following a reference already uses, so the
6582    /// author is left standing on the near end of a round trip that works.
6583    ///
6584    /// A selection collapses to its *end* rather than being replaced: a
6585    /// reference annotates the words before it, so "select the claim, add a
6586    /// footnote" should mark that claim, not consume it.
6587    pub fn insert_footnote(&mut self) {
6588        if self.read_only || self.refuse_unsupported("footnote", Gesture::InsertFootnote) {
6589            return;
6590        }
6591        let at = self.selection().map_or(self.caret, |(_, end)| end);
6592        self.anchor = None;
6593        self.caret = at;
6594        self.record_caret();
6595        let label = self.next_footnote_label();
6596        match self.editor.insert_footnote(at, &label) {
6597            Ok(change) => {
6598                self.last_edit_kind = None;
6599                self.refresh();
6600                self.anchor = None;
6601                // `change.new` runs from the reference to the end of the
6602                // document, so its start is the `[^1]` just written and
6603                // `footnote_at` resolves it to the note the same way a reader's
6604                // tap does — and to the note's *body*, which is already a caret
6605                // stop even when it is empty (the `[^1]:` marker draws as `[1] `
6606                // and has none), so this needs no snap on top. The fallback is
6607                // the reference's own offset: a format that spelled the pair some
6608                // way leaf can't read back should still leave the caret on the
6609                // edit rather than at the far end of a document it just grew.
6610                self.caret = self
6611                    .footnote_at(change.new.start)
6612                    .and_then(|note| note.offset)
6613                    .unwrap_or(change.new.start);
6614                self.dirty = self.source != self.clean_source;
6615                self.status = None;
6616                self.clamp_caret();
6617                self.record_caret();
6618            }
6619            Err(e) => self.status = Some(format!("footnote: {e}")),
6620        }
6621    }
6622
6623    /// The label to give a footnote the author has not named: the lowest counting
6624    /// number no footnote in the document is already wearing.
6625    ///
6626    /// twig takes the label rather than minting one, because it holds no opinion
6627    /// about what a document's footnotes should be called — and it is right not
6628    /// to. Numbering them is what every author of a numbered note expects, and
6629    /// re-using a taken number would silently point the new reference at somebody
6630    /// else's note (twig reuses an existing definition rather than appending a
6631    /// second one, which is the right rule for citing a note twice on purpose and
6632    /// exactly the wrong accident to have by default).
6633    ///
6634    /// *References* are counted alongside definitions, not just definitions: a
6635    /// document carrying a dangling `[^2]` has a 2 that means something to
6636    /// whoever wrote it, and minting a definition for it here would answer a
6637    /// question nobody asked. Non-numeric labels (`[^why]`) are left out of the
6638    /// count entirely — they take no number, so they block none.
6639    fn next_footnote_label(&mut self) -> String {
6640        let mut taken: Vec<u32> = wysiwyg::footnote_definitions(&mut self.editor)
6641            .into_iter()
6642            .filter_map(|note| wysiwyg::footnote_label(&self.source, note.span.start))
6643            .filter_map(|label| label.parse().ok())
6644            .collect();
6645        taken.extend(
6646            self.nodes()
6647                .into_iter()
6648                .filter(|n| n.kind == Kind::FootnoteReference)
6649                .filter_map(|n| wysiwyg::footnote_reference_label(&self.source, n.span))
6650                .filter_map(|label| label.parse::<u32>().ok()),
6651        );
6652        (1..).find(|n| !taken.contains(n)).unwrap_or(1).to_string()
6653    }
6654
6655    /// The footnote reference under the caret, resolved to the note it names.
6656    /// [`footnote_at`](Self::footnote_at) at the caret's offset.
6657    pub fn footnote_at_caret(&mut self) -> Option<FootnoteRef> {
6658        self.footnote_at(self.caret)
6659    }
6660
6661    /// The footnote reference at `off`, resolved to the note it names — what a
6662    /// frontend shows when a reader activates a `[^1]`.
6663    ///
6664    /// A reference is not a link node, so
6665    /// [`link_destination_at_caret`](Self::link_destination_at_caret) does not
6666    /// (and should not) answer for one: a link names a destination to leave for,
6667    /// a reference names a note that is already in this document. Following one
6668    /// is a move within the page, which is why this hands back an `offset`
6669    /// rather than something to open.
6670    ///
6671    /// Offset-based rather than caret-only because the gesture that wants this
6672    /// most is the one that must not move the caret: a pointer hovering a `[1]`
6673    /// asks what note it names without disturbing where the reader was typing.
6674    /// The caret is just the offset a click already placed —
6675    /// [`footnote_at_caret`](Self::footnote_at_caret) passes it.
6676    ///
6677    /// `None` when `off` stands in no reference. A reference whose note the
6678    /// document never defines is *not* `None` — it answers with the label it
6679    /// looked for and no text, which is what lets a frontend say so instead of
6680    /// silently doing nothing.
6681    pub fn footnote_at(&mut self, off: usize) -> Option<FootnoteRef> {
6682        // Innermost-wins by latest start, the rule its link sibling uses.
6683        let span = self
6684            .nodes()
6685            .into_iter()
6686            .filter(|n| n.kind == Kind::FootnoteReference)
6687            .filter(|n| n.span.start <= off && off < n.span.end)
6688            .max_by_key(|n| n.span.start)?
6689            .span;
6690        let label = wysiwyg::footnote_reference_label(&self.source, span)?.to_string();
6691
6692        // The note itself. Definitions are roots beside `doc` rather than
6693        // children of it, so they're asked for directly — see
6694        // `wysiwyg::footnote_definitions`.
6695        let note = wysiwyg::footnote_definitions(&mut self.editor)
6696            .into_iter()
6697            .find(|m| wysiwyg::footnote_label(&self.source, m.span.start) == Some(&label));
6698        let Some(note) = note else {
6699            return Some(FootnoteRef {
6700                label,
6701                text: None,
6702                offset: None,
6703                end: None,
6704            });
6705        };
6706        let body = wysiwyg::footnote_body_span(&self.source, note.span.clone());
6707        Some(FootnoteRef {
6708            label,
6709            text: body
6710                .clone()
6711                .and_then(|b| self.source.get(b))
6712                .map(str::to_string),
6713            // The body's start, not the definition's — see `FootnoteRef::offset`.
6714            offset: body.clone().map(|b| b.start),
6715            end: body.map(|b| b.end),
6716        })
6717    }
6718
6719    /// The footnote *definition* the caret stands in, and where the reference
6720    /// that names it is. [`footnote_definition_at`](Self::footnote_definition_at)
6721    /// at the caret's offset.
6722    pub fn footnote_definition_at_caret(&mut self) -> Option<FootnoteDef> {
6723        self.footnote_definition_at(self.caret)
6724    }
6725
6726    /// The footnote definition spanning `off`, and where the reference that
6727    /// names it is — the return leg of [`footnote_at`](Self::footnote_at).
6728    ///
6729    /// The mirror image, deliberately: the same gesture that takes a reader from
6730    /// `[1]` down to the note takes them from the note back up to `[1]`, so
6731    /// following a footnote is a round trip rather than a fall. It needs no
6732    /// memory of how the reader arrived — the document says where the reference
6733    /// is — which is what makes it work for a reader who scrolled to the notes
6734    /// themselves, and what keeps it right after an edit moves either end.
6735    ///
6736    /// `None` when `off` stands in no definition. A definition nothing cites is
6737    /// *not* `None`, for [`FootnoteRef`]'s reason in reverse: it answers with
6738    /// its label and no offset, so a frontend can say "nothing refers to this"
6739    /// rather than offer a jump that goes nowhere.
6740    pub fn footnote_definition_at(&mut self, off: usize) -> Option<FootnoteDef> {
6741        // Definitions are roots beside `doc`, so `nodes()` — which walks the
6742        // document body — never reports one. They're asked for directly, the way
6743        // `footnote_at` asks for the note it resolves to.
6744        //
6745        // Closed at the end, unlike the half-open test its neighbours use. A
6746        // definition's span stops at its last content byte — the newline ending
6747        // the line is outside it — so `span.end` is the caret stop at the end of
6748        // the note's own row, not the first byte of anything after. Excluding it
6749        // meant the one caret an author is guaranteed to have, the one left
6750        // sitting at the end of the note they just typed, was in no definition at
6751        // all: writing a note and then asking to go back to its reference
6752        // answered nothing. Two definitions in a row still can't both match —
6753        // there is a blank line between them — and `max_by_key` decides anyway.
6754        let note = wysiwyg::footnote_definitions(&mut self.editor)
6755            .into_iter()
6756            .filter(|m| m.span.start <= off && off <= m.span.end)
6757            .max_by_key(|m| m.span.start)?;
6758        let label = wysiwyg::footnote_label(&self.source, note.span.start)?.to_string();
6759
6760        // The earliest reference carrying this label. `min` rather than a `find`,
6761        // because `nodes()` reports a flattened walk whose order is twig's
6762        // business, not document order. Bound first: the walk needs `&mut self`
6763        // and reading the labels back out needs `&self.source`.
6764        let nodes = self.nodes();
6765        let offset = nodes
6766            .into_iter()
6767            .filter(|n| n.kind == Kind::FootnoteReference)
6768            .filter(|n| {
6769                wysiwyg::footnote_reference_label(&self.source, n.span.clone()) == Some(&*label)
6770            })
6771            // Past the `[^`, onto the label — see `FootnoteDef::offset`.
6772            .map(|n| n.span.start + 2)
6773            .min();
6774        Some(FootnoteDef { label, offset })
6775    }
6776
6777    /// The destination of the image under the caret — what an image prompt shows
6778    /// so editing an existing image starts from its current URL instead of blank,
6779    /// the image analogue of [`link_destination_at_caret`](Self::link_destination_at_caret).
6780    /// `None` when the caret stands in no image. A caret resting just after a
6781    /// block image (its trailing stop) is still "in" it — the half-open span test
6782    /// excludes that offset, which is the intended precision: past the image is
6783    /// past it.
6784    pub fn image_destination_at_caret(&mut self) -> Option<String> {
6785        let off = self.caret;
6786        self.nodes()
6787            .into_iter()
6788            .filter(|n| n.kind == Kind::Image)
6789            .filter(|n| n.span.start <= off && off < n.span.end)
6790            .max_by_key(|n| n.span.start)
6791            .and_then(|n| n.destination)
6792    }
6793
6794    /// The language of the fenced code block the caret stands in — what a
6795    /// language prompt shows so editing it starts from the current value rather
6796    /// than blank. `None` when the caret is in no code block, or in one whose
6797    /// fence carries no language (or an indented block, which has no fence).
6798    pub fn code_language_at_caret(&mut self) -> Option<String> {
6799        let start = self.code_block_start_at_caret()?;
6800        wysiwyg::code_language(&self.source, start)
6801    }
6802
6803    /// Whether the caret stands in a fenced code block — the one a language
6804    /// prompt could edit. A frontend gates its "set language" affordance on this
6805    /// (an indented block, which can't carry a language, reports `false`).
6806    pub fn caret_in_fenced_code(&mut self) -> bool {
6807        self.code_block_start_at_caret()
6808            .is_some_and(|start| wysiwyg::code_info_span(&self.source, start).is_some())
6809    }
6810
6811    /// Set (or clear, with `""`) the language of the fenced code block the caret
6812    /// is in — the prompt's confirm. A no-op when the caret is in no fenced
6813    /// block, and a reported error for a language the format's fence cannot
6814    /// carry.
6815    ///
6816    /// twig rewrites the info string, so the fence's own width — measured
6817    /// against a body neither side touches — is kept, and a language holding a
6818    /// space, a line end or the fence character is refused rather than written
6819    /// out to reparse as something else. Leaf used to splice over the info span
6820    /// itself and `trim()` the input, which handled the one bad case it had
6821    /// thought of.
6822    pub fn set_code_language(&mut self, lang: &str) {
6823        // The read-only gate — this door reaches twig without the splice.
6824        if self.read_only {
6825            return;
6826        }
6827        if self.refuse_unsupported("code language", Gesture::SetCodeLanguage) {
6828            return;
6829        }
6830        if self.code_block_start_at_caret().is_none() {
6831            return;
6832        }
6833        let lang = lang.trim();
6834        // `None` clears the info string; `Some("")` asks for an empty one. Both
6835        // write a bare fence, and the prompt's empty value means "clear".
6836        let want = (!lang.is_empty()).then_some(lang);
6837        self.record_caret();
6838        match self.editor.set_code_language(self.caret, want) {
6839            Ok(_) => {
6840                self.last_edit_kind = None;
6841                self.refresh();
6842                self.anchor = None;
6843                self.dirty = self.source != self.clean_source;
6844                self.status = None;
6845                self.clamp_caret();
6846                self.record_caret();
6847            }
6848            Err(e) => self.status = Some(format!("code language: {e}")),
6849        }
6850    }
6851
6852    /// The `span.start` of the code block covering the caret — the anchor
6853    /// [`wysiwyg::code_info_span`] reads the fence from. `None` when the caret is
6854    /// in none.
6855    fn code_block_start_at_caret(&mut self) -> Option<usize> {
6856        let off = self.caret;
6857        self.nodes()
6858            .into_iter()
6859            .filter(|n| n.kind == Kind::CodeBlock && n.span.start <= off && off <= n.span.end)
6860            .max_by_key(|n| n.span.start)
6861            .map(|n| n.span.start)
6862    }
6863
6864    /// The source range of the text inside the link covering `off` — what sits
6865    /// between its `[` and `]`. `None` when twig reports no link there.
6866    fn link_text_span(&mut self, off: usize) -> Option<std::ops::Range<usize>> {
6867        self.nodes()
6868            .into_iter()
6869            // Two links can touch (`[a](x)[b](y)`), and then one's `span.end` is
6870            // the other's `span.start`; the link that starts latest at or before
6871            // `off` is the one `off` is actually in.
6872            .filter(|n| n.kind == Kind::Link && n.span.start <= off && off < n.span.end)
6873            .max_by_key(|n| n.span.start)
6874            .and_then(|n| n.content_span)
6875    }
6876
6877    // ── undo / redo ───────────────────────────────────────────────────────────
6878    // twig owns the history of *bytes* (it owns the buffer) and now carries the
6879    // caret through it too: `record_caret` stashes each state's caret in twig's
6880    // opaque per-step blob, and undo/redo hand it back with the source they
6881    // restore. So leaf keeps no history of its own — no parallel stacks to march
6882    // in lockstep and silently drift out of it.
6883
6884    /// Undo the last edit step (⌘Z / ^Z), putting the caret and selection back
6885    /// where they were when that step began.
6886    pub fn undo(&mut self) {
6887        if self.read_only {
6888            return;
6889        }
6890        let (undone, redoable) = (self.undo_steps, self.redo_steps);
6891        match self.editor.undo() {
6892            Ok(Some(change)) => {
6893                self.after_history(change);
6894                // `refresh` counted the restore as an edit; it was a step back.
6895                self.undo_steps = undone.saturating_sub(1);
6896                self.redo_steps = redoable + 1;
6897            }
6898            Ok(None) => {
6899                self.undo_steps = 0;
6900                self.status = Some("nothing to undo".into());
6901            }
6902            Err(e) => self.status = Some(format!("undo: {e}")),
6903        }
6904    }
6905
6906    /// Redo the last undone edit step (⇧⌘Z / ^Y), putting the caret and
6907    /// selection back where that step originally left them.
6908    pub fn redo(&mut self) {
6909        if self.read_only {
6910            return;
6911        }
6912        let (undone, redoable) = (self.undo_steps, self.redo_steps);
6913        match self.editor.redo() {
6914            Ok(Some(change)) => {
6915                self.after_history(change);
6916                // `refresh` counted the restore as an edit; it was a step forward.
6917                self.undo_steps = undone + 1;
6918                self.redo_steps = redoable.saturating_sub(1);
6919            }
6920            Ok(None) => {
6921                self.redo_steps = 0;
6922                self.status = Some("nothing to redo".into());
6923            }
6924            Err(e) => self.status = Some(format!("redo: {e}")),
6925        }
6926    }
6927
6928    /// Refresh the cached source and put the caret back where the step being
6929    /// undone/redone had it, clearing any active run.
6930    ///
6931    /// The caret comes from twig's blob for the restored state (what
6932    /// `record_caret` stored). `change` is only the fallback for a state with no
6933    /// blob — a caret at the end of the restored text, which is where this always
6934    /// landed before the blobs were kept. It is the edit site, not where the user
6935    /// was standing, so it's a floor and not the behaviour: undoing should hand
6936    /// back the document *and* the place you were working, which for an edit made
6937    /// anywhere but under the caret are two different places.
6938    fn after_history(&mut self, change: Change) {
6939        self.refresh();
6940        match self
6941            .editor
6942            .caret_blob()
6943            .ok()
6944            .and_then(|b| CaretState::from_blob(&b))
6945        {
6946            Some(state) => {
6947                self.caret = state.caret.min(self.source.len());
6948                self.anchor = state.anchor.map(|a| a.min(self.source.len()));
6949            }
6950            None => {
6951                self.caret = change.new.end.min(self.source.len());
6952                self.anchor = None;
6953            }
6954        }
6955        self.goal_col = None;
6956        self.last_edit_kind = None;
6957        self.dirty = self.source != self.clean_source;
6958        self.status = None;
6959        self.clamp_caret();
6960    }
6961
6962    // ── the file ──────────────────────────────────────────────────────────────
6963
6964    #[cfg(feature = "fs")]
6965    pub fn save(&mut self) {
6966        if self.is_untitled() {
6967            // No path to write and no name to invent: ⌘S on an untitled document
6968            // is a Save As, and only a frontend has a picker to ask with. Say so
6969            // rather than failing at the filesystem with an empty path.
6970            self.status = Some("untitled — save as…".into());
6971            return;
6972        }
6973        let path = self.path.clone();
6974        if self.write(&path) {
6975            self.mark_saved();
6976        }
6977    }
6978
6979    /// Save As: write the document to `path` and *move* it there — `self.path`
6980    /// becomes `path`, and every later [`Doc::save`] writes the new file. That's
6981    /// what Save As means; a copy would leave the user editing a document whose
6982    /// name is no longer where their keystrokes go.
6983    ///
6984    /// The move only happens if the bytes actually landed. A failed write leaves
6985    /// the path, `dirty`, and the disk watermark exactly as they were, with the
6986    /// same `save failed: …` status a failed [`Doc::save`] sets — the document
6987    /// must never come away believing it was saved.
6988    ///
6989    /// An existing `path` is overwritten, and the caller is the one that knows
6990    /// whether to ask first: a Save As picker has already run that prompt, and a
6991    /// second confirmation from down here would be the same question twice.
6992    ///
6993    /// `format` does **not** follow the new extension. The buffer is parsed as
6994    /// the format it was opened with, and re-reading it as another one is a
6995    /// conversion — a different, lossy operation that would throw away the undo
6996    /// history — not a rename. So `notes.md` saved as `notes.dj` holds Markdown
6997    /// in a `.dj` file, and `format_name()` keeps honestly saying `markdown`
6998    /// until it's reopened.
6999    #[cfg(feature = "fs")]
7000    pub fn save_as(&mut self, path: PathBuf) {
7001        if !self.write(&path) {
7002            return;
7003        }
7004        self.path = path;
7005        self.mark_saved();
7006    }
7007
7008    /// Put `source` on disk at `path`, reporting whether it got there. The one
7009    /// place leaf writes a document, so a save and a Save As can't disagree
7010    /// about what a failure looks like.
7011    #[cfg(feature = "fs")]
7012    fn write(&mut self, path: &Path) -> bool {
7013        match std::fs::write(path, self.source.as_bytes()) {
7014            Ok(()) => true,
7015            Err(e) => {
7016                self.status = Some(format!("save failed: {e}"));
7017                false
7018            }
7019        }
7020    }
7021
7022    /// Re-base the document's saved watermark to the current bytes: clears
7023    /// `dirty`, records `source` as the new clean state (so undoing back to here
7024    /// clears the flag again), and re-stamps the on-disk hash.
7025    ///
7026    /// [`Doc::save`]/[`Doc::save_as`] call this after a write lands. It is also
7027    /// the hook a **filesystem-free host** calls itself once it has persisted
7028    /// [`Doc::source`] its own way (a browser download, `localStorage`, a backend
7029    /// `PUT`) — which is why it is public and touches no filesystem: the bytes
7030    /// are already where that host wants them, and this just tells the model they
7031    /// are safe.
7032    pub fn mark_saved(&mut self) {
7033        self.clean_source = self.source.clone();
7034        self.dirty = false;
7035        // The bytes on disk are now ours, so this is the new watermark: without
7036        // re-stamping it, every save would report its own work as an external
7037        // change forever after.
7038        self.disk_hash = Some(hash_bytes(self.source.as_bytes()));
7039        self.status = Some(format!("saved {}", self.file_name()));
7040    }
7041
7042    /// What the file looks like now against the bytes leaf last read or wrote.
7043    ///
7044    /// Reads the file and hashes it (see `disk_hash` for why it isn't an mtime),
7045    /// so this is a filesystem round-trip, not a per-frame question — ask it
7046    /// when a window regains focus, on a timer, or before a save.
7047    ///
7048    /// This *only* reports the file. Whether the document also has unsaved edits
7049    /// is `dirty`, and the interesting case is the conjunction: `dirty` plus
7050    /// [`DiskState::Changed`] means a save overwrites someone's work and a
7051    /// [`Doc::reload`] discards the user's. leaf-core deliberately won't choose —
7052    /// it has no way to ask — so it hands a frontend both halves and lets it put
7053    /// the question to the person who can answer it.
7054    #[cfg(feature = "fs")]
7055    pub fn disk_state(&self) -> DiskState {
7056        let Some(want) = self.disk_hash else {
7057            return DiskState::Untitled;
7058        };
7059        match std::fs::read(&self.path) {
7060            Ok(bytes) if hash_bytes(&bytes) == want => DiskState::Unchanged,
7061            Ok(_) => DiskState::Changed,
7062            Err(e) if e.kind() == std::io::ErrorKind::NotFound => DiskState::Missing,
7063            Err(_) => DiskState::Unreadable,
7064        }
7065    }
7066
7067    /// Re-read the file and replace the document with what's there — the other
7068    /// answer to a [`DiskState::Changed`].
7069    ///
7070    /// **Discards unsaved changes, unconditionally.** It doesn't check `dirty`
7071    /// first: a frontend that wants to protect unsaved work asks (`dirty` +
7072    /// [`Doc::disk_state`]) *before* calling this, and one reloading a clean
7073    /// document shouldn't have to argue with a guard.
7074    ///
7075    /// **The undo history survives, and the reload is one step in it.** The
7076    /// whole buffer is spliced with the file's bytes through the same door every
7077    /// other edit goes through, as an [`EditKind::Other`] that coalesces with
7078    /// nothing on either side — so ^Z after a formatter or a `git checkout` has
7079    /// swapped the document out from under a reader gives them back what they
7080    /// were looking at, marked dirty, and ^Z again carries on into whatever they
7081    /// had done before it. This used to build a fresh parse and drop the stack,
7082    /// on the reasoning that twig's history belongs to the buffer and these are
7083    /// different bytes; that is true of *rebasing* a step onto them and not of
7084    /// recording the swap itself as one, which is all this is. A splice twig
7085    /// won't take falls back to the fresh parse, and only that path still costs
7086    /// the history.
7087    ///
7088    /// The caret keeps its byte offset, clamped to the new length; the selection
7089    /// is dropped. Anything cleverer would be a lie: leaf doesn't know how the
7090    /// file changed, so it can't know where the caret "still" is. Clamping keeps
7091    /// it where the user left it in the common case (a change further down the
7092    /// file, or none in the text they're sitting in), and never puts it
7093    /// somewhere invalid. A selection has two such offsets and no such excuse —
7094    /// silently reinterpreting one over changed bytes would arm the *next*
7095    /// keystroke to delete something the user never selected.
7096    ///
7097    /// Nothing is touched unless the whole reload succeeds; a failure leaves the
7098    /// document alone with a status.
7099    #[cfg(feature = "fs")]
7100    pub fn reload(&mut self) {
7101        if self.is_untitled() {
7102            self.status = Some("no file to reload".into());
7103            return;
7104        }
7105        let bytes = match std::fs::read(&self.path) {
7106            Ok(b) => b,
7107            Err(e) => {
7108                self.status = Some(format!("reload failed: {e}"));
7109                return;
7110            }
7111        };
7112        let Ok(source) = String::from_utf8(bytes) else {
7113            self.status = Some("reload failed: file is not UTF-8".into());
7114            return;
7115        };
7116        // Already these bytes — someone saved a file back unchanged, or leaf's
7117        // own write is being read back. Re-baseline against it and stop: a
7118        // splice of the text onto itself would put an undo step on the stack for
7119        // something nobody did.
7120        if source == self.source {
7121            self.disk_hash = Some(hash_bytes(source.as_bytes()));
7122            self.clean_source = source;
7123            self.dirty = false;
7124            self.status = Some(format!("reloaded {}", self.file_name()));
7125            return;
7126        }
7127        let caret = self.caret;
7128        // The pre-reload caret, so undoing the swap puts it back where the
7129        // reader was standing — the same bracketing `splice_exact` does.
7130        self.record_caret();
7131        if self
7132            .editor
7133            .edit_range(0, self.source.len(), &source)
7134            .is_ok()
7135        {
7136            self.refresh();
7137        } else {
7138            // twig wouldn't take the splice. Start over from the bytes, which is
7139            // what this always did, and is the one path that still costs the
7140            // history — `format` is the format this document *is*, not what the
7141            // (unchanged) name now says, see `save_as`.
7142            match new_editor(source.as_bytes(), self.format) {
7143                Ok(editor) => {
7144                    self.editor = editor;
7145                    self.source = source.clone();
7146                    // Not going through `refresh`, so the revision has to move
7147                    // here or every frontend keeps painting the old file from
7148                    // cache.
7149                    self.revision += 1;
7150                }
7151                Err(e) => {
7152                    self.status = Some(format!("reload failed: {e}"));
7153                    return;
7154                }
7155            }
7156        }
7157        self.disk_hash = Some(hash_bytes(source.as_bytes()));
7158        self.clean_source = self.source.clone();
7159        self.caret = caret.min(self.source.len());
7160        self.anchor = None;
7161        self.goal_col = None;
7162        self.last_edit_kind = None;
7163        self.dirty = false;
7164        self.status = Some(format!("reloaded {}", self.file_name()));
7165        self.clamp_caret();
7166        // And the post-reload caret, so a redo restores it.
7167        self.record_caret();
7168    }
7169
7170    /// Re-read the source from twig after it has changed the document. The one
7171    /// funnel every edit, undo, and redo comes through — so it's where the
7172    /// revision moves, and anything cached against the text dies here.
7173    fn refresh(&mut self) {
7174        if let Ok(s) = self.editor.source_str() {
7175            self.source = s;
7176        }
7177        self.revision += 1;
7178        // An edit is a step onto the history and the end of anything undone;
7179        // `undo`/`redo` come through here too and correct this after.
7180        self.undo_steps += 1;
7181        self.redo_steps = 0;
7182        self.clamp_caret();
7183    }
7184
7185    /// Whether [`undo`](Self::undo) has a step to take back — for a native
7186    /// Edit menu to enable its item by. See the note on `undo_steps` for what
7187    /// "has" means here.
7188    pub fn can_undo(&self) -> bool {
7189        !self.read_only && self.undo_steps > 0
7190    }
7191
7192    /// Whether [`redo`](Self::redo) has an undone step to restore.
7193    pub fn can_redo(&self) -> bool {
7194        !self.read_only && self.redo_steps > 0
7195    }
7196
7197    // ── caret movement ─────────────────────────────────────────────────────────
7198    // `extend` grows the selection (Shift+motion): it pins the anchor on the
7199    // first extended step and moves only the caret; an un-extended motion drops
7200    // the selection.
7201
7202    /// Place the caret at byte `offset` (clamped to a char boundary), extending
7203    /// the selection when `extend` is set. The public form of `move_to`, for a
7204    /// frontend that hit-tests pixels straight to a source offset.
7205    pub fn place_caret(&mut self, offset: usize, extend: bool) {
7206        self.goal_col = None;
7207        let before = self.caret;
7208        // A pixel hit-test can land between the visible caret stops — in the
7209        // blank gap a paragraph break is drawn with, or inside a hidden delimiter.
7210        // Snap to the nearest real stop so the caret can't come to rest where it
7211        // would draw in one place and type in another. The `(row, col)` click
7212        // path (`click`) already snaps this way through `offset_of_pos`; the
7213        // source view reaches every byte, so it snaps to nothing.
7214        let target = match self.view {
7215            View::Wysiwyg => self.vmap.snap_to_stop(offset.min(self.source.len())),
7216            // The source view reaches every byte, so there is no stop to snap
7217            // to — but "every byte" still means every *character* boundary. A
7218            // caret resting inside a multi-byte character draws nowhere real
7219            // and panics the next time anything slices there.
7220            View::Source => self.char_boundary_at_or_before(offset),
7221        };
7222        self.move_to(target, extend);
7223        self.clamp_caret();
7224        self.debug_assert_on_a_stop(before);
7225    }
7226
7227    /// Select the whole document (⌘A / Ctrl+A) — everything reachable in the
7228    /// active view, so in WYSIWYG it starts below hidden frontmatter (copy won't
7229    /// grab the metadata) while the source view still selects the literal whole.
7230    pub fn select_all(&mut self) {
7231        self.anchor = Some(self.caret_floor());
7232        self.caret = self.source.len();
7233        self.goal_col = None;
7234        self.last_edit_kind = None;
7235        self.status = None;
7236    }
7237
7238    /// Select the word (or whitespace / punctuation run) at `offset` — the
7239    /// double-click gesture. Anchors on the run's start with the caret at its
7240    /// end so a following Shift-motion extends from the far edge.
7241    pub fn select_word_at(&mut self, offset: usize) {
7242        let (s, e) = word_range_at(&self.source, offset.min(self.source.len()));
7243        self.anchor = Some(s);
7244        self.caret = e;
7245        self.goal_col = None;
7246        self.last_edit_kind = None;
7247        self.status = None;
7248        self.clamp_caret();
7249    }
7250
7251    /// Select the whole enclosing text block (paragraph, heading, list item's
7252    /// text…) at `offset` — the triple-click gesture. Reads the range straight
7253    /// from the AST (twig's `content_span`), so it selects the entire *logical*
7254    /// paragraph even when that paragraph soft-wraps across several visual rows —
7255    /// where a visual-row-based select breaks down, because one source offset at
7256    /// a wrap boundary belongs to two rows at once.
7257    pub fn select_block_at(&mut self, offset: usize) {
7258        let off = offset.min(self.source.len());
7259        let range = self
7260            .editor
7261            .ancestors_at(off)
7262            .ok()
7263            .and_then(|chain| {
7264                // Ancestors run root → deepest; the deepest node that is neither
7265                // an inline span nor a multi-block container is the text block
7266                // the caret sits in (a paragraph, a heading, a code block…).
7267                chain
7268                    .into_iter()
7269                    .rev()
7270                    .find(|m| !wysiwyg::is_inline_kind(&m.kind) && !is_block_container(&m.kind))
7271                    .map(|m| m.content_span.unwrap_or(m.span))
7272            })
7273            .unwrap_or_else(|| source_line_range(&self.source, off));
7274        self.anchor = Some(range.start.min(self.source.len()));
7275        self.caret = range.end.min(self.source.len());
7276        self.goal_col = None;
7277        self.last_edit_kind = None;
7278        self.status = None;
7279        self.clamp_caret();
7280    }
7281
7282    /// Select the exact source range `[start, end)` — anchor at `start`, caret
7283    /// at `end` — without snapping either end to a visible caret stop.
7284    ///
7285    /// The one caret verb that takes a range it was *handed* rather than one it
7286    /// worked out, for a host that already knows the bytes it means: a search
7287    /// hit, an annotation's footprint, a quote re-anchored through
7288    /// [`Doc::selection_quote`]. [`place_caret`](Self::place_caret) is the
7289    /// wrong tool for that, and not by a little — it snaps to the nearest
7290    /// *visible* stop, and where a range butts up against a hidden delimiter
7291    /// the nearest stop is the one before it, so selecting the "needle" of
7292    /// `**needle**` comes back with "needl" and an edit against it strands the
7293    /// "e".
7294    ///
7295    /// What `place_caret` does that is bookkeeping rather than snapping still
7296    /// happens here, because a host handing in a range is not asking to opt out
7297    /// of the invariants:
7298    ///
7299    /// - both ends are clamped into the document and up to
7300    ///   [`caret_floor`](Self::caret_floor) — in WYSIWYG the leading
7301    ///   frontmatter is hidden, and a caret parked in it draws nowhere and
7302    ///   types into the metadata;
7303    /// - both land on character boundaries, so nothing slices a `é` in half;
7304    /// - the sticky vertical goal column is dropped, and any armed inline mark
7305    ///   disarmed, since a range from outside inherits neither.
7306    ///
7307    /// An empty range is a caret rather than a selection —
7308    /// [`selection`](Self::selection) reports `None` for it, as it does for any
7309    /// anchor that has met the caret.
7310    pub fn select_range(&mut self, start: usize, end: usize) {
7311        let floor = self.caret_floor();
7312        let anchor = self.char_boundary_at_or_before(start.clamp(floor, self.source.len()));
7313        let caret = self.char_boundary_at_or_before(end.clamp(floor, self.source.len()));
7314        self.anchor = Some(anchor);
7315        self.caret = caret;
7316        self.goal_col = None;
7317        self.status = None;
7318        self.last_edit_kind = None;
7319        self.clear_pending();
7320    }
7321
7322    /// `offset` itself if it is a character boundary, else the boundary before
7323    /// it. An offset that isn't one draws nowhere real and panics the next time
7324    /// anything slices there.
7325    fn char_boundary_at_or_before(&self, offset: usize) -> usize {
7326        let mut o = offset.min(self.source.len());
7327        while o > 0 && !self.source.is_char_boundary(o) {
7328            o -= 1;
7329        }
7330        o
7331    }
7332
7333    /// The lowest source offset the caret may occupy in the active view. In
7334    /// WYSIWYG, leading frontmatter is hidden and unreachable, so the floor is
7335    /// the first rendered offset; the source view reaches everything, so it's 0.
7336    fn caret_floor(&self) -> usize {
7337        match self.view {
7338            View::Wysiwyg => self.vmap.content_start.min(self.source.len()),
7339            View::Source => 0,
7340        }
7341    }
7342
7343    /// Land in a table cell with its whole content selected — the anchor at the
7344    /// cell's start, the caret at its end — so a Tab/Return hop into a cell reads
7345    /// like tabbing into a form field: the text comes up selected, so typing
7346    /// replaces it and an arrow collapses to an edge. An empty cell (`start ==
7347    /// end`) collapses to a plain caret home (an empty selection is no selection).
7348    fn select_cell(&mut self, start: usize, end: usize) {
7349        self.select_range(start, end);
7350    }
7351
7352    fn move_to(&mut self, offset: usize, extend: bool) {
7353        if extend {
7354            if self.anchor.is_none() {
7355                self.anchor = Some(self.caret);
7356            }
7357        } else {
7358            self.anchor = None;
7359        }
7360        self.caret = offset.min(self.source.len()).max(self.caret_floor());
7361        self.status = None;
7362        // A caret move ends the current typing/deletion run, so the next edit
7363        // starts a fresh undo group rather than coalescing across the gap.
7364        self.last_edit_kind = None;
7365        // Moving away disarms any sticky mark — "start bold" applies only where
7366        // it was asked for, not wherever the caret next lands.
7367        self.clear_pending();
7368    }
7369
7370    // In the source view, motion walks source bytes / source lines. In the
7371    // WYSIWYG view it walks the rendered glyph grid (the visual map), which is
7372    // what steps the caret cleanly over hidden delimiters.
7373
7374    pub fn move_left(&mut self, extend: bool) {
7375        self.goal_col = None;
7376        if !extend && let Some((s, _e)) = self.selection() {
7377            self.move_to(s, false);
7378            return;
7379        }
7380        let target = match self.view {
7381            View::Source => {
7382                if self.caret > 0 {
7383                    prev_boundary(&self.source, self.caret)
7384                } else {
7385                    0
7386                }
7387            }
7388            // Walks caret *stops*, not columns: decoration (a table border, a
7389            // cell's padding) is stepped over in one press, and a hidden
7390            // delimiter never holds the caret up — though the end of a mark's
7391            // content is a stop of its own (`VisualMap::mark_ends`), so
7392            // leaving `**bold**` from past its `**` is a press onto the end of
7393            // the bold and another onto the `d`.
7394            View::Wysiwyg => self
7395                .vmap
7396                .caret_stop_before(self.caret)
7397                .unwrap_or(self.caret),
7398        };
7399        let before = self.caret;
7400        self.move_to(target, extend);
7401        self.debug_assert_on_a_stop(before);
7402    }
7403
7404    pub fn move_right(&mut self, extend: bool) {
7405        self.goal_col = None;
7406        if !extend && let Some((_s, e)) = self.selection() {
7407            self.move_to(e, false);
7408            return;
7409        }
7410        let target = match self.view {
7411            View::Source => {
7412                if self.caret < self.source.len() {
7413                    next_boundary(&self.source, self.caret)
7414                } else {
7415                    self.caret
7416                }
7417            }
7418            View::Wysiwyg => self.vmap.caret_stop_after(self.caret).unwrap_or(self.caret),
7419        };
7420        let before = self.caret;
7421        self.move_to(target, extend);
7422        self.debug_assert_on_a_stop(before);
7423    }
7424
7425    /// Move to the start of the previous word (⌥← / Ctrl+←).
7426    pub fn move_word_left(&mut self, extend: bool) {
7427        self.goal_col = None;
7428        let before = self.caret;
7429        let target = self.word_left_from(self.caret);
7430        self.move_to(target, extend);
7431        self.debug_assert_on_a_stop(before);
7432    }
7433
7434    /// Move to the end of the next word (⌥→ / Ctrl+→).
7435    pub fn move_word_right(&mut self, extend: bool) {
7436        self.goal_col = None;
7437        let before = self.caret;
7438        let target = self.word_right_from(self.caret);
7439        self.move_to(target, extend);
7440        self.debug_assert_on_a_stop(before);
7441    }
7442
7443    // Word boundaries are found in the space the *view* is in. The source view
7444    // walks the source, because there the source is what's rendered. WYSIWYG
7445    // walks the rendered text instead: `**` is invisible to the user, so it has
7446    // to be invisible to word motion too — a caret parked inside one draws in
7447    // the column after `bold` and types two bytes earlier, and a word-delete
7448    // that stops there shreds the markup into `a ** c`.
7449
7450    /// The word boundary to the left of `off` in the active view's space.
7451    fn word_left_from(&self, off: usize) -> usize {
7452        match self.view {
7453            View::Source => prev_word(&self.source, off),
7454            View::Wysiwyg => self.glyph_word_left(off),
7455        }
7456    }
7457
7458    /// The word boundary to the right of `off` in the active view's space.
7459    fn word_right_from(&self, off: usize) -> usize {
7460        match self.view {
7461            View::Source => next_word(&self.source, off),
7462            View::Wysiwyg => self.glyph_word_right(off),
7463        }
7464    }
7465
7466    /// The character class of the glyph drawn at stop `off`.
7467    ///
7468    /// Read from the source, because a stop points at the source byte its glyph
7469    /// came from — the source *is* where the rendered character is written. What
7470    /// makes the walk glyph space rather than source space is that it only ever
7471    /// visits stops, and the hidden bytes between them have none.
7472    fn class_at(&self, off: usize) -> Class {
7473        self.source
7474            .get(off..)
7475            .and_then(|s| s.chars().next())
7476            .map_or(Class::Space, classify)
7477    }
7478
7479    /// [`next_word`] in glyph space: skip any leading separators, then consume
7480    /// the following word run, with the stop table standing in for the source's
7481    /// characters.
7482    fn glyph_word_right(&self, from: usize) -> usize {
7483        let Some(mut off) = self.vmap.stop_at_or_after(from) else {
7484            return from;
7485        };
7486        let mut in_word = false;
7487        loop {
7488            match self.class_at(off) {
7489                Class::Word => in_word = true,
7490                _ if in_word => return off,
7491                _ => {}
7492            }
7493            match self.vmap.stop_after(off) {
7494                Some(next) => off = next,
7495                None => return off,
7496            }
7497        }
7498    }
7499
7500    /// [`prev_word`] in glyph space: skip separators walking left, then consume
7501    /// the preceding word run.
7502    fn glyph_word_left(&self, from: usize) -> usize {
7503        let Some(mut off) = self.vmap.stop_at_or_before(from) else {
7504            return from;
7505        };
7506        let mut in_word = false;
7507        while let Some(prev) = self.vmap.stop_before(off) {
7508            match self.class_at(prev) {
7509                Class::Word => in_word = true,
7510                _ if in_word => return off,
7511                _ => {}
7512            }
7513            off = prev;
7514        }
7515        off
7516    }
7517
7518    /// After a motion that walks the visual map, the caret must be *on* the map.
7519    /// A stop is the only offset where the caret draws and edits in the same
7520    /// place, and it's the invariant both a caret parked inside an emoji and one
7521    /// parked inside a `**` were quietly breaking.
7522    ///
7523    /// Only when the caret actually moved: a walk with nowhere to go leaves it
7524    /// where it was, which is wherever the floor or a frontend put it rather
7525    /// than somewhere this motion chose.
7526    fn debug_assert_on_a_stop(&self, before: usize) {
7527        debug_assert!(
7528            self.view != View::Wysiwyg
7529                || self.vmap.num_rows() == 0
7530                || self.caret == before
7531                || self.vmap.is_stop(self.caret),
7532            "motion left the caret at {}, which is not a caret stop: it would draw in \
7533             one place and type in another",
7534            self.caret
7535        );
7536    }
7537
7538    // Up and Down run off the ends of the document rather than stopping dead at
7539    // them: Up from the first row lands at the document's start, Down from the
7540    // last at its end. That's Cocoa's rule (`moveUp:`/`moveDown:` past the edge
7541    // are `moveToBeginningOfDocument:`/`moveToEndOfDocument:`), and holding ↓
7542    // reaching the end of the text is what a reader means by it.
7543    //
7544    // The views used to disagree here by accident rather than by decision: the
7545    // source view fell into the edge behaviour through `row_col_to_offset`
7546    // clamping an out-of-range row to the end of the string, while WYSIWYG had
7547    // no row below to walk to and did nothing at all. They share the rule now,
7548    // each in its own space — the source view reaches every byte, WYSIWYG only
7549    // the offsets it draws.
7550
7551    pub fn move_up(&mut self, extend: bool) {
7552        let (row, col) = self.caret_pos();
7553        let goal = self.goal_col.unwrap_or(col);
7554        let target = match self.view {
7555            View::Source => match row.checked_sub(1) {
7556                Some(r) => row_col_to_offset(&self.source, r, goal),
7557                None => self.reachable_start(),
7558            },
7559            // A table's border rules are drawn but hold no caret, so Up steps
7560            // over them to the row that does.
7561            View::Wysiwyg => match self.vmap.navigable_above(row) {
7562                Some(r) => self.row_target(r, goal),
7563                None => self.reachable_start(),
7564            },
7565        };
7566        self.step_vertical(target, goal, extend);
7567    }
7568
7569    pub fn move_down(&mut self, extend: bool) {
7570        let (row, col) = self.caret_pos();
7571        let goal = self.goal_col.unwrap_or(col);
7572        let target = match self.view {
7573            View::Source => match self.source_row_below(row) {
7574                Some(r) => row_col_to_offset(&self.source, r, goal),
7575                None => self.reachable_end(),
7576            },
7577            View::Wysiwyg => match self.vmap.navigable_below(row) {
7578                Some(r) => self.row_target(r, goal),
7579                None => self.reachable_end(),
7580            },
7581        };
7582        self.step_vertical(target, goal, extend);
7583    }
7584
7585    /// Land a vertical motion at `target`, latching the `goal` column it aimed
7586    /// with so the rest of the run keeps aiming there.
7587    ///
7588    /// A motion with nowhere to go changes *nothing*, the goal column included:
7589    /// the latch used to run before the early return at the top of the document,
7590    /// so an Up that did nothing still armed a column, and the next Down aimed
7591    /// at one the caret had never been in.
7592    fn step_vertical(&mut self, target: usize, goal: usize, extend: bool) {
7593        let before = self.caret;
7594        if target == before {
7595            return;
7596        }
7597        self.goal_col = Some(goal);
7598        self.move_to(target, extend);
7599        self.debug_assert_on_a_stop(before);
7600    }
7601
7602    /// The source line below `row`, or `None` when `row` is the last one. Lines
7603    /// are counted by newline, so a trailing one leaves a real, empty last line
7604    /// for the caret to sit on — the document ends below it, not on it.
7605    fn source_row_below(&self, row: usize) -> Option<usize> {
7606        let last = self.source.bytes().filter(|&b| b == b'\n').count();
7607        (row < last).then_some(row + 1)
7608    }
7609
7610    /// Where a vertical motion aiming at the `goal` column lands on visual row
7611    /// `r`: the column clamped to the row, mapped to its offset, then held
7612    /// inside the row's own [bounds](Self::row_bounds) — a wrapped row's last
7613    /// column belongs to the row below, and a gutter's column 0 points at the
7614    /// block rather than at this row.
7615    fn row_target(&self, r: usize, goal: usize) -> usize {
7616        let (start, end) = self.row_bounds(r);
7617        self.vmap
7618            .offset_of_pos(r, goal.min(self.vmap.row_width(r)))
7619            .clamp(start, end)
7620    }
7621
7622    /// The first and last offsets the caret can reach in the active view.
7623    ///
7624    /// Not the same span in both: the source view shows every byte, so it can
7625    /// reach every byte. WYSIWYG reaches only what it draws — hidden frontmatter
7626    /// sits below the first stop, and a document's trailing newline is drawn
7627    /// nowhere and so sits past the last.
7628    fn reachable_start(&self) -> usize {
7629        match self.view {
7630            View::Source => 0,
7631            View::Wysiwyg => self.vmap.stop_at_or_after(0).unwrap_or(self.caret),
7632        }
7633    }
7634
7635    fn reachable_end(&self) -> usize {
7636        match self.view {
7637            View::Source => self.source.len(),
7638            View::Wysiwyg => self
7639                .vmap
7640                .stop_at_or_before(self.source.len())
7641                .unwrap_or(self.caret),
7642        }
7643    }
7644
7645    /// The `[start, end]` offsets visual row `r` *draws* — everything on it,
7646    /// including the space a soft wrap ate off its end, which is drawn on this
7647    /// row however much the offset past it belongs to the next one.
7648    fn row_span(&self, r: usize) -> (usize, usize) {
7649        let start = self
7650            .vmap
7651            .row_start(r)
7652            .unwrap_or_else(|| self.vmap.offset_of_pos(r, 0));
7653        let end = self.vmap.offset_of_pos(r, self.vmap.row_width(r));
7654        (start.min(end), end)
7655    }
7656
7657    /// [`row_span`](Self::row_span) narrowed to where the caret can stand: a
7658    /// soft wrap's shared offset opens the row below (see `pos_of_offset`), so
7659    /// this row's last position is the one before it — the offset before the
7660    /// space the wrap ate, where the caret draws just past the row's last word
7661    /// and types there too.
7662    ///
7663    /// Aiming at the shared offset instead is what stalled End: it is the row's
7664    /// last *column*, so End pressed on the row reached it and then read back as
7665    /// the row below's start, where a second press ran on to that row's end and
7666    /// the next to the one after — End walking down the paragraph a row a press.
7667    fn row_bounds(&self, r: usize) -> (usize, usize) {
7668        let (start, end) = self.row_span(r);
7669        let wraps = self
7670            .vmap
7671            .navigable_below(r)
7672            .and_then(|b| self.vmap.row_start(b))
7673            .is_some_and(|off| off == end);
7674        match wraps {
7675            true => (start, self.vmap.stop_before(end).unwrap_or(end).max(start)),
7676            false => (start, end),
7677        }
7678    }
7679
7680    /// The `[start, end]` of the line Home and End aim at: the visual row in
7681    /// WYSIWYG, the logical line in the source view. Both ends are caret stops.
7682    ///
7683    /// A soft-wrapped row is a line here, because it is one to the eye and the
7684    /// eye is what these keys are aimed by — a reader pressing End means the end
7685    /// of the line they can see. (`select_block_at` wants the opposite and reads
7686    /// the AST for it: a triple-click grabs the whole paragraph, however many
7687    /// rows it folds into.)
7688    fn line_bounds(&self) -> (usize, usize) {
7689        let (row, _) = self.caret_pos();
7690        match self.view {
7691            View::Source => {
7692                let start = line_start(&self.source, row);
7693                (start, line_end_from(&self.source, start))
7694            }
7695            View::Wysiwyg => self.row_bounds(row),
7696        }
7697    }
7698
7699    /// The same line as [`line_bounds`](Self::line_bounds), as far as it is
7700    /// *drawn* — what a kill takes.
7701    ///
7702    /// The two part only at a soft wrap, over the space the wrap ate: the caret
7703    /// can't stand after it (that offset opens the row below, and End stopping
7704    /// there would walk), but it is on this row, and a kill that spared it would
7705    /// leave a double space behind where the row's text had been. Deleting it
7706    /// joins nothing — a wrap is drawn, not written.
7707    fn line_span(&self) -> (usize, usize) {
7708        let (row, _) = self.caret_pos();
7709        match self.view {
7710            View::Source => self.line_bounds(),
7711            View::Wysiwyg => self.row_span(row),
7712        }
7713    }
7714
7715    /// The first offset in `[start, end]` holding something other than
7716    /// whitespace, or `end` when the line holds nothing else — where Home aims.
7717    ///
7718    /// Walks the space the view is in, as word motion does: WYSIWYG steps stops,
7719    /// so a hidden delimiter is never taken for the line's first character (nor
7720    /// landed on), and the source view steps the source it is showing.
7721    fn first_non_space(&self, start: usize, end: usize) -> usize {
7722        let mut off = start;
7723        while off < end {
7724            if self.class_at(off) != Class::Space {
7725                return off;
7726            }
7727            off = match self.view {
7728                View::Source => next_boundary(&self.source, off),
7729                View::Wysiwyg => match self.vmap.stop_after(off) {
7730                    Some(next) => next,
7731                    None => return end,
7732                },
7733            };
7734        }
7735        end
7736    }
7737
7738    /// Home: to the first character on the line, or to column 0 when the caret
7739    /// is already on it — the two-press toggle every editor spells this way.
7740    /// The indentation is somewhere the caret has to be able to reach and almost
7741    /// never where a reader is headed, so it costs the second press.
7742    pub fn move_home(&mut self, extend: bool) {
7743        self.goal_col = None;
7744        let (start, end) = self.line_bounds();
7745        let text = self.first_non_space(start, end);
7746        let target = if self.caret == text { start } else { text };
7747        let before = self.caret;
7748        self.move_to(target, extend);
7749        self.debug_assert_on_a_stop(before);
7750    }
7751
7752    /// End: to the end of the line.
7753    pub fn move_end(&mut self, extend: bool) {
7754        self.goal_col = None;
7755        let (_, end) = self.line_bounds();
7756        let before = self.caret;
7757        self.move_to(end, extend);
7758        self.debug_assert_on_a_stop(before);
7759    }
7760
7761    /// Hop to the next (Tab) or previous (Shift+Tab) table cell, landing with the
7762    /// cell's whole content selected (see [`Self::select_cell`]). Returns `false`
7763    /// when the caret isn't in a table, or is already in the last/first cell — the
7764    /// frontend then does whatever Tab normally does (indent), so Tab keeps its
7765    /// meaning everywhere else.
7766    pub fn cell_hop(&mut self, forward: bool) -> bool {
7767        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7768            return false;
7769        };
7770        // Flatten to document (row-major) order and step one cell either way.
7771        let i: usize = grid[..r].iter().map(Vec::len).sum::<usize>() + c;
7772        let flat: Vec<(usize, usize)> = grid.into_iter().flatten().collect();
7773        let next = if forward {
7774            i.checked_add(1)
7775        } else {
7776            i.checked_sub(1)
7777        };
7778        let Some(&(start, end)) = next.and_then(|j| flat.get(j)) else {
7779            return false; // at the table's edge; leave Tab to the frontend
7780        };
7781        self.select_cell(start, end);
7782        true
7783    }
7784
7785    /// Move the caret to the cell directly above (`down == false`) or below in
7786    /// the same column, landing with the cell's whole content selected (see
7787    /// [`Self::select_cell`]). Returns `false` at the grid's top/bottom edge (or
7788    /// when the caret isn't in a table), so the frontend can fall through — the
7789    /// vertical counterpart of [`Self::cell_hop`].
7790    ///
7791    /// A ragged row that is short a column clamps to its last cell, so Down never
7792    /// falls out of the table over a gap the row above happened to have.
7793    pub fn cell_move_vertical(&mut self, down: bool) -> bool {
7794        let Some((grid, r, c)) = self.table_grid_at(self.caret) else {
7795            return false;
7796        };
7797        let target = match down {
7798            true => r + 1,
7799            false if r == 0 => return false,
7800            false => r - 1,
7801        };
7802        let Some(row) = grid.get(target) else {
7803            return false;
7804        };
7805        let Some(&(start, end)) = row.get(c).or_else(|| row.last()) else {
7806            return false;
7807        };
7808        self.select_cell(start, end);
7809        true
7810    }
7811
7812    /// The table containing `off` as a row-major grid of `(start, end)` cell
7813    /// caret homes, plus the `(row, col)` the caret sits in — `None` when `off`
7814    /// isn't in a table. Read straight off the visual map's laid-out grid, so
7815    /// every cell (an empty one included, whose derived home twig gives no
7816    /// `content_span` for) is present and in the order Tab walks them.
7817    // Grid, row, column — three returns that only ever travel together, and a
7818    // named type for the pair of them would be read at one call site.
7819    #[allow(clippy::type_complexity)]
7820    fn table_grid_at(&self, off: usize) -> Option<(Vec<Vec<(usize, usize)>>, usize, usize)> {
7821        for t in &self.vmap.tables {
7822            let mut pos = None;
7823            let grid: Vec<Vec<(usize, usize)>> = t
7824                .grid
7825                .iter()
7826                .enumerate()
7827                .map(|(r, row)| {
7828                    row.cells
7829                        .iter()
7830                        .enumerate()
7831                        .map(|(c, cell)| {
7832                            if pos.is_none() && off >= cell.start && off <= cell.end {
7833                                pos = Some((r, c));
7834                            }
7835                            (cell.start, cell.end)
7836                        })
7837                        .collect()
7838                })
7839                .collect();
7840            if let Some((r, c)) = pos {
7841                return Some((grid, r, c));
7842            }
7843        }
7844        None
7845    }
7846
7847    // ── table key policy ──────────────────────────────────────────────────────
7848    // The three keys a table gives its own meaning — Tab, Return, Shift+Return —
7849    // as one policy every frontend shares, rather than each re-deriving it. Each
7850    // reports whether it acted *as a table key*; a `false` hands the key back to
7851    // the frontend's ordinary handling (indent, newline) so it keeps its meaning
7852    // everywhere else.
7853
7854    /// Tab / Shift+Tab inside a table. Tab steps to the next cell, appending a
7855    /// fresh row and entering it when it runs off the last one; Shift+Tab steps
7856    /// back and simply stays put at the very first cell. `false` when the caret
7857    /// isn't in a table.
7858    pub fn cell_tab(&mut self, forward: bool) -> bool {
7859        if !self.caret_in_table() {
7860            return false;
7861        }
7862        if self.cell_hop(forward) {
7863            return true;
7864        }
7865        // Off the last cell: grow the table by a row and step into its first
7866        // cell. (Shift+Tab at the first cell has nowhere to go and just holds.)
7867        if forward {
7868            self.append_row_and_enter(0);
7869        }
7870        true
7871    }
7872
7873    /// Return inside a table: drop to the cell below in the same column,
7874    /// appending a new row when the caret is already in the last one. `false`
7875    /// when the caret isn't in a table, so the frontend inserts a newline.
7876    pub fn cell_return(&mut self) -> bool {
7877        if !self.caret_in_table() {
7878            return false;
7879        }
7880        if self.cell_move_vertical(true) {
7881            return true;
7882        }
7883        // Already on the last row: grow one below and drop into the same column.
7884        let col = self.table_grid_at(self.caret).map_or(0, |(_, _, c)| c);
7885        self.append_row_and_enter(col);
7886        true
7887    }
7888
7889    /// Append a row below the caret's (last) row and land in `col` of it. The
7890    /// caret is in the last row, so twig's "insert below" makes the fresh row the
7891    /// table's new last — but twig re-spells the whole table, moving every byte,
7892    /// so the destination is read back from the rebuilt grid by the table's
7893    /// position (stable across a row insert), not from the pre-edit caret.
7894    fn append_row_and_enter(&mut self, col: usize) {
7895        let table = self.caret_table_index();
7896        self.table_insert_row(true);
7897        self.rebuild_map();
7898        let Some((start, end)) = table
7899            .and_then(|ti| self.vmap.tables.get(ti))
7900            .and_then(|t| t.grid.last())
7901            .and_then(|row| row.cells.get(col.min(row.cells.len().saturating_sub(1))))
7902            .map(|cell| (cell.start, cell.end))
7903        else {
7904            return;
7905        };
7906        self.select_cell(start, end);
7907    }
7908
7909    /// The index, among the document's tables, of the one the caret sits in —
7910    /// `None` when it's in none. Used to re-find a table after an edit re-spells
7911    /// it (a row insert leaves the table order unchanged).
7912    fn caret_table_index(&self) -> Option<usize> {
7913        let off = self.caret;
7914        self.vmap.tables.iter().position(|t| {
7915            t.grid
7916                .iter()
7917                .any(|row| row.cells.iter().any(|c| off >= c.start && off <= c.end))
7918        })
7919    }
7920
7921    /// Shift+Return inside a table: insert a hard line break *within* the current
7922    /// cell, via twig's `insert_line_break`. `false` when the caret isn't in a
7923    /// table, so the frontend inserts an ordinary line break.
7924    ///
7925    /// A table row is a single source line, so the newline-spelled hard break
7926    /// can't live in a cell. twig spells the in-cell break the format's way
7927    /// (`<br>` for Markdown) and reparses it as a *semantic* `hard_break`, so the
7928    /// break round-trips as structure the renderer reads back as a line — not the
7929    /// opaque raw HTML the old raw-splice left behind.
7930    ///
7931    /// Djot has no idiomatic in-cell break, so twig refuses it
7932    /// (`UnsupportedFormat`) rather than emit a `<br>` that any other djot reader
7933    /// would render as the literal text `<br>`. The gesture is still *consumed*
7934    /// there — returning `false` would let the frontend insert a real newline,
7935    /// which splits the one-line row — it just leaves the cell unchanged and says
7936    /// so on the status line. A rollback (`EditConflict`) is swallowed the same.
7937    ///
7938    /// Which formats refuse is [`Capabilities::cell_line_break`], and the two
7939    /// have to be read together: djot is not the only `false`, and naming it in
7940    /// the message was already a guess that HTML — which spells the break as its
7941    /// own `<br>` — would have made wrong.
7942    pub fn cell_line_break(&mut self) -> bool {
7943        if self.read_only || !self.caret_in_table() {
7944            return false;
7945        }
7946        self.record_caret();
7947        match self.editor.insert_line_break(self.caret) {
7948            Ok(change) => {
7949                self.last_edit_kind = None;
7950                self.refresh();
7951                self.caret = change.new.end;
7952                self.anchor = None;
7953                self.goal_col = None;
7954                self.clamp_caret();
7955                self.dirty = self.source != self.clean_source;
7956                self.status = None;
7957                self.record_caret();
7958            }
7959            Err(twig::Error::UnsupportedFormat) => {
7960                self.status = Some(format!(
7961                    "in-cell line breaks aren't supported in {}",
7962                    self.format_name()
7963                ));
7964            }
7965            Err(_) => {}
7966        }
7967        true
7968    }
7969
7970    /// Rebuild the visual map at the width the last build used. A structural edit
7971    /// bumps the revision and swaps the source in, but leaves the *map* stale;
7972    /// when a single gesture edits and then moves over the result (Tab appending
7973    /// a row, then stepping into it), the move needs the map to already show the
7974    /// edit rather than waiting for the frontend's next frame.
7975    fn rebuild_map(&mut self) {
7976        let wrap = self.vmap_key.as_ref().and_then(|(_, w, _)| *w);
7977        self.build_map(wrap);
7978    }
7979
7980    /// Move the caret to the very start of the document (⌘↑ on macOS,
7981    /// Ctrl+Home on Windows/Linux).
7982    pub fn move_doc_start(&mut self, extend: bool) {
7983        self.goal_col = None;
7984        self.move_to(0, extend);
7985    }
7986
7987    /// Move the caret to the very end of the document (⌘↓ on macOS,
7988    /// Ctrl+End on Windows/Linux).
7989    pub fn move_doc_end(&mut self, extend: bool) {
7990        self.goal_col = None;
7991        let end = self.source.len();
7992        self.move_to(end, extend);
7993    }
7994
7995    /// Point the caret at the body cell `(row, col)` the mouse landed on —
7996    /// `col` being a cell of the terminal grid, which is what a display column
7997    /// is. A click on the far cell of a wide character lands at that
7998    /// character's start; the mapping's own doc-comments carry the rule.
7999    pub fn click(&mut self, row: usize, col: usize, extend: bool) {
8000        self.goal_col = None;
8001        let target = match self.view {
8002            View::Source => row_col_to_offset(&self.source, row, col),
8003            View::Wysiwyg => self.vmap.offset_of_pos(row, col),
8004        };
8005        let before = self.caret;
8006        self.move_to(target, extend);
8007        self.debug_assert_on_a_stop(before);
8008    }
8009
8010    /// A click in the blank space under the document's last block.
8011    ///
8012    /// Not a click *on* anything, so it lands on nothing in particular: the
8013    /// caret goes onto an empty paragraph under the last block, wherever the
8014    /// pointer was horizontally — and if the document does not end with one,
8015    /// one is opened, which is the only way to get out from under a block Enter
8016    /// cannot leave. Enter inside a fenced code block is a literal newline (see
8017    /// [`newline`](Self::newline)), so a document that *ends* in a fence had no
8018    /// way out at all; and a click under any last block used to land at the
8019    /// pointer's x on the block's last line, which is what a click on that line
8020    /// means and not what a click under it does.
8021    ///
8022    /// "Ends with an empty paragraph" is two trailing newlines: the first closes
8023    /// the last line and the second opens the blank line the visual map lays
8024    /// out as a navigable empty row (see `emit_trailing_blank_lines`). A
8025    /// document ending inside an *unclosed* fence gets the fence closed first,
8026    /// since a newline written there would only be another line of code. An
8027    /// empty document has nothing to be under, and the caret simply goes to its
8028    /// start.
8029    ///
8030    /// In the source view the gesture is the ordinary one: the caret goes to the
8031    /// end of the source, and nothing is written. A read-only document likewise.
8032    pub fn click_past_end(&mut self) {
8033        self.goal_col = None;
8034        let len = self.source.len();
8035        if self.view == View::Source || self.read_only || self.source.trim().is_empty() {
8036            self.move_to(len, false);
8037            return;
8038        }
8039        let mut tail = String::new();
8040        if let Some(fence) = self.unclosed_fence_at_end() {
8041            if !self.source.ends_with('\n') {
8042                tail.push('\n');
8043            }
8044            tail.push_str(&fence);
8045            tail.push('\n');
8046        }
8047        let joined = format!("{}{tail}", self.source);
8048        let trailing = joined.len() - joined.trim_end_matches('\n').len();
8049        for _ in trailing..2 {
8050            tail.push('\n');
8051        }
8052        if !tail.is_empty() && !self.splice(len, len, &tail, EditKind::Other) {
8053            return;
8054        }
8055        let end = self.source.len();
8056        self.move_to(end, false);
8057    }
8058
8059    /// The closing fence a document ending inside an unclosed fenced code block
8060    /// needs — the opening fence's own run, behind the quote prefix the block
8061    /// wears — or `None` when the last block is closed, indented, or not a code
8062    /// block at all.
8063    ///
8064    /// Unclosed is when twig's content span reaches the block's end: a closing
8065    /// fence line would lie between the two. `code_info_span`'s read of the
8066    /// fence is not used because it starts at the block's span, which inside a
8067    /// quote is the quote marker rather than the fence.
8068    fn unclosed_fence_at_end(&mut self) -> Option<String> {
8069        let content_end = self.source.trim_end_matches('\n').len();
8070        let block = self
8071            .nodes()
8072            .into_iter()
8073            .filter(|n| n.kind == Kind::CodeBlock && n.span.end >= content_end)
8074            .max_by_key(|n| n.span.start)?;
8075        if block.content_span.as_ref()?.end < block.span.end {
8076            return None;
8077        }
8078        let line = self.source[block.span.start..].lines().next()?;
8079        let opening = line.trim_start_matches(['>', ' ', '\t']);
8080        let fence = opening.chars().next().filter(|c| matches!(c, '`' | '~'))?;
8081        let run: String = opening.chars().take_while(|&c| c == fence).collect();
8082        let prefix = self.quote_prefix_at(block.span.start);
8083        Some(format!("{prefix}{run}"))
8084    }
8085
8086    /// Settle `scroll` for a frame about to be drawn: follow the caret onto the
8087    /// screen if it has moved since the last frame, and never scroll past the
8088    /// last of `rows`.
8089    ///
8090    /// Only if it has *moved* — that's the whole point. Revealing the caret on
8091    /// every frame ties the viewport to it, and a scroll wheel that fights the
8092    /// caret for the viewport loses: the view snaps back the instant it tries to
8093    /// pass the caret's row, so the document can't be scrolled beyond what's
8094    /// already on screen. A caret move is the frontend's cue to follow; a scroll
8095    /// with the caret sitting still is the reader's cue to leave it alone.
8096    pub fn follow_caret(&mut self, caret_row: usize, height: usize, rows: usize) {
8097        if self.drawn_caret != Some(self.caret) {
8098            if caret_row < self.scroll {
8099                self.scroll = caret_row;
8100            } else if height > 0 && caret_row >= self.scroll + height {
8101                self.scroll = caret_row + 1 - height;
8102            }
8103            self.drawn_caret = Some(self.caret);
8104        }
8105        self.scroll = self.scroll.min(rows.saturating_sub(1));
8106    }
8107
8108    /// The caret's screen position `(row, col)` in the active view's grid, with
8109    /// `col` a display column: the cell to draw the caret in, which on a line of
8110    /// `你好` or emoji is not the count of characters before it.
8111    pub fn caret_pos(&self) -> (usize, usize) {
8112        match self.view {
8113            View::Source => offset_to_row_col(&self.source, self.caret),
8114            View::Wysiwyg => self.vmap.pos_of_offset(self.caret),
8115        }
8116    }
8117
8118    fn clamp_caret(&mut self) {
8119        if self.caret > self.source.len() {
8120            self.caret = self.source.len();
8121        }
8122        // In WYSIWYG the caret can't sit inside hidden frontmatter; lift it (and
8123        // any selection anchor) to the first rendered offset.
8124        let floor = self.caret_floor();
8125        if self.caret < floor {
8126            self.caret = floor;
8127        }
8128        if let Some(a) = self.anchor
8129            && a < floor
8130        {
8131            self.anchor = Some(floor);
8132        }
8133        while self.caret > 0 && !self.source.is_char_boundary(self.caret) {
8134            self.caret -= 1;
8135        }
8136    }
8137}
8138
8139// ── byte-offset ⇄ (row, col) helpers ─────────────────────────────────────────
8140
8141// Left/right motion and backspace/delete step by *grapheme cluster*, not
8142// codepoint, so an emoji (a ZWJ sequence) or a base letter plus its combining
8143// marks moves and deletes as the single character a user sees. Grapheme
8144// boundaries are a superset of char boundaries, so the caret stays valid for twig.
8145
8146/// How an insert of `text` groups for undo: a single typed character folds into
8147/// the run of typing around it, while a newline or a multi-character insert is a
8148/// step of its own.
8149fn typed_edit_kind(text: &str) -> EditKind {
8150    if text.chars().take(2).count() == 1 && text != "\n" {
8151        EditKind::Insert
8152    } else {
8153        EditKind::Other
8154    }
8155}
8156
8157fn prev_boundary(s: &str, i: usize) -> usize {
8158    let mut cursor = GraphemeCursor::new(i, s.len(), true);
8159    cursor.prev_boundary(s, 0).ok().flatten().unwrap_or(0)
8160}
8161
8162fn next_boundary(s: &str, i: usize) -> usize {
8163    let mut cursor = GraphemeCursor::new(i, s.len(), true);
8164    cursor.next_boundary(s, 0).ok().flatten().unwrap_or(s.len())
8165}
8166
8167// ── word boundaries ──────────────────────────────────────────────────────────
8168// The shared primitive behind word-wise motion, word deletion, and
8169// double-click-to-select-a-word. A "word" is a maximal run of one character
8170// class; whitespace and punctuation are their own classes, so motion skips
8171// cleanly between them the way native text fields do.
8172
8173#[derive(PartialEq, Eq, Clone, Copy)]
8174enum Class {
8175    Word,
8176    Space,
8177    Other,
8178}
8179
8180/// The source range of an inline node's own visible text — the part of it a
8181/// WYSIWYG caret can reach, as against the delimiters that only spell it.
8182/// `None` for a node with no interior to empty (a `str`, a break).
8183///
8184/// twig reports no `content_span` for `verbatim`/`inline_math`, whose text sits
8185/// one delimiter in from the span — the same place the renderer maps it to. A
8186/// longer fence (`` ``a`` ``) breaks that assumption, so the guess is checked
8187/// against the source rather than trusted: a range guessed wrong here is text
8188/// deleted wrong.
8189fn inline_content_span(n: &FlatNode, source: &str) -> Option<std::ops::Range<usize>> {
8190    if let Some(span) = n.content_span.clone() {
8191        return Some(span);
8192    }
8193    match n.kind.as_str() {
8194        "verbatim" | "inline_math" => {
8195            let text = n.text.as_ref()?;
8196            let start = n.span.start + 1;
8197            let range = start..start + text.len();
8198            (source.get(range.clone()) == Some(text.as_str())).then_some(range)
8199        }
8200        _ => None,
8201    }
8202}
8203
8204/// The `id` a node declares, or `None` for one that declares none — the
8205/// attribute djot writes for a `{#v1}` and mints for a heading.
8206///
8207/// A bare attribute (`{#v1 hidden}`'s `hidden`) has no value, and a bare `id`
8208/// names nothing, so it reads as absent rather than as the empty string.
8209fn declared_id(n: &FlatNode) -> Option<&str> {
8210    n.attrs.iter().find(|(k, _)| k == "id")?.1.as_deref()
8211}
8212
8213/// A heading's words reduced to the form a link fragment spells them in:
8214/// lowercase, runs of anything else collapsed to a single `-`, with none left
8215/// dangling at either end. `## Some Heading Here` → `some-heading-here`.
8216///
8217/// The rule every Markdown renderer follows, and applied to djot's own auto-ids
8218/// too so that `#some-heading-here` and `#Some-Heading-Here` are one question.
8219/// Unicode-aware (`is_alphanumeric`, not an ASCII test), because a heading in
8220/// any other language is still a heading someone will link to. Underscores
8221/// survive for the same reason they do on the web: they are word characters
8222/// wherever identifiers are written.
8223fn slug(text: &str) -> String {
8224    let mut out = String::new();
8225    let mut pending = false;
8226    for c in text.chars() {
8227        if c.is_alphanumeric() || c == '_' {
8228            if pending && !out.is_empty() {
8229                out.push('-');
8230            }
8231            pending = false;
8232            out.extend(c.to_lowercase());
8233        } else {
8234            pending = true;
8235        }
8236    }
8237    out
8238}
8239
8240fn is_block_container(kind: &Kind) -> bool {
8241    matches!(
8242        kind,
8243        Kind::Doc
8244            | Kind::Section
8245            | Kind::BlockQuote
8246            | Kind::BulletList
8247            | Kind::OrderedList
8248            | Kind::TaskList
8249            | Kind::ListItem
8250            | Kind::TaskListItem
8251            // Every `container` — a directive in any of its three forms, or a
8252            // promoted HTML element. A *text* directive is really inline, so
8253            // claiming it here is a small overreach, and the deliberate one this
8254            // function's kind-only peer `is_inline_kind` documents: the pair is
8255            // consulted together, and answering "block container" for something
8256            // inline is what keeps an ancestor walk from stopping short of the
8257            // paragraph that actually holds it.
8258            | Kind::Container
8259    )
8260}
8261
8262/// The `[start, end)` byte range of the source line containing `off` (newline
8263/// excluded) — the fallback when `off` sits outside any AST block (e.g. a blank
8264/// line between paragraphs).
8265fn source_line_range(s: &str, off: usize) -> std::ops::Range<usize> {
8266    let off = off.min(s.len());
8267    let start = s[..off].rfind('\n').map(|p| p + 1).unwrap_or(0);
8268    let end = s[off..].find('\n').map(|p| off + p).unwrap_or(s.len());
8269    start..end
8270}
8271
8272/// How many leading bytes an outdent takes off `line`: a whole indent level
8273/// where the line has one, and whatever it has where it has less.
8274///
8275/// A leading tab counts as a level on its own. It's indentation some other
8276/// editor wrote, and one tab is one level everywhere it came from — measuring it
8277/// in spaces it doesn't contain would leave it untouchable.
8278fn outdent_width(line: &str, unit: usize) -> usize {
8279    if line.starts_with('\t') {
8280        return 1;
8281    }
8282    line.bytes().take(unit).take_while(|b| *b == b' ').count()
8283}
8284
8285/// A list marker found at the head of a line, together with everything before it
8286/// that a sibling line has to repeat.
8287///
8288/// The three offsets differ only inside a block quote, where `>   - b` opens with
8289/// a `> ` quote marker the line's own text doesn't own. Outside one they collapse:
8290/// `line_start == marker_start`, and `text` is the plain `"  - "`.
8291#[derive(Clone, Debug)]
8292struct ListMarker {
8293    /// The line's first byte.
8294    line_start: usize,
8295    /// Where the marker proper begins, past any quote prefix. The offset to hand
8296    /// the AST: a quoted item's span opens at its bullet, not at the `>`.
8297    marker_start: usize,
8298    /// `line_start` through the marker's trailing space — quote prefix, indent
8299    /// and bullet together, which is what the next item's line opens with.
8300    text: String,
8301}
8302
8303impl ListMarker {
8304    /// Where the item's content starts — one past the marker's trailing space.
8305    fn content_start(&self) -> usize {
8306        self.line_start + self.text.len()
8307    }
8308}
8309
8310fn classify(c: char) -> Class {
8311    if c == '_' || c.is_alphanumeric() {
8312        Class::Word
8313    } else if c.is_whitespace() {
8314        Class::Space
8315    } else {
8316        Class::Other
8317    }
8318}
8319
8320/// The offset at the end of the next word to the right of `i` (⌥→ / Ctrl+→):
8321/// skip any leading separators, then consume the following word run.
8322fn next_word(s: &str, i: usize) -> usize {
8323    let mut off = i;
8324    let mut in_word = false;
8325    for c in s[i..].chars() {
8326        if classify(c) == Class::Word {
8327            in_word = true;
8328        } else if in_word {
8329            break;
8330        }
8331        off += c.len_utf8();
8332    }
8333    off
8334}
8335
8336/// The offset at the start of the word to the left of `i` (⌥← / Ctrl+←):
8337/// skip separators walking left, then consume the preceding word run.
8338fn prev_word(s: &str, i: usize) -> usize {
8339    let mut off = i;
8340    let mut in_word = false;
8341    for c in s[..i].chars().rev() {
8342        if classify(c) == Class::Word {
8343            in_word = true;
8344        } else if in_word {
8345            break;
8346        }
8347        off -= c.len_utf8();
8348    }
8349    off
8350}
8351
8352/// The `[start, end)` run of same-class characters surrounding `off` — the
8353/// word (or whitespace/punctuation run) a double-click selects. At end-of-text
8354/// the run ending there is used.
8355fn word_range_at(s: &str, off: usize) -> (usize, usize) {
8356    if s.is_empty() {
8357        return (0, 0);
8358    }
8359    let off = off.min(s.len());
8360    let reference = if off < s.len() {
8361        s[off..].chars().next()
8362    } else {
8363        s[..off].chars().next_back()
8364    };
8365    let Some(rc) = reference else {
8366        return (off, off);
8367    };
8368    let class = classify(rc);
8369
8370    let mut start = off;
8371    for c in s[..start].chars().rev() {
8372        if classify(c) == class {
8373            start -= c.len_utf8();
8374        } else {
8375            break;
8376        }
8377    }
8378    let mut end = off;
8379    for c in s[end..].chars() {
8380        if classify(c) == class {
8381            end += c.len_utf8();
8382        } else {
8383            break;
8384        }
8385    }
8386    (start, end)
8387}
8388
8389/// `(row, col)` of byte offset `off`, `col` counted in *display columns* from
8390/// the line's start — terminal cells, not characters, so the column names the
8391/// cell the caret is drawn in even on a line of `你好` or emoji.
8392fn offset_to_row_col(s: &str, off: usize) -> (usize, usize) {
8393    let off = off.min(s.len());
8394    let mut row = 0;
8395    let mut line_start = 0;
8396    for (i, &b) in s.as_bytes().iter().enumerate() {
8397        if i >= off {
8398            break;
8399        }
8400        if b == b'\n' {
8401            row += 1;
8402            line_start = i + 1;
8403        }
8404    }
8405    (row, wysiwyg::text_width(&s[line_start..off]))
8406}
8407
8408/// The byte offset at display column `col` of `row` (clamped to that line's
8409/// end) — the inverse of [`offset_to_row_col`], which it has to agree with.
8410///
8411/// A column landing *inside* a character — the second cell of `你`, or any cell
8412/// but the first of an emoji — resolves to that character's start, which is the
8413/// column the caret would have been drawn at to begin with. So both cells of a
8414/// wide character mean the character, and every offset survives the round trip
8415/// out to a column and back. The walk steps by grapheme cluster for the same
8416/// reason the caret does: a cluster is the character, and the cells belong to it
8417/// rather than to the codepoints spelling it.
8418fn row_col_to_offset(s: &str, row: usize, col: usize) -> usize {
8419    let start = line_start(s, row);
8420    let end = line_end_from(s, start);
8421    let mut off = start;
8422    let mut at = 0; // the display column `off` sits at
8423    while off < end {
8424        let next = next_boundary(s, off).min(end);
8425        let cells = wysiwyg::text_width(&s[off..next]);
8426        if at + cells > col {
8427            break; // `col` is one of this cluster's own cells
8428        }
8429        at += cells;
8430        off = next;
8431    }
8432    off
8433}
8434
8435fn line_start(s: &str, row: usize) -> usize {
8436    if row == 0 {
8437        return 0;
8438    }
8439    let mut r = 0;
8440    for (i, &b) in s.as_bytes().iter().enumerate() {
8441        if b == b'\n' {
8442            r += 1;
8443            if r == row {
8444                return i + 1;
8445            }
8446        }
8447    }
8448    s.len()
8449}
8450
8451fn line_end_from(s: &str, start: usize) -> usize {
8452    s[start..].find('\n').map(|p| start + p).unwrap_or(s.len())
8453}
8454
8455/// twig's node-kind name for an inline mark, back to the [`InlineKind`] a
8456/// frontend names when it calls [`Doc::toggle`] — the inverse of the mapping
8457/// twig applies writing the mark out, so the toolbar can light the same button
8458/// that made the node.
8459///
8460/// `None` for every other kind, including the inline nodes that aren't marks at
8461/// all (`str`, `link`, `image`, the math and break kinds): they're things a
8462/// caret stands in, not formatting a button toggles.
8463/// Whether a match from an ancestor chain is an inline run whose delimiters
8464/// the rich view draws nothing for — a mark (`**`, `_`, `==`), or an
8465/// attributed span: `<span data-size="large">…</span>`, djot's `[…]{…}`. The
8466/// span is a [`Kind::Container`], which the kind alone cannot tell from a
8467/// block `<div>`, so the chain's caller passes [`Doc::run_span_ids`] and the
8468/// answer is the node's own. Every delete and caret step that walks over a
8469/// `**` walks over a span's tags by this test; without it Backspace after
8470/// `</span>` took the `>` and left the paragraph unparseable.
8471fn hides_delims(m: &QueryMatch, run_spans: &[NodeId]) -> bool {
8472    inline_kind(&m.kind).is_some() || run_spans.contains(&NodeId(m.node_id))
8473}
8474
8475fn inline_kind(kind: &Kind) -> Option<InlineKind> {
8476    Some(match kind {
8477        Kind::Strong => InlineKind::Strong,
8478        Kind::Emph => InlineKind::Emph,
8479        Kind::Verbatim => InlineKind::Verbatim,
8480        Kind::Mark => InlineKind::Mark,
8481        Kind::Superscript => InlineKind::Superscript,
8482        Kind::Subscript => InlineKind::Subscript,
8483        Kind::Insert => InlineKind::Insert,
8484        Kind::Delete => InlineKind::Delete,
8485        _ => return None,
8486    })
8487}
8488
8489/// leaf's [`MarkColor`] as twig's — the palette twig writes as the emoji after
8490/// a highlight's opening `==`.
8491///
8492/// Two enums for one closed vocabulary, and the duplication is the boundary
8493/// working: core's is what a *frontend* names (`style::MarkColor`, beside the
8494/// [`Role`](crate::Role) that carries it into the glyph map) and twig's is what
8495/// the editor writes. Spelled as a match rather than routed through the two
8496/// crates' name strings so that a colour added on either side is a compile
8497/// error here, where the pairing is decided, rather than a runtime `None` that
8498/// would read as "clear the colour".
8499fn twig_mark_color(color: MarkColor) -> twig::MarkColor {
8500    match color {
8501        MarkColor::Red => twig::MarkColor::Red,
8502        MarkColor::Orange => twig::MarkColor::Orange,
8503        MarkColor::Yellow => twig::MarkColor::Yellow,
8504        MarkColor::Green => twig::MarkColor::Green,
8505        MarkColor::Blue => twig::MarkColor::Blue,
8506        MarkColor::Purple => twig::MarkColor::Purple,
8507        MarkColor::Brown => twig::MarkColor::Brown,
8508    }
8509}
8510
8511/// Where an offset lands after a splice it didn't make — twig's own rule, from
8512/// [`Change`]: shift anything at or past the replaced range's end by the length
8513/// the replacement gained or lost, and leave anything before it alone.
8514///
8515/// An offset *inside* the replaced range has no text of its own to ride any
8516/// more, and lands at the end of what replaced it: for
8517/// [`Doc::set_mark_color`] that is a caret standing on the colour prefix when
8518/// the prefix is cleared, which then sits where the highlighted text begins.
8519/// One node's attribute list, twig's own `(key, value)` pairs owned — what
8520/// every presentation gesture reads, edits one key of, and passes back whole.
8521type Attrs = Vec<(String, Option<String>)>;
8522
8523/// The name of the leaf directive a page break is — [`Doc::insert_page_break`]
8524/// writes it and the walker draws it, and a frontend that paginates matches a
8525/// [`DirectiveMark`](crate::wysiwyg::DirectiveMark) against it. One spelling,
8526/// stated once.
8527pub const PAGE_BREAK: &str = "page-break";
8528
8529/// `attrs` with `key` set to `value`, or removed when `value` is `None`, and
8530/// every other attribute kept in its place — the read-edit-write half of twig's
8531/// replace-not-merge contract for a `data-` key.
8532///
8533/// **A key that is already there is rewritten where it stands**, and only a key
8534/// the node did not have goes on the end. That is what makes the proposal's
8535/// worked example true: `class="lead center" id="intro"
8536/// data-line-height="1.5"`, right-aligned, is `class="lead right" id="intro"
8537/// data-line-height="1.5"` — the same document with one token changed, and a
8538/// one-line diff. Removing the key and pushing it back would reorder the
8539/// author's attributes on every press, so a document that passed through the
8540/// editor came out shuffled even where nothing about it had changed.
8541///
8542/// A duplicate key — which no format leaf opens can spell, but twig reports
8543/// verbatim — collapses onto the first of its copies, since twig is handed one
8544/// value for one key either way.
8545fn with_attr(attrs: &[(String, Option<String>)], key: &str, value: Option<&str>) -> Attrs {
8546    let mut out: Attrs = Vec::with_capacity(attrs.len() + 1);
8547    let mut written = false;
8548    for (k, v) in attrs {
8549        if k != key {
8550            out.push((k.clone(), v.clone()));
8551            continue;
8552        }
8553        if let Some(new) = value.filter(|_| !written) {
8554            out.push((k.clone(), Some(new.to_string())));
8555            written = true;
8556        }
8557    }
8558    if let Some(new) = value.filter(|_| !written) {
8559        out.push((key.to_string(), Some(new.to_string())));
8560    }
8561    out
8562}
8563
8564/// [`with_attr`] for a `class` token: every token `mine` claims is removed, and
8565/// `token` added, with the rest of the list kept in order.
8566///
8567/// `class` is a space-separated token list, and leaf owns three of the tokens in
8568/// it. A paragraph that arrives as `class="lead center"` and is right-aligned
8569/// goes out as `class="lead right"`; one whose last owned token goes and which
8570/// carried nothing else loses the key, so a block that has lost its whole
8571/// vocabulary is spelled bare again. `class` itself keeps its place among the
8572/// attributes, because [`with_attr`] does the writing.
8573fn with_class_token(
8574    attrs: &[(String, Option<String>)],
8575    mine: impl Fn(&str) -> bool,
8576    token: Option<&str>,
8577) -> Attrs {
8578    let kept: Vec<&str> = attrs
8579        .iter()
8580        .find(|(k, _)| k == "class")
8581        .and_then(|(_, v)| v.as_deref())
8582        .unwrap_or_default()
8583        .split_whitespace()
8584        .filter(|t| !mine(t))
8585        .collect();
8586    let class = kept.into_iter().chain(token).collect::<Vec<_>>().join(" ");
8587    with_attr(
8588        attrs,
8589        "class",
8590        (!class.is_empty()).then_some(class.as_str()),
8591    )
8592}
8593
8594/// An owned attribute list as the borrowed pairs twig's two attribute ops take.
8595///
8596/// A **bare** attribute — one twig reports with no value, such as HTML's `<p
8597/// hidden>` — is passed back as an empty one. Twig refuses a `None` outright
8598/// (djot has no bare attribute, so no format reads one back everywhere), and
8599/// `hidden=""` is the same document where `hidden` is; dropping it instead
8600/// would lose what the author wrote, which is the one thing these gestures
8601/// promise not to do.
8602fn attr_pairs(attrs: &[(String, Option<String>)]) -> Vec<(&str, Option<&str>)> {
8603    attrs
8604        .iter()
8605        .map(|(k, v)| (k.as_str(), Some(v.as_deref().unwrap_or_default())))
8606        .collect()
8607}
8608
8609fn reanchor(off: usize, change: &Change) -> usize {
8610    if off < change.old.start {
8611        return off;
8612    }
8613    if off < change.old.end {
8614        return change.new.end;
8615    }
8616    (off + change.new.end).saturating_sub(change.old.end)
8617}
8618
8619/// [`reanchor`] for an edit that respells the markup *around* a block and
8620/// leaves the block's own bytes alone — which is every attribute gesture.
8621///
8622/// `block` is that block's content span before and after the splice, so an
8623/// offset standing in the text keeps its distance from the text's start and how
8624/// many bytes twig wrote above it never enters the arithmetic. That is the whole
8625/// rule, and it is why nothing here knows how long a `<div …>` is: a second key
8626/// on the same div lengthens the attribute line, clearing the last one takes the
8627/// div away entirely, and both are the same sum. `None` where the splice named
8628/// no block at either end, which is every djot case — the `{…}` line is written
8629/// above the block, and the block itself only shifts past it.
8630///
8631/// Anywhere else it is `reanchor`'s own answer: untouched before the splice,
8632/// shifted by its delta after it, and at the splice's end for an offset that
8633/// stood in markup being rewritten — a caret inside djot's `{…}` line has no
8634/// text to keep.
8635fn reanchor_in_block(
8636    off: usize,
8637    change: &Change,
8638    block: Option<(&Range<usize>, &Range<usize>)>,
8639) -> usize {
8640    if let Some((was, now)) = block
8641        && was.start <= off
8642        && off <= was.end
8643    {
8644        return now.start + (off - was.start).min(now.end - now.start);
8645    }
8646    reanchor(off, change)
8647}
8648
8649/// A watermark for a file's contents (see `Doc::disk_hash`).
8650///
8651/// `DefaultHasher` is not stable across Rust releases, which doesn't matter: a
8652/// watermark is compared only against one taken by the same process moments
8653/// earlier, and never outlives it. 64 bits leaves a collision — an external edit
8654/// that hashes to exactly what leaf wrote — at odds no filesystem race gets near.
8655fn hash_bytes(bytes: &[u8]) -> u64 {
8656    use std::hash::{Hash, Hasher};
8657    let mut h = std::collections::hash_map::DefaultHasher::new();
8658    bytes.hash(&mut h);
8659    h.finish()
8660}
8661
8662#[cfg(feature = "fs")]
8663fn detect_format(path: &Path) -> Result<Format> {
8664    let ext = path
8665        .extension()
8666        .and_then(|e| e.to_str())
8667        .unwrap_or("")
8668        .to_ascii_lowercase();
8669    Ok(match ext.as_str() {
8670        "dj" | "djot" => Format::Djot,
8671        "md" | "markdown" => Format::Markdown,
8672        "xml" => Format::Xml,
8673        "html" | "htm" => Format::Html,
8674        other => return Err(anyhow!("unknown document extension: .{other}")),
8675    })
8676}
8677
8678#[cfg(test)]
8679mod tests {
8680    use super::*;
8681    use crate::style::{FontFamily, LineSpacing, SizeStep};
8682
8683    /// A document open in `view`. WYSIWYG motion reads the visual map, which the
8684    /// renderer stamps each frame, so the map is built here too — a WYSIWYG doc
8685    /// without one is a view no user is ever in.
8686    fn doc_in(view: View, name: &str, body: &str) -> Doc {
8687        // The fixture name doubles as the temp file's, so two tests picking the
8688        // same one raced under the parallel runner and read each other's body —
8689        // a green suite proving the wrong thing. The counter makes that
8690        // unreachable rather than asking every future caller to notice.
8691        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
8692        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8693        let mut p = std::env::temp_dir();
8694        p.push(format!("leaf_test_{name}_{seq}.md"));
8695        std::fs::write(&p, body).unwrap();
8696        let mut d = Doc::open(p).unwrap();
8697        d.view = view;
8698        if view == View::Wysiwyg {
8699            d.build_visual(80);
8700        }
8701        d
8702    }
8703
8704    // Source-view document for the source-behaviour tests. `Doc::open` now
8705    // defaults to WYSIWYG (leaf's default view), so pin the source view here;
8706    // `wysiwyg_doc` builds the rich-text variant on top of this.
8707    fn doc_with(name: &str, body: &str) -> Doc {
8708        doc_in(View::Source, name, body)
8709    }
8710
8711    /// Every visual row's drawn text — what the reader actually sees, which is
8712    /// the only thing the reveal preference is supposed to change.
8713    fn drawn_rows(d: &Doc) -> Vec<String> {
8714        d.vmap
8715            .rows
8716            .iter()
8717            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8718            .collect()
8719    }
8720
8721    /// Put the caret at the first byte of `needle` and rebuild, so the row under
8722    /// it becomes the revealed line.
8723    fn caret_at(d: &mut Doc, needle: &str) {
8724        d.caret = d.source.find(needle).expect("needle in source");
8725        d.build_visual(80);
8726    }
8727
8728    #[test]
8729    fn blockquote_after_a_list_is_not_bulleted() {
8730        // twig nests a following top-level block quote under the `bullet_list`
8731        // (a direct child, not a `list_item`). The map must render it de-nested —
8732        // `│ quote`, never `• │ quote` — with a blank separator, like any block
8733        // that follows a list. Regression for the "combined list + blockquote" bug.
8734        let mut d = doc_in(View::Wysiwyg, "bq_after_list", "- item\n\n> quote\n");
8735        d.build_visual(80);
8736        let rows: Vec<String> = d
8737            .vmap
8738            .rows
8739            .iter()
8740            .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
8741            .collect();
8742        assert!(
8743            rows.iter().any(|r| r == "│ quote"),
8744            "block quote should render on its own gutter, got rows: {rows:?}"
8745        );
8746        assert!(
8747            !rows.iter().any(|r| r.contains('•') && r.contains('│')),
8748            "no row should carry both a bullet and a quote gutter, got rows: {rows:?}"
8749        );
8750    }
8751
8752    // ── the map is built at most once per (revision, wrap) ───────────────────
8753    //
8754    // A frontend repaints for reasons that have nothing to do with the text — a
8755    // blinking caret, a scroll — and rebuilding the map is O(document). These
8756    // pin *that the cache fires*, which a passing suite can't tell you: a cache
8757    // that never hits is invisible to every other test in this file.
8758    //
8759    // The probe is to wreck the built map and ask for it again. A rebuild
8760    // repairs it; a cache hit hands the wreckage straight back. Nothing else
8761    // can distinguish the two from outside.
8762
8763    #[test]
8764    fn a_rebuild_with_nothing_changed_reuses_the_map() {
8765        let mut d = doc_in(View::Wysiwyg, "cache_hit", "# Title\n\nbody\n");
8766        d.build_visual(80);
8767        assert!(!d.vmap.rows.is_empty());
8768        d.vmap.rows.clear(); // wreck it
8769        d.build_visual(80);
8770        assert!(
8771            d.vmap.rows.is_empty(),
8772            "the map was rebuilt though nothing changed — the cache never fired"
8773        );
8774    }
8775
8776    #[test]
8777    fn an_edit_rebuilds_the_map() {
8778        let mut d = doc_in(View::Wysiwyg, "cache_edit", "# Title\n\nbody\n");
8779        d.build_visual(80);
8780        let before = d.revision();
8781        d.vmap.rows.clear();
8782        d.insert("x");
8783        d.build_visual(80);
8784        assert!(d.revision() > before, "an edit must move the revision");
8785        assert!(
8786            !d.vmap.rows.is_empty(),
8787            "an edited document must not paint from a stale map"
8788        );
8789    }
8790
8791    #[test]
8792    fn a_width_change_rebuilds_the_map() {
8793        // The map is a function of the wrap width too, so a resize is a miss
8794        // even though the text is untouched.
8795        let mut d = doc_in(
8796            View::Wysiwyg,
8797            "cache_width",
8798            "one two three four five six\n",
8799        );
8800        d.build_visual(80);
8801        d.vmap.rows.clear();
8802        d.build_visual(12);
8803        assert!(!d.vmap.rows.is_empty(), "a resize must rebuild the map");
8804        // And the unwrapped map is its own key, not the same as any width.
8805        d.vmap.rows.clear();
8806        d.build_visual_unwrapped();
8807        assert!(!d.vmap.rows.is_empty(), "unwrapped is a different map");
8808    }
8809
8810    #[test]
8811    fn a_motion_does_not_rebuild_the_map() {
8812        // The whole point: moving the caret changes nothing the map is built
8813        // from. If a motion bumped the revision, every arrow key would cost a
8814        // full rebuild and the cache would be worthless.
8815        let mut d = doc_in(View::Wysiwyg, "cache_motion", "# Title\n\nbody text\n");
8816        d.build_visual(80);
8817        let rev = d.revision();
8818        d.move_right(false);
8819        d.move_right(true);
8820        d.move_down(false);
8821        assert_eq!(d.revision(), rev, "a motion must not move the revision");
8822        d.vmap.rows.clear();
8823        d.build_visual(80);
8824        assert!(
8825            d.vmap.rows.is_empty(),
8826            "a motion should not rebuild the map"
8827        );
8828    }
8829
8830    #[test]
8831    fn saving_does_not_rebuild_the_map() {
8832        // Saving changes `dirty`, not the text.
8833        let mut d = doc_in(View::Wysiwyg, "cache_save", "# Title\n\nbody\n");
8834        d.insert("x");
8835        d.build_visual(80);
8836        let rev = d.revision();
8837        d.save();
8838        assert_eq!(d.revision(), rev, "a save must not move the revision");
8839        assert!(!d.dirty, "the save should have cleaned the document");
8840    }
8841
8842    #[test]
8843    fn a_reload_rebuilds_the_map() {
8844        // Reload replaces the text without going through `refresh`, so it has to
8845        // move the revision itself — else the editor paints the old file.
8846        let mut d = doc_in(View::Wysiwyg, "cache_reload", "# Title\n\nbody\n");
8847        d.build_visual(80);
8848        let rev = d.revision();
8849        std::fs::write(&d.path, "# Other\n\nwholly new\n").unwrap();
8850        d.reload();
8851        assert!(d.revision() > rev, "a reload must move the revision");
8852        d.build_visual(80);
8853        let text: String = d
8854            .vmap
8855            .rows
8856            .iter()
8857            .flat_map(|r| r.glyphs.iter().map(|g| g.ch))
8858            .collect();
8859        assert!(
8860            text.contains("wholly new"),
8861            "the reloaded text should be on screen, got {text:?}"
8862        );
8863    }
8864
8865    // ── golden-case harness ──────────────────────────────────────────────────
8866    // The pattern the whole parity suite can reuse: write a fixture with the
8867    // caret marked by `|`, run one action, and compare the rendered result —
8868    // also caret-marked — against the expected string. One readable line per
8869    // behavior, and it exercises the exact `Doc` ops both frontends call.
8870
8871    /// Split a `|`-marked fixture into `(source, caret_offset)`.
8872    fn parse_caret(marked: &str) -> (String, usize) {
8873        let caret = marked.find('|').expect("fixture needs a `|` caret marker");
8874        (marked.replacen('|', "", 1), caret)
8875    }
8876
8877    /// Render a doc's source with `|` at the caret (and `[`…`]` around any
8878    /// selection) so a result reads like the fixtures.
8879    fn render_caret(d: &Doc) -> String {
8880        // (offset, rank, char); rank keeps coincident markers ordered `[ | ]`
8881        // so the caret always renders inside its own selection.
8882        let mut marks: Vec<(usize, u8, char)> = vec![(d.caret, 1, '|')];
8883        if let Some((s, e)) = d.selection() {
8884            marks.push((s, 0, '['));
8885            marks.push((e, 2, ']'));
8886        }
8887        // Insert right-to-left: descending offset, then descending rank.
8888        marks.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
8889        let mut out = d.source.clone();
8890        for (at, _, ch) in marks {
8891            out.insert(at, ch);
8892        }
8893        out
8894    }
8895
8896    /// Load a `|`-marked fixture, run `action`, return the caret-marked result.
8897    fn golden(name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8898        golden_in(View::Source, name, marked, action)
8899    }
8900
8901    /// [`golden`] in a chosen view — the editing ops are the view's to share, so
8902    /// the same fixture has to read the same way in both.
8903    fn golden_in(view: View, name: &str, marked: &str, action: impl FnOnce(&mut Doc)) -> String {
8904        let (src, caret) = parse_caret(marked);
8905        let mut d = doc_in(view, name, &src);
8906        d.caret = caret;
8907        action(&mut d);
8908        render_caret(&d)
8909    }
8910
8911    #[test]
8912    fn word_motion_walks_word_by_word() {
8913        let g = |m, f: fn(&mut Doc)| golden("word_motion", m, f);
8914        assert_eq!(
8915            g("hello wor|ld", |d| d.move_word_left(false)),
8916            "hello |world"
8917        );
8918        assert_eq!(
8919            g("hello| world", |d| d.move_word_left(false)),
8920            "|hello world"
8921        );
8922        assert_eq!(
8923            g("hel|lo world", |d| d.move_word_right(false)),
8924            "hello| world"
8925        );
8926        assert_eq!(
8927            g("hello| world", |d| d.move_word_right(false)),
8928            "hello world|"
8929        );
8930        // Punctuation is its own class, so motion stops at the boundary.
8931        assert_eq!(g("|foo.bar", |d| d.move_word_right(false)), "foo|.bar");
8932    }
8933
8934    #[test]
8935    fn word_motion_extends_the_selection_when_asked() {
8936        assert_eq!(
8937            golden("word_sel", "hello |world", |d| d.move_word_right(true)),
8938            "hello [world|]"
8939        );
8940    }
8941
8942    #[test]
8943    fn delete_word_removes_a_whole_word() {
8944        let g = |m, f: fn(&mut Doc)| golden("del_word", m, f);
8945        assert_eq!(g("hello world|", |d| d.delete_word_back()), "hello |");
8946        assert_eq!(g("hello |world", |d| d.delete_word_forward()), "hello |");
8947        assert_eq!(g("foo |bar baz", |d| d.delete_word_back()), "|bar baz");
8948    }
8949
8950    // ── Home / End ───────────────────────────────────────────────────────────
8951
8952    #[test]
8953    fn home_toggles_between_the_line_s_text_and_its_margin() {
8954        // Source: the indentation is what the toggle is for. WYSIWYG resolves an
8955        // indent to the markup it spells everywhere it means one, so the fixture
8956        // with whitespace left to walk is a code block, which is verbatim.
8957        let g = |m, f: fn(&mut Doc)| golden("smart_home", m, f);
8958        assert_eq!(g("    inden|ted", |d| d.move_home(false)), "    |indented");
8959        assert_eq!(g("    |indented", |d| d.move_home(false)), "|    indented");
8960        assert_eq!(g("|    indented", |d| d.move_home(false)), "    |indented");
8961        // A line with no indentation has one place to go, so the toggle is a
8962        // no-op rather than a trip to nowhere.
8963        assert_eq!(g("hel|lo", |d| d.move_home(false)), "|hello");
8964        assert_eq!(g("|hello", |d| d.move_home(false)), "|hello");
8965
8966        let mut d = wysiwyg_doc("smart_home_wys", "```\n    indented\n```\n");
8967        let indent = d.source.find("    indented").unwrap();
8968        d.caret = indent + 6; // inside "indented"
8969        d.move_home(false);
8970        assert_eq!(
8971            d.caret,
8972            indent + 4,
8973            "wysiwyg: Home aims at the code line's text"
8974        );
8975        d.move_home(false);
8976        assert_eq!(
8977            d.caret, indent,
8978            "wysiwyg: the second press takes the indent"
8979        );
8980        d.move_home(false);
8981        assert_eq!(d.caret, indent + 4, "wysiwyg: the toggle swaps back");
8982    }
8983
8984    #[test]
8985    fn end_takes_the_line_the_view_is_showing() {
8986        // The line differs by view for the same document, and that is the point:
8987        // a bare newline inside a paragraph is a soft break, which WYSIWYG draws
8988        // as a space on one row and the source view as two lines.
8989        let mut d = doc_with("end_src", "one two\nthree\n");
8990        d.caret = 1;
8991        d.move_end(false);
8992        assert_eq!(d.caret, 7, "source: the end of the source line");
8993
8994        let mut d = wysiwyg_doc("end_wys", "one two\nthree\n");
8995        d.caret = 1;
8996        d.move_end(false);
8997        assert_eq!(
8998            d.caret, 13,
8999            "wysiwyg: the end of the row, soft break and all"
9000        );
9001    }
9002
9003    #[test]
9004    fn home_and_end_extend_the_selection_when_asked() {
9005        for (view, tag) in VIEWS {
9006            let mut d = doc_in(view, &format!("home_end_ext_{tag}"), "hello world");
9007            d.caret = 6;
9008            d.move_end(true);
9009            assert_eq!(d.selection(), Some((6, 11)), "{tag}: End extends");
9010            let mut d = doc_in(view, &format!("home_ext_{tag}"), "hello world");
9011            d.caret = 6;
9012            d.move_home(true);
9013            assert_eq!(d.selection(), Some((0, 6)), "{tag}: Home extends");
9014        }
9015    }
9016
9017    // ── kill to the line's start / end ───────────────────────────────────────
9018
9019    #[test]
9020    fn kill_to_the_line_start_and_end_in_both_views() {
9021        for (view, tag) in VIEWS {
9022            // The gap that reads as a paragraph break in each view: the source
9023            // view's lines are the renderer's rows only where the source says so.
9024            let gap = if view == View::Source { "\n" } else { "\n\n" };
9025            let mut d = doc_in(
9026                view,
9027                &format!("kill_end_{tag}"),
9028                &format!("one two{gap}three\n"),
9029            );
9030            d.caret = 3;
9031            d.delete_to_line_end();
9032            assert_eq!(
9033                d.source,
9034                format!("one{gap}three\n"),
9035                "{tag}: ^K to the line's end"
9036            );
9037            assert_eq!(d.caret, 3, "{tag}: the caret stays where it kills from");
9038
9039            let mut d = doc_in(
9040                view,
9041                &format!("kill_start_{tag}"),
9042                &format!("one two{gap}three\n"),
9043            );
9044            d.caret = 7; // the end of the first line
9045            d.delete_to_line_start();
9046            assert_eq!(
9047                d.source,
9048                format!("{gap}three\n"),
9049                "{tag}: ⌘⌫ to the line's start"
9050            );
9051            assert_eq!(d.caret, 0, "{tag}");
9052        }
9053    }
9054
9055    #[test]
9056    fn a_kill_at_the_line_s_edge_leaves_the_lines_joined() {
9057        // The decision: at the boundary both kills do nothing, rather than
9058        // eating the line break. "Line" is the view's own — in WYSIWYG it ends
9059        // at a soft wrap as often as at a newline, where there is nothing
9060        // written to delete — and a source newline is only half of the blank
9061        // line between two paragraphs, so taking it leaves a soft break rather
9062        // than the join it looks like. Backspace and Delete are the keys for it.
9063        for (view, tag) in VIEWS {
9064            let gap = if view == View::Source { "\n" } else { "\n\n" };
9065            let src = format!("one{gap}three\n");
9066            let mut d = doc_in(view, &format!("kill_edge_end_{tag}"), &src);
9067            d.caret = 3; // the end of "one"
9068            d.delete_to_line_end();
9069            assert_eq!(
9070                d.source, src,
9071                "{tag}: ^K at the line's end joined it to the next"
9072            );
9073
9074            let mut d = doc_in(view, &format!("kill_edge_start_{tag}"), &src);
9075            d.caret = 3 + gap.len(); // the start of "three"
9076            d.delete_to_line_start();
9077            assert_eq!(
9078                d.source, src,
9079                "{tag}: ⌘⌫ at the line's start joined it to the last"
9080            );
9081        }
9082    }
9083
9084    #[test]
9085    fn a_kill_takes_the_selection_when_there_is_one() {
9086        // What every other delete here does with one, so these two as well.
9087        for (view, tag) in VIEWS {
9088            for (name, kill) in [
9089                (
9090                    "end",
9091                    (|d: &mut Doc| d.delete_to_line_end()) as fn(&mut Doc),
9092                ),
9093                ("start", |d: &mut Doc| d.delete_to_line_start()),
9094            ] {
9095                let mut d = doc_in(view, &format!("kill_sel_{name}_{tag}"), "one two three\n");
9096                d.anchor = Some(4);
9097                d.caret = 7; // "two"
9098                kill(&mut d);
9099                assert_eq!(
9100                    d.source, "one  three\n",
9101                    "{tag}: {name} ignored the selection"
9102                );
9103                assert_eq!(d.selection(), None, "{tag}: {name}");
9104            }
9105        }
9106    }
9107
9108    #[test]
9109    fn a_kill_takes_the_markup_it_empties_with_it() {
9110        // The same hazard a word-delete has: a WYSIWYG range covers what the
9111        // user can see, which for `**bold**` is the word and never the
9112        // delimiters, so a kill that stopped at the text would leave `a ****` —
9113        // markup wrapped around nothing.
9114        let mut d = wysiwyg_doc("kill_widen", "a **bold**\n");
9115        d.caret = d.source.find("bold").unwrap();
9116        d.delete_to_line_end();
9117        assert_eq!(d.source, "a \n");
9118    }
9119
9120    #[test]
9121    fn a_kill_is_undone_in_one_step() {
9122        for (view, tag) in VIEWS {
9123            let mut d = doc_in(view, &format!("kill_undo_{tag}"), "one two three\n");
9124            d.caret = 3;
9125            d.delete_to_line_end();
9126            assert_eq!(d.source, "one\n", "{tag}");
9127            d.undo();
9128            assert_eq!(d.source, "one two three\n", "{tag}: a kill takes one undo");
9129        }
9130    }
9131
9132    #[test]
9133    fn select_block_grabs_the_whole_paragraph_from_any_wrapped_row() {
9134        // Regression: triple-click used move_home/move_end over visual rows, so
9135        // it only worked on a paragraph's first row (a wrap-boundary offset maps
9136        // to the earlier row). select_block_at reads the AST, so every offset in
9137        // the paragraph selects the whole thing.
9138        let body = "one two three four five six seven eight\n";
9139        let mut d = doc_with("sel_block", body);
9140        d.view = View::Wysiwyg;
9141        d.build_visual(12); // force the paragraph to wrap into several rows
9142        assert!(d.vmap.num_rows() > 1, "test needs a wrapped paragraph");
9143        let para = (0, "one two three four five six seven eight".len());
9144        for off in [0usize, 8, 19, 28, 38] {
9145            d.caret = 0;
9146            d.anchor = None;
9147            d.select_block_at(off);
9148            assert_eq!(
9149                d.selection(),
9150                Some(para),
9151                "offset {off} should select the paragraph"
9152            );
9153        }
9154    }
9155
9156    #[test]
9157    fn select_block_uses_content_span_for_a_heading() {
9158        let mut d = doc_with("sel_head", "# Title\n\nbody\n");
9159        d.select_block_at(4); // inside "Title"
9160        // content_span excludes the "# " marker.
9161        assert_eq!(d.selected_text(), Some("Title"));
9162        d.select_block_at(10); // inside "body"
9163        assert_eq!(d.selected_text(), Some("body"));
9164    }
9165
9166    #[test]
9167    fn select_all_spans_the_document() {
9168        let mut d = doc_with("sel_all", "abc\n\ndef\n");
9169        d.select_all();
9170        assert_eq!(d.selection(), Some((0, d.source.len())));
9171    }
9172
9173    #[test]
9174    fn select_word_at_picks_the_surrounding_word() {
9175        let mut d = doc_with("sel_word", "hello world\n");
9176        d.select_word_at(8); // inside "world"
9177        assert_eq!(d.selection(), Some((6, 11)));
9178        // Double-clicking at end-of-word still grabs the word to its left.
9179        d.select_word_at(5); // the space between the words
9180        assert_eq!(d.selection(), Some((5, 6)));
9181    }
9182
9183    #[test]
9184    fn word_helpers_respect_utf8_boundaries() {
9185        // "café" is 5 bytes ('é' is two); motion must land on char boundaries.
9186        assert_eq!(
9187            golden("utf8", "|café ok", |d| d.move_word_right(false)),
9188            "café| ok"
9189        );
9190        assert_eq!(golden("utf8b", "café |ok", |d| d.delete_word_back()), "|ok");
9191    }
9192
9193    #[test]
9194    fn typing_inserts_at_the_caret_and_advances_it() {
9195        let mut d = doc_with("type", "hello\n");
9196        d.insert("Hi ");
9197        assert_eq!(d.source, "Hi hello\n");
9198        assert_eq!(d.caret, 3);
9199        assert!(d.dirty);
9200    }
9201
9202    #[test]
9203    fn backspace_deletes_the_char_before_the_caret() {
9204        let mut d = doc_with("bs", "hello\n");
9205        d.caret = 3; // after "hel"
9206        d.backspace();
9207        assert_eq!(d.source, "helo\n");
9208        assert_eq!(d.caret, 2);
9209    }
9210
9211    #[test]
9212    fn typing_replaces_the_selection() {
9213        let mut d = doc_with("replace", "a word b\n");
9214        d.anchor = Some(2);
9215        d.caret = 6; // "word" selected
9216        d.insert("X");
9217        assert_eq!(d.source, "a X b\n");
9218        assert_eq!(d.caret, 3);
9219        assert_eq!(d.anchor, None);
9220    }
9221
9222    #[test]
9223    fn toggle_bold_wraps_then_unwraps_the_selection() {
9224        let mut d = doc_with("bold", "a word b\n");
9225        d.anchor = Some(2);
9226        d.caret = 6;
9227        d.toggle(InlineKind::Strong);
9228        assert_eq!(d.source, "a **word** b\n");
9229        // The toggled region stays selected, so a second toggle reverses it.
9230        d.toggle(InlineKind::Strong);
9231        assert_eq!(d.source, "a word b\n");
9232        d.toggle(InlineKind::Strong);
9233        assert_eq!(d.source, "a **word** b\n");
9234    }
9235
9236    #[test]
9237    fn toggle_code_wraps_then_unwraps_the_selection() {
9238        let mut d = doc_with("code_rt", "a word b\n");
9239        d.anchor = Some(2);
9240        d.caret = 6;
9241        d.toggle(InlineKind::Verbatim);
9242        assert_eq!(d.source, "a `word` b\n");
9243        d.toggle(InlineKind::Verbatim);
9244        assert_eq!(d.source, "a word b\n");
9245    }
9246
9247    #[test]
9248    fn sticky_bold_with_no_selection_wraps_the_next_typed_text() {
9249        // ⌘b at a bare caret, then type: the text comes out bold with no
9250        // selection ever made — the word-processor "start bold here" gesture.
9251        let mut d = doc_with("sticky_wrap", "xy\n");
9252        d.caret = 1; // between x and y
9253        d.toggle(InlineKind::Strong);
9254        assert_eq!(d.source, "xy\n", "arming a mark must not edit the document");
9255        d.insert("A");
9256        assert_eq!(d.source, "x**A**y\n");
9257    }
9258
9259    #[test]
9260    fn sticky_bold_lights_the_toolbar_before_any_typing() {
9261        // The button must light the instant ⌘b is pressed, or the mode is
9262        // invisible until the first character lands.
9263        let mut d = doc_with("sticky_light", "xy\n");
9264        d.caret = 1;
9265        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9266        d.toggle(InlineKind::Strong);
9267        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9268    }
9269
9270    #[test]
9271    fn sticky_bold_toggled_off_types_normally_again() {
9272        // ⌘b, type, ⌘b, type: the first run is bold, the second is not — all
9273        // in the flow of typing, the exact sequence the user described.
9274        let mut d = doc_with("sticky_off", "\n");
9275        d.caret = 0;
9276        d.toggle(InlineKind::Strong);
9277        d.insert("a");
9278        d.insert("b"); // continues inside the run, no re-arming
9279        assert_eq!(d.source, "**ab**\n");
9280        d.toggle(InlineKind::Strong); // ⌘b again — shed bold
9281        d.insert("c");
9282        assert_eq!(d.source, "**ab**c\n");
9283    }
9284
9285    #[test]
9286    fn continued_typing_after_a_sticky_run_stays_in_the_run() {
9287        // Once a mark is realised the caret sits inside the run, so plain typing
9288        // extends it rather than starting a second, adjacent bold span.
9289        let mut d = doc_with("sticky_cont", "\n");
9290        d.caret = 0;
9291        d.toggle(InlineKind::Emph);
9292        d.insert("h");
9293        d.insert("i");
9294        assert_eq!(d.source, "*hi*\n");
9295    }
9296
9297    #[test]
9298    fn moving_the_caret_disarms_a_sticky_mark() {
9299        // Arming a mark and then moving away must not style text elsewhere.
9300        let mut d = doc_with("sticky_disarm", "xy\n");
9301        d.caret = 0;
9302        d.toggle(InlineKind::Strong);
9303        d.move_right(false); // caret 0 → 1, disarms
9304        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9305        d.insert("A");
9306        assert_eq!(d.source, "xAy\n", "the mark must not follow the caret");
9307    }
9308
9309    #[test]
9310    fn stacked_sticky_marks_apply_together() {
9311        // ⌘b then ⌘i before typing: the text comes out both bold and italic.
9312        let mut d = doc_with("sticky_stack", "\n");
9313        d.caret = 0;
9314        d.toggle(InlineKind::Strong);
9315        d.toggle(InlineKind::Emph);
9316        d.insert("x");
9317        // Land the caret on the styled character and confirm both marks are live.
9318        d.anchor = Some(d.source.find('x').unwrap());
9319        d.caret = d.anchor.unwrap() + 1;
9320        let marks = d.active_inline_marks();
9321        assert!(marks.contains(InlineKind::Strong), "bold: {}", d.source);
9322        assert!(marks.contains(InlineKind::Emph), "italic: {}", d.source);
9323    }
9324
9325    // ── the mark-edge rule (see `Doc::splice`) ───────────────────────────────
9326
9327    #[test]
9328    fn a_space_typed_in_a_bold_run_never_leaves_the_delimiters_showing() {
9329        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, "hey".
9330        // The space inside the run made `**bold **`, which is *not* bold — four
9331        // literal asterisks — so the rich view drew them, correctly and
9332        // uselessly, until the next character happened to close the run again.
9333        let mut d = wysiwyg_doc("edge_typing", "a \n");
9334        d.caret = 2;
9335        d.toggle(InlineKind::Strong);
9336        for c in "bold".chars() {
9337            d.insert(&c.to_string());
9338        }
9339        assert_eq!(d.source, "a **bold**\n");
9340        d.insert(" ");
9341        assert_eq!(
9342            d.source, "a **bold** \n",
9343            "the space belongs outside the run"
9344        );
9345        assert!(
9346            d.active_inline_marks().contains(InlineKind::Strong),
9347            "bold is still what's being typed, so the button stays lit"
9348        );
9349        // What the writer is looking at while all this happens: their words.
9350        d.build_visual(80);
9351        let drawn: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
9352        assert_eq!(drawn, "a bold ", "no delimiter ever surfaces: {}", d.source);
9353        for c in "hey".chars() {
9354            d.insert(&c.to_string());
9355        }
9356        assert_eq!(
9357            d.source, "a **bold hey**\n",
9358            "one bold phrase, not two runs"
9359        );
9360    }
9361
9362    #[test]
9363    fn typing_past_a_space_can_still_leave_the_bold_behind() {
9364        // The other half: the marks stay armed across the space, so ⌘b turns
9365        // them off again there and the next word is plain — the run isn't
9366        // rejoined by a caret that was told not to.
9367        let mut d = wysiwyg_doc("edge_shed", "\n");
9368        d.caret = 0;
9369        d.toggle(InlineKind::Strong);
9370        for c in "bold ".chars() {
9371            d.insert(&c.to_string());
9372        }
9373        assert_eq!(d.source, "**bold** \n");
9374        d.toggle(InlineKind::Strong);
9375        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
9376        d.insert("x");
9377        assert_eq!(d.source, "**bold** x\n");
9378    }
9379
9380    #[test]
9381    fn a_space_typed_first_of_all_still_leaves_the_mark_armed() {
9382        // ⌘b and then a space before any word: the space is not marked (nothing
9383        // is), and the word after it is.
9384        let mut d = wysiwyg_doc("edge_space_first", "a\n");
9385        d.caret = 1;
9386        d.toggle(InlineKind::Strong);
9387        d.insert(" ");
9388        assert_eq!(d.source, "a \n");
9389        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9390        d.insert("b");
9391        assert_eq!(d.source, "a **b**\n");
9392    }
9393
9394    #[test]
9395    fn a_space_typed_at_either_edge_of_an_existing_mark_steps_outside_it() {
9396        let mut d = wysiwyg_doc("edge_tail", "x **bold**\n");
9397        d.caret = 8; // the caret's home at the end of the run's text
9398        d.insert(" ");
9399        assert_eq!(
9400            d.source, "x **bold** \n",
9401            "the space lands past the delimiters"
9402        );
9403        assert_eq!(d.caret, 11, "and the caret stands past it, outside the run");
9404
9405        let mut d = wysiwyg_doc("edge_head", "x **bold** y\n");
9406        d.caret = 4; // in front of the "b"
9407        d.insert(" ");
9408        assert_eq!(d.source, "x  **bold** y\n");
9409        assert_eq!(d.caret, 3, "in front of the run, where the space was typed");
9410    }
9411
9412    #[test]
9413    fn a_delete_that_backs_a_space_onto_a_delimiter_moves_the_delimiter() {
9414        // Backspace over the last letter of a bold phrase.
9415        let mut d = wysiwyg_doc("edge_bksp", "a **bold h**\n");
9416        d.caret = 10; // past the "h"
9417        d.backspace();
9418        assert_eq!(d.source, "a **bold** \n");
9419        assert_eq!(d.caret, 11, "the caret keeps the place on screen it had");
9420        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9421        d.insert("x");
9422        assert_eq!(d.source, "a **bold x**\n", "and typing rejoins the run");
9423    }
9424
9425    #[test]
9426    fn deleting_the_last_of_a_run_takes_its_delimiters_with_it() {
9427        // `**b**` with the `b` gone is `****`: two delimiters with nothing to
9428        // mark, which is only text. The marks live on in the caret instead.
9429        let mut d = wysiwyg_doc("edge_empty", "a **b** c\n");
9430        d.caret = 5;
9431        d.backspace();
9432        assert_eq!(d.source, "a  c\n");
9433        assert!(d.active_inline_marks().contains(InlineKind::Strong));
9434        d.insert("x");
9435        assert_eq!(d.source, "a **x** c\n");
9436    }
9437
9438    #[test]
9439    fn typing_over_a_whole_bold_word_keeps_it_bold() {
9440        let mut d = wysiwyg_doc("edge_replace", "a **bold** c\n");
9441        d.anchor = Some(4);
9442        d.caret = 8; // the word, not its delimiters
9443        d.insert("x");
9444        assert_eq!(d.source, "a **x** c\n");
9445    }
9446
9447    #[test]
9448    fn a_code_span_keeps_the_space_it_is_given() {
9449        // Backticks are not whitespace-sensitive the way `**` is: `` `code ` ``
9450        // is still verbatim, so nothing is re-spelt. The repair asks the parser
9451        // rather than a table of kinds, and this is the answer it gets.
9452        let mut d = wysiwyg_doc("edge_code", "a `code` c\n");
9453        d.caret = 7;
9454        d.insert(" ");
9455        assert_eq!(d.source, "a `code ` c\n");
9456    }
9457
9458    #[test]
9459    fn a_delete_from_a_runs_outer_edge_reaches_into_the_run() {
9460        // A run's closing delimiter has a caret home on each side of it, one
9461        // column apart on screen — and a plain ← off the space after a bold word
9462        // lands on the outer one. The character drawn behind the caret there is
9463        // still the last letter of the phrase, so that is what Backspace takes;
9464        // the byte behind it is a `*` nobody can see.
9465        let mut d = wysiwyg_doc("edge_outer_close", "**bold** x\n");
9466        d.caret = 9;
9467        d.move_left(false);
9468        assert_eq!(d.caret, 8, "← rests past the delimiters, not inside them");
9469        d.backspace();
9470        assert_eq!(
9471            d.source, "**bol** x\n",
9472            "a letter of the phrase, not its `*`"
9473        );
9474        assert_eq!(d.caret, 5);
9475
9476        // And the mirror in front of the opening delimiter, where Delete's
9477        // character is the first letter of the run.
9478        let mut d = wysiwyg_doc("edge_outer_open", "x**bold**\n");
9479        d.caret = 1;
9480        d.delete_forward();
9481        assert_eq!(d.source, "x**old**\n");
9482        assert_eq!(d.caret, 3, "inside the run, in front of what is left of it");
9483    }
9484
9485    #[test]
9486    fn a_delete_at_a_run_edge_never_eats_a_delimiter() {
9487        // The byte beside the caret at either edge of a bold word is a `*` the
9488        // rich view draws nothing for. Taking it is not the character delete the
9489        // key was pressed for — it unspells the run and puts a literal asterisk
9490        // on screen (`a *bold** c`). The visible character is the one that goes.
9491        let mut d = wysiwyg_doc("edge_open_bksp", "a **bold** c\n");
9492        d.caret = 4; // in front of the "b"
9493        d.backspace();
9494        assert_eq!(d.source, "a**bold** c\n", "the space goes, the run stands");
9495
9496        let mut d = wysiwyg_doc("edge_close_del", "a **bold** c\n");
9497        d.caret = 8; // past the "d"
9498        d.delete_forward();
9499        assert_eq!(d.source, "a **bold**c\n");
9500        assert_eq!(d.caret, 8, "and the caret stays inside the run");
9501        d.insert("x");
9502        assert_eq!(d.source, "a **boldx**c\n");
9503
9504        // A code span's backticks are hidden the same way, so they are covered
9505        // by the same rule and not by a list of kinds.
9506        let mut d = wysiwyg_doc("edge_open_code", "a `code` c\n");
9507        d.caret = 3;
9508        d.backspace();
9509        assert_eq!(d.source, "a`code` c\n");
9510    }
9511
9512    #[test]
9513    fn the_source_view_deletes_the_delimiter_byte_it_is_shown() {
9514        // The asterisks are on the screen there and the caret can stand between
9515        // them, so a delete takes exactly the byte it is aimed at.
9516        let mut d = doc_with("edge_open_src", "a **bold** c\n");
9517        d.caret = 4;
9518        d.backspace();
9519        assert_eq!(d.source, "a *bold** c\n");
9520
9521        let mut d = doc_with("edge_close_src", "a **bold** c\n");
9522        d.caret = 8;
9523        d.delete_forward();
9524        assert_eq!(d.source, "a **bold* c\n");
9525    }
9526
9527    #[test]
9528    fn backspacing_the_space_out_of_a_bold_phrase_leaves_the_caret_in_it() {
9529        // The reported bug, keystroke for keystroke: ⌘b, "bold", space, Backspace.
9530        // The space had stepped outside the run (the mark-edge rule), taking the
9531        // caret with it, so the delete put it back down on the far side of the
9532        // closing `**` — one place on screen, and the wrong side of it. Typing
9533        // came out plain and the toolbar went dark, with nothing to see.
9534        let mut d = wysiwyg_doc("edge_bksp_space", "\n");
9535        d.caret = 0;
9536        d.toggle(InlineKind::Strong);
9537        for c in "bold".chars() {
9538            d.insert(&c.to_string());
9539        }
9540        d.insert(" ");
9541        assert_eq!(d.source, "**bold** \n");
9542        d.backspace();
9543        assert_eq!(
9544            d.source, "**bold**\n",
9545            "the space goes, the delimiters stay"
9546        );
9547        assert_eq!(d.caret, 6, "and the caret comes back inside the run");
9548        assert!(
9549            d.active_inline_marks().contains(InlineKind::Strong),
9550            "so the button is still lit"
9551        );
9552        d.insert("x");
9553        assert_eq!(
9554            d.source, "**boldx**\n",
9555            "and the next character is still bold"
9556        );
9557    }
9558
9559    #[test]
9560    fn a_second_backspace_there_deletes_a_letter_of_the_phrase() {
9561        // What the stranded caret did next: the byte behind it was the closing
9562        // `*`, so a second press took that instead of a letter — `**bold*`, the
9563        // styling gone and an asterisk on the screen where the word had been.
9564        let mut d = wysiwyg_doc("edge_bksp_twice", "\n");
9565        d.caret = 0;
9566        d.toggle(InlineKind::Strong);
9567        for c in "bold ".chars() {
9568            d.insert(&c.to_string());
9569        }
9570        assert_eq!(d.source, "**bold** \n");
9571        d.backspace();
9572        d.backspace();
9573        assert_eq!(d.source, "**bol**\n", "the delete lands inside the run");
9574        assert_eq!(d.caret, 5);
9575    }
9576
9577    #[test]
9578    fn a_delete_that_ends_at_a_nested_run_settles_inside_every_delimiter() {
9579        // `***both***` closes two runs with one stack of asterisks: the caret has
9580        // to walk in through all of them, or it lands between the emph and the
9581        // strong and types half-marked.
9582        let mut d = wysiwyg_doc("edge_bksp_nested", "***both*** \n");
9583        d.caret = 11;
9584        d.backspace();
9585        assert_eq!(d.source, "***both***\n");
9586        assert_eq!(d.caret, 7, "past the last letter, inside both runs");
9587        d.insert("x");
9588        assert_eq!(d.source, "***bothx***\n");
9589    }
9590
9591    #[test]
9592    fn a_delete_that_ends_mid_run_leaves_the_caret_where_it_fell() {
9593        // The settle only moves a caret a run actually closed over. Ordinary
9594        // deletes — inside a run, or in plain prose — are untouched.
9595        let mut d = wysiwyg_doc("edge_bksp_mid", "a **bold** c\n");
9596        d.caret = 8;
9597        d.backspace();
9598        assert_eq!(d.source, "a **bol** c\n");
9599        assert_eq!(d.caret, 7);
9600
9601        let mut d = wysiwyg_doc("edge_bksp_plain", "plain\n");
9602        d.caret = 5;
9603        d.backspace();
9604        assert_eq!(d.source, "plai\n");
9605        assert_eq!(d.caret, 4);
9606    }
9607
9608    #[test]
9609    fn the_source_view_leaves_a_delete_where_it_landed() {
9610        // The delimiters are on the screen there, so the offset past them is a
9611        // place the caret can be seen to be — nothing to settle.
9612        let mut d = doc_with("edge_bksp_src", "**bold** \n");
9613        d.caret = 9;
9614        d.backspace();
9615        assert_eq!(d.source, "**bold**\n");
9616        assert_eq!(d.caret, 8);
9617    }
9618
9619    #[test]
9620    fn the_mark_edge_rule_clears_every_delimiter_of_a_nested_run() {
9621        // `***both***` closes two runs with one stack of asterisks; a space that
9622        // clears only the inner one lands against the outer's and breaks that
9623        // instead.
9624        let mut d = wysiwyg_doc("edge_nested", "a ***both***\n");
9625        d.caret = 9;
9626        d.insert(" ");
9627        assert_eq!(d.source, "a ***both*** \n");
9628        assert_eq!(d.caret, 13);
9629        d.insert("x");
9630        assert_eq!(d.source, "a ***both x***\n");
9631    }
9632
9633    #[test]
9634    fn the_mark_edge_repair_undoes_with_the_keystroke_that_caused_it() {
9635        // The delimiter shuffle is not an edit the writer made, so it is not a
9636        // step they have to undo past.
9637        let mut d = wysiwyg_doc("edge_undo", "a **bold**\n");
9638        d.caret = 8;
9639        d.insert(" ");
9640        assert_eq!(d.source, "a **bold** \n");
9641        d.undo();
9642        assert_eq!(d.source, "a **bold**\n");
9643    }
9644
9645    #[test]
9646    fn the_source_view_types_the_space_where_it_was_asked_to() {
9647        // The rule is a rich-view courtesy. In the source view the delimiters are
9648        // on the screen and the user is editing the bytes they can see.
9649        let mut d = doc_with("edge_src", "a **bold** c\n");
9650        d.caret = 8;
9651        d.insert(" ");
9652        assert_eq!(d.source, "a **bold ** c\n");
9653    }
9654
9655    #[test]
9656    fn toggling_a_mark_over_a_selection_leaves_its_edge_whitespace_out() {
9657        // Double-clicking a word takes the space after it; bolding that must not
9658        // spell `**word **`, which is not bold at all.
9659        let mut d = wysiwyg_doc("edge_sel", "a word b\n");
9660        d.anchor = Some(2);
9661        d.caret = 7; // "word "
9662        d.toggle(InlineKind::Strong);
9663        assert_eq!(d.source, "a **word** b\n");
9664        d.toggle(InlineKind::Strong);
9665        assert_eq!(d.source, "a word b\n");
9666        d.toggle(InlineKind::Strong);
9667        assert_eq!(
9668            d.source, "a **word** b\n",
9669            "reapplying the mark must not wrap stale delimiter offsets"
9670        );
9671        // And a selection of nothing but whitespace has no word to mark.
9672        let mut d = wysiwyg_doc("edge_sel_ws", "a word b\n");
9673        d.anchor = Some(6);
9674        d.caret = 7;
9675        d.toggle(InlineKind::Strong);
9676        assert_eq!(d.source, "a word b\n");
9677        assert!(d.status.is_some());
9678    }
9679
9680    #[test]
9681    fn set_block_turns_a_paragraph_into_a_heading_at_the_caret() {
9682        let mut d = doc_with("head_set", "hello\n");
9683        d.caret = 2; // caret inside the paragraph, no selection
9684        d.set_block(BlockKind::Heading(1));
9685        assert_eq!(d.source, "# hello\n");
9686    }
9687
9688    #[test]
9689    fn set_block_heading_works_in_wysiwyg_view() {
9690        // The app defaults to WYSIWYG; the caret is a source offset either way.
9691        let mut d = wysiwyg_doc("head_wys", "hello\n");
9692        d.caret = 2;
9693        d.set_block(BlockKind::Heading(1));
9694        assert_eq!(d.source, "# hello\n");
9695    }
9696
9697    #[test]
9698    fn toggle_heading_applies_switches_and_reverts() {
9699        let mut d = doc_with("head_toggle", "hello\n");
9700        d.caret = 2;
9701        d.toggle_heading(1);
9702        assert_eq!(d.source, "# hello\n"); // paragraph → H1
9703        d.toggle_heading(2);
9704        assert_eq!(d.source, "## hello\n"); // H1 → H2 (different level switches)
9705        d.toggle_heading(2);
9706        assert_eq!(d.source, "hello\n"); // same level reverts to paragraph
9707    }
9708
9709    #[test]
9710    fn preserve_enter_at_a_line_end_lands_the_caret_on_the_new_blank_line() {
9711        // Regression: Enter at the end of a soft-break line (mid-paragraph) opened
9712        // the blank line but the caret rendered on the *next* line, because the
9713        // separator was a non-navigable decoration row. In Preserve flow that
9714        // blank line is a real caret home — the caret must resolve onto it, and
9715        // typing there makes the soft break that continues the paragraph.
9716        let src = "line one:\nsecond line\n";
9717        let mut d = wysiwyg_doc("pre_enter_lineend", src);
9718        d.set_line_flow(LineFlow::Preserve);
9719        d.build_visual_unwrapped(); // the GUI path (pixel-wrapped)
9720        d.caret = 9; // the visual end of row 0, at the soft-break '\n'
9721        d.newline();
9722        d.build_visual_unwrapped();
9723        assert_eq!(d.source, "line one:\n\nsecond line\n");
9724        assert_eq!(
9725            d.caret, 10,
9726            "caret sits on the new blank line, not the next line"
9727        );
9728        // The blank line is row 1, and the caret resolves onto it — not row 2.
9729        assert_eq!(
9730            d.vmap.pos_of_offset(10),
9731            (1, 0),
9732            "caret renders on the blank row"
9733        );
9734        assert!(
9735            !d.vmap.rows[1].decoration,
9736            "the blank line is navigable in Preserve"
9737        );
9738        // Typing there makes a soft break: one paragraph, three lines.
9739        d.insert("new clause,");
9740        assert_eq!(d.source, "line one:\nnew clause,\nsecond line\n");
9741    }
9742
9743    #[test]
9744    fn preserve_enter_makes_a_soft_break_not_a_paragraph() {
9745        // Mid-paragraph: Enter splits the line with a single `\n`, a soft break
9746        // that keeps it one paragraph — where Fold would open a second paragraph.
9747        let mut d = wysiwyg_doc("pre_enter_mid", "abcdef\n");
9748        d.set_line_flow(LineFlow::Preserve);
9749        d.caret = 3;
9750        d.newline();
9751        assert_eq!(d.source, "abc\ndef\n", "mid-line Enter is a soft break");
9752
9753        // End-of-paragraph: Enter then typing continues the same paragraph on a
9754        // new line (a soft break), not a fresh paragraph.
9755        let mut d = wysiwyg_doc("pre_enter_end", "abc\n");
9756        d.set_line_flow(LineFlow::Preserve);
9757        d.caret = 3;
9758        d.newline();
9759        d.insert("def");
9760        assert_eq!(
9761            d.source, "abc\ndef\n",
9762            "end-of-line Enter + typing is a soft break"
9763        );
9764    }
9765
9766    #[test]
9767    fn preserve_double_enter_still_makes_a_paragraph() {
9768        // Two Enters in a row promote to a real paragraph break: the second lands
9769        // on the blank line the first opened and takes the empty-line branch.
9770        let mut d = wysiwyg_doc("pre_enter_dbl", "abc\n");
9771        d.set_line_flow(LineFlow::Preserve);
9772        d.caret = 3;
9773        d.newline();
9774        d.newline();
9775        d.insert("def");
9776        assert_eq!(
9777            d.source, "abc\n\ndef\n",
9778            "double Enter is a paragraph break"
9779        );
9780    }
9781
9782    #[test]
9783    fn preserve_backspace_joins_across_a_soft_break() {
9784        // Backspace is the symmetric undo of a Preserve Enter: over the `\n` of a
9785        // soft break it deletes the single newline and joins the two lines.
9786        let mut d = wysiwyg_doc("pre_bs", "abc\ndef\n");
9787        d.set_line_flow(LineFlow::Preserve);
9788        d.build_visual(80);
9789        d.caret = 4; // start of "def", just past the soft break
9790        d.backspace();
9791        assert_eq!(
9792            d.source, "abcdef\n",
9793            "Backspace joins across the soft break"
9794        );
9795        assert_eq!(d.caret, 3, "caret lands where the lines meet");
9796    }
9797
9798    #[test]
9799    fn fold_enter_still_starts_a_new_paragraph() {
9800        // The default flow is unchanged: a lone `\n` would render as an invisible
9801        // space, so Enter keeps opening the paragraph break that actually shows.
9802        let mut d = wysiwyg_doc("fold_enter", "abcdef\n");
9803        d.caret = 3;
9804        d.newline();
9805        assert_eq!(
9806            d.source, "abc\n\ndef\n",
9807            "Fold mid-line Enter is a paragraph break"
9808        );
9809    }
9810
9811    #[test]
9812    fn wysiwyg_one_enter_starts_a_new_paragraph() {
9813        // Regression: one Enter left the caret between the two newlines, so typing
9814        // made a soft break (one paragraph) and you needed a second Enter.
9815        let mut d = wysiwyg_doc("wys_enter", "abc\n");
9816        d.caret = 3;
9817        d.newline();
9818        d.insert("def");
9819        assert_eq!(d.source, "abc\n\ndef\n"); // two paragraphs, not "abc\ndef\n"
9820    }
9821
9822    #[test]
9823    fn enter_at_the_end_of_a_bold_run_keeps_its_closing_delimiter_attached() {
9824        // Regression: Enter at the caret's natural End-of-line resting place
9825        // after a bold run with nothing following it (on screen: right after
9826        // "bold", before the hidden closing "**") spliced the paragraph break
9827        // at that very byte offset — which sits *before* the closing "**" in
9828        // the source, since the delimiter is hidden and emits no glyph of its
9829        // own for `push_row`'s "end of row" fallback to count. That severed the
9830        // mark: "**bold**\n" became "**bold\n\n**\n", stranding the closing
9831        // "**" alone on the new line instead of leaving "**bold**" intact with
9832        // a fresh empty paragraph after it.
9833        let mut d = wysiwyg_doc("bold_eol_enter", "**bold**\n");
9834        d.move_end(false); // the WYSIWYG End key, from caret 0
9835        assert_eq!(
9836            d.caret, 6,
9837            "caret rests right after \"bold\", before the hidden \"**\""
9838        );
9839        d.newline();
9840        assert!(
9841            d.source.starts_with("**bold**"),
9842            "the closing ** must stay attached to \"bold\": got {:?}",
9843            d.source
9844        );
9845        assert_eq!(
9846            d.source, "**bold**\n\n\n",
9847            "a fresh empty paragraph follows the still-intact bold run"
9848        );
9849    }
9850
9851    #[test]
9852    fn source_view_enter_is_a_single_newline() {
9853        let mut d = doc_with("src_enter", "abc\n");
9854        d.caret = 3;
9855        d.newline();
9856        assert_eq!(d.source, "abc\n\n");
9857    }
9858
9859    #[test]
9860    fn heading_applies_at_the_end_of_a_paragraph() {
9861        // The caret at a line end sits at the doc level; set_block must still find
9862        // the block on that line.
9863        let mut d = doc_with("head_end", "abc\n");
9864        d.caret = 3; // end of "abc"
9865        d.toggle_heading(1);
9866        assert_eq!(d.source, "# abc\n");
9867    }
9868
9869    #[test]
9870    fn heading_on_an_empty_new_paragraph_creates_one() {
9871        let mut d = wysiwyg_doc("head_empty", "abc\n");
9872        d.caret = 3;
9873        d.newline(); // caret now on a fresh, empty paragraph
9874        d.toggle_heading(1);
9875        d.insert("Title");
9876        assert!(d.source.contains("# Title"), "got {:?}", d.source);
9877    }
9878
9879    #[test]
9880    fn a_heading_typed_on_a_blank_line_keeps_the_caret_on_its_own_row() {
9881        // The reported bug, end to end: click a blank line with another one under
9882        // it, press H1, type. The text landed in the heading and the caret's
9883        // offset was right (the source view drew it there), but the rich view
9884        // drew it two rows lower, on the trailing blank line — the empty `# `
9885        // heading had left every row below it short by the marker's two bytes,
9886        // and the blank line ended up claiming the heading's own end offset.
9887        let mut d = wysiwyg_doc("head_blank", "one\n\ntwo\n\n\n\n");
9888        d.build_visual_unwrapped();
9889        d.caret = d.vmap.offset_of_pos(4, 0); // the first of the two blank lines
9890        d.toggle_heading(1);
9891        for c in "title".chars() {
9892            d.insert(&c.to_string());
9893            d.build_visual_unwrapped(); // as a frontend does, one frame per key
9894        }
9895        assert_eq!(d.source, "one\n\ntwo\n\n# title\n\n");
9896        assert_eq!(
9897            d.caret_pos(),
9898            (4, 5),
9899            "the caret draws at the end of the heading"
9900        );
9901    }
9902
9903    #[test]
9904    fn clicking_an_empty_heading_types_after_its_marker() {
9905        // The same anchor from the other side: the empty heading's row is its own
9906        // caret home, so a click on it must land past the hidden `# `. Landing in
9907        // front of the hashes made the first keystroke un-heading the line.
9908        let mut d = wysiwyg_doc("head_click", "# \n");
9909        d.build_visual_unwrapped();
9910        d.caret = d.vmap.offset_of_pos(0, 0);
9911        d.insert("x");
9912        assert_eq!(d.source, "# x\n");
9913    }
9914
9915    #[test]
9916    fn wysiwyg_enter_after_a_heading_makes_a_paragraph() {
9917        let mut d = wysiwyg_doc("head_enter", "# Title\n");
9918        d.caret = 7; // end of the heading
9919        d.newline();
9920        d.insert("body");
9921        assert_eq!(d.source, "# Title\n\nbody\n");
9922    }
9923
9924    #[test]
9925    fn wysiwyg_enter_continues_a_bullet_list() {
9926        let mut d = wysiwyg_doc("wys_bullet", "- item\n");
9927        d.caret = 6; // end of "item"
9928        d.newline();
9929        d.insert("two");
9930        assert_eq!(d.source, "- item\n- two\n");
9931    }
9932
9933    #[test]
9934    fn wysiwyg_enter_increments_an_ordered_list() {
9935        let mut d = wysiwyg_doc("wys_ol", "1. one\n");
9936        d.caret = 6; // end of "one"
9937        d.newline();
9938        d.insert("two");
9939        assert_eq!(d.source, "1. one\n2. two\n");
9940    }
9941
9942    #[test]
9943    fn wysiwyg_backspace_after_leaving_a_list_collapses_the_gap_cleanly() {
9944        // Regression for the "extra newline" left between a list and the paragraph
9945        // below it. Enter, Enter leaves the list on a fresh empty paragraph
9946        // (`- item\n\n\n\nnext`, a navigable blank between the two blocks); one
9947        // Backspace should then take the caret cleanly back to the end of the list
9948        // item, `- item\n\nnext`, not delete a single newline and strand it on the
9949        // odd `- item\n\n\nnext` — a blank line the eye reads as one separator but
9950        // no caret can land on. The map is rebuilt between keystrokes exactly as a
9951        // frontend does, since Backspace reads the stop table to place the delete.
9952        let mut d = wysiwyg_doc("wys_exit_bksp", "- item\n\nnext\n");
9953        d.caret = 6; // end of "item"
9954        d.newline();
9955        d.build_visual(80);
9956        d.newline(); // leave the list onto a fresh empty paragraph
9957        d.build_visual(80);
9958        assert_eq!(
9959            d.source, "- item\n\n\n\nnext\n",
9960            "double-Enter opens the empty paragraph"
9961        );
9962        d.backspace();
9963        assert_eq!(
9964            d.source, "- item\n\nnext\n",
9965            "one Backspace collapses the whole gap"
9966        );
9967        assert_eq!(
9968            d.caret, 6,
9969            "and lands the caret back at the end of the list item"
9970        );
9971    }
9972
9973    #[test]
9974    fn wysiwyg_backspace_on_stacked_blank_lines_still_removes_just_one() {
9975        // The stop-wise delete must not over-reach when there is no block boundary
9976        // to cross: two blank lines in a row are one caret stop apart, so pressing
9977        // Enter on an empty line and then Backspace removes exactly the one newline
9978        // it added — the lone-Enter / lone-Backspace symmetry, preserved.
9979        let mut d = wysiwyg_doc("wys_stack", "abc\n\n\n");
9980        d.caret = 5; // the empty paragraph the first Enter already opened
9981        d.build_visual(80);
9982        d.newline();
9983        d.build_visual(80);
9984        assert_eq!(
9985            d.source, "abc\n\n\n\n",
9986            "Enter on the blank line adds one newline"
9987        );
9988        d.backspace();
9989        assert_eq!(
9990            d.source, "abc\n\n\n",
9991            "Backspace takes back exactly that one newline"
9992        );
9993    }
9994
9995    #[test]
9996    fn wysiwyg_enter_on_an_empty_list_item_exits_the_list() {
9997        let mut d = wysiwyg_doc("wys_exit", "- a\n- \n");
9998        d.caret = 6; // end of the empty "- " item
9999        d.newline();
10000        d.insert("p");
10001        assert_eq!(d.source, "- a\n\np\n");
10002    }
10003
10004    #[test]
10005    fn wysiwyg_enter_does_not_mistake_a_setext_underline_for_a_list() {
10006        // `text\n- \n` is a setext heading — the `- ` is its underline, not a
10007        // list item, though it reads as a `- ` marker byte-for-byte. Enter must
10008        // not take the list-exit path (which would splice the `- ` away as if
10009        // leaving an empty item); the AST guard sends it to a normal break and
10010        // leaves the underline intact.
10011        let mut d = wysiwyg_doc("wys_setext", "text\n- \n");
10012        assert!(
10013            d.nodes().iter().any(|n| n.kind == Kind::Heading),
10014            "precondition: twig parses this as a heading, not a list",
10015        );
10016        d.caret = 7; // on the `- ` underline line
10017        d.newline();
10018        assert!(
10019            d.source.contains("- "),
10020            "the setext underline survives, not spliced away as a list item: {:?}",
10021            d.source,
10022        );
10023    }
10024
10025    #[test]
10026    fn wysiwyg_enter_in_a_code_block_is_a_literal_newline() {
10027        let mut d = wysiwyg_doc("wys_code", "```\nabc\n```\n");
10028        d.caret = 7; // end of "abc" inside the fence
10029        d.newline();
10030        d.insert("def");
10031        assert_eq!(d.source, "```\nabc\ndef\n```\n");
10032    }
10033
10034    #[test]
10035    fn wysiwyg_enter_continues_a_block_quote() {
10036        // Enter opens a new *paragraph* inside the quote, not a second line of
10037        // the same one. `> quote\n> more` is a soft break, which under
10038        // `LineFlow::Fold` renders as a space — the keystroke would look like it
10039        // did nothing. The quoted blank line is what makes the break visible, and
10040        // it's the same thing Enter does in running prose.
10041        let mut d = wysiwyg_doc("wys_quote", "> quote\n");
10042        d.caret = 7; // end of "quote"
10043        d.newline();
10044        d.insert("more");
10045        assert_eq!(d.source, "> quote\n>\n> more\n");
10046        // Still one quote, now holding two paragraphs — not a quote and a stray
10047        // line that fell out of it.
10048        let quotes = d
10049            .nodes()
10050            .iter()
10051            .filter(|n| n.kind == Kind::BlockQuote)
10052            .count();
10053        assert_eq!(quotes, 1);
10054    }
10055
10056    #[test]
10057    fn set_block_makes_a_heading_at_the_caret() {
10058        let mut d = doc_with("head", "Title\n\nbody\n");
10059        d.caret = 0;
10060        d.set_block(BlockKind::Heading(2));
10061        assert_eq!(d.source, "## Title\n\nbody\n");
10062        d.set_block(BlockKind::Paragraph);
10063        assert_eq!(d.source, "Title\n\nbody\n");
10064    }
10065
10066    // ── block containers (quote / list) ──────────────────────────────────────
10067
10068    #[test]
10069    fn toggle_blockquote_wraps_the_block_at_the_caret_and_reverses() {
10070        let g = |m, f: fn(&mut Doc)| golden("quote", m, f);
10071        assert_eq!(g("hel|lo\n", |d| d.toggle_blockquote()), "> hel|lo\n");
10072        assert_eq!(g("> hel|lo\n", |d| d.toggle_blockquote()), "hel|lo\n");
10073        // A caret at a line end sits at the doc level; the block is still found.
10074        assert_eq!(g("hello|\n", |d| d.toggle_blockquote()), "> hello|\n");
10075    }
10076
10077    #[test]
10078    fn toggle_blockquote_keeps_the_caret_in_a_hard_wrapped_paragraph() {
10079        // Every source line of the paragraph gets its own `> `, so a caret left
10080        // on its old byte offset falls one prefix per line above it too far
10081        // back — inside the markup it just asked for rather than in its word.
10082        assert_eq!(
10083            golden("quote_wrap", "aaa\nb|bb\nccc\n", |d| d.toggle_blockquote()),
10084            "> aaa\n> b|bb\n> ccc\n"
10085        );
10086    }
10087
10088    #[test]
10089    fn toggle_blockquote_works_in_wysiwyg_view() {
10090        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
10091        assert_eq!(
10092            g("q_wys", "hel|lo\n", |d| d.toggle_blockquote()),
10093            "> hel|lo\n"
10094        );
10095        assert_eq!(
10096            g("q_wys2", "> hel|lo\n", |d| d.toggle_blockquote()),
10097            "hel|lo\n"
10098        );
10099    }
10100
10101    #[test]
10102    fn toggle_list_makes_a_list_and_converts_between_the_kinds() {
10103        let g = |m, f: fn(&mut Doc)| golden("list", m, f);
10104        assert_eq!(g("hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
10105        assert_eq!(g("hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
10106        // The *other* kind converts in place instead of nesting, which is what
10107        // makes the two buttons one three-state control.
10108        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(true)), "1. hel|lo\n");
10109        assert_eq!(g("1. hel|lo\n", |d| d.toggle_list(false)), "- hel|lo\n");
10110        // Its own kind, over the only item the list holds, takes it off.
10111        assert_eq!(g("- hel|lo\n", |d| d.toggle_list(false)), "hel|lo\n");
10112    }
10113
10114    #[test]
10115    fn toggle_list_works_in_wysiwyg_view() {
10116        let g = |n, m, f: fn(&mut Doc)| golden_in(View::Wysiwyg, n, m, f);
10117        assert_eq!(
10118            g("l_wys", "hel|lo\n", |d| d.toggle_list(true)),
10119            "1. hel|lo\n"
10120        );
10121        assert_eq!(
10122            g("l_wys2", "1. hel|lo\n", |d| d.toggle_list(false)),
10123            "- hel|lo\n"
10124        );
10125        assert_eq!(
10126            g("l_wys3", "- hel|lo\n", |d| d.toggle_list(false)),
10127            "hel|lo\n"
10128        );
10129    }
10130
10131    #[test]
10132    fn a_list_over_a_selection_numbers_each_block_and_stays_selected() {
10133        // The selection has to grow with the markup: twig takes a container off
10134        // only a range covering every block it holds, so the second press can
10135        // reverse the first only if the result is what's selected.
10136        let mut d = doc_with("list_sel", "abc\n\ndef\n");
10137        d.select_all();
10138        d.toggle_list(true);
10139        assert_eq!(d.source, "1. abc\n\n2. def\n");
10140        assert_eq!(d.selection(), Some((0, d.source.len())));
10141        d.toggle_list(true);
10142        assert_eq!(d.source, "abc\n\ndef\n");
10143    }
10144
10145    #[test]
10146    fn toggle_blockquote_nests_a_partly_covered_quote() {
10147        // twig's rule: covering only some of a container's blocks nests, because
10148        // taking the quote off would drag its uncovered siblings out with it.
10149        let mut d = doc_with("quote_nest", "> a\n>\n> b\n");
10150        d.caret = 2; // in the first quoted paragraph only
10151        d.toggle_blockquote();
10152        assert_eq!(d.source, "> > a\n>\n> b\n");
10153    }
10154
10155    #[test]
10156    fn a_container_toggle_opens_an_empty_one_on_a_blank_line() {
10157        // A blank line used to be no block for twig to wrap —
10158        // `toggle_block_container` answered `NotFound` — so Quote and the list
10159        // buttons did nothing on the very line the H1 button works on, and leaf
10160        // lent twig a scratch paragraph to wrap and took it back out again.
10161        // twig 3.2.0 opens an empty container there itself, so what is left here
10162        // is where the caret lands: inside the marker that was just written.
10163        let mut d = doc_with("quote_blank", "\nabc\n");
10164        d.caret = 0;
10165        d.toggle_blockquote();
10166        assert_eq!(d.source, "> \nabc\n");
10167        assert_eq!(
10168            d.caret, 2,
10169            "the caret belongs inside the quote it just opened"
10170        );
10171        assert!(d.status.is_none(), "{:?}", d.status);
10172        assert!(d.dirty);
10173
10174        // And the paragraph below is still its own block: an empty container one
10175        // soft break from `abc` would take that paragraph into the quote with it.
10176        let mut d = wysiwyg_doc("quote_blank_rows", "\nabc\n");
10177        d.caret = 0;
10178        d.toggle_blockquote();
10179        d.build_visual(80);
10180        assert_eq!(drawn_rows(&d), ["│ ", "", "abc"]);
10181
10182        // The same from the other side: a blank line directly under a paragraph
10183        // earns the blank line an empty block needs, rather than being read as a
10184        // soft break inside that paragraph.
10185        let mut d = doc_with("list_blank_below", "abc\n");
10186        d.caret = 4;
10187        d.toggle_list(false);
10188        assert_eq!(d.source, "abc\n\n- ");
10189        assert_eq!(d.caret, 7);
10190    }
10191
10192    #[test]
10193    fn enter_at_the_end_of_a_quote_stays_in_the_quote() {
10194        // The gesture the rendering fix is for. `newline` inside a quote already
10195        // wrote the right source — `> a\n` becomes `> a\n>\n> \n`, twig's own
10196        // spelling — but the two marker lines it adds belonged to no node until
10197        // twig 3.2.0, so the gutter stopped at `a` and the line the writer had
10198        // just made drew as plain prose under the quote.
10199        let mut d = wysiwyg_doc("quote_enter", "> a\n");
10200        d.caret = 3; // past `a`, at the end of the quoted line
10201        d.newline();
10202        assert_eq!(d.source, "> a\n>\n> \n");
10203        d.build_visual(80);
10204        assert_eq!(drawn_rows(&d), ["│ a", "│ ", "│ "]);
10205        // And the caret is on the new line, not stranded on the old one.
10206        assert_eq!(d.caret, 8);
10207    }
10208
10209    #[test]
10210    fn opening_a_container_on_a_blank_line_is_one_undo_step() {
10211        // It was three edits — scratch, wrap, unscratch — coalesced into one, and
10212        // now it is twig's single edit. Either way one ⌘z has to put the blank
10213        // line back rather than undoing into a half-built document.
10214        for open in [
10215            &(|d: &mut Doc| d.toggle_blockquote()) as &dyn Fn(&mut Doc),
10216            &|d: &mut Doc| d.toggle_list(false),
10217            &|d: &mut Doc| d.toggle_list(true),
10218        ] {
10219            let mut d = doc_with("container_blank_undo", "a\n\n\n\nb\n");
10220            d.caret = 3;
10221            open(&mut d);
10222            assert_ne!(d.source, "a\n\n\n\nb\n");
10223            d.undo();
10224            assert_eq!(d.source, "a\n\n\n\nb\n");
10225        }
10226    }
10227
10228    #[test]
10229    fn a_container_toggle_is_one_undo_step() {
10230        let mut d = doc_with("quote_undo", "hello\n");
10231        d.caret = 3;
10232        d.insert("X"); // a typing run the structural edit must not fold into
10233        d.toggle_blockquote();
10234        assert_eq!(d.source, "> helXlo\n");
10235        d.undo();
10236        assert_eq!(d.source, "helXlo\n");
10237    }
10238
10239    // ── links ────────────────────────────────────────────────────────────────
10240
10241    #[test]
10242    fn insert_link_wraps_the_selection_and_leaves_its_text_selected() {
10243        let mut d = doc_with("link_sel", "word here\n");
10244        d.anchor = Some(0);
10245        d.caret = 4;
10246        d.insert_link("http://x.dev");
10247        assert_eq!(d.source, "[word](http://x.dev) here\n");
10248        // The text, not the destination — so a second press re-points the link
10249        // the first one made rather than nesting one inside it.
10250        assert_eq!(d.selected_text(), Some("word"));
10251        d.insert_link("http://y.dev");
10252        assert_eq!(d.source, "[word](http://y.dev) here\n");
10253        assert_eq!(d.selected_text(), Some("word"));
10254    }
10255
10256    #[test]
10257    fn insert_image_at_the_caret_spells_the_markup_and_lands_past_it() {
10258        let mut d = doc_with("img_caret", "before after\n");
10259        d.caret = 7; // between "before " and "after"
10260        d.insert_image("cat.png", "a cat");
10261        assert_eq!(d.source, "before ![a cat](cat.png)after\n");
10262        // The caret sits just past the inserted image, nothing selected.
10263        assert_eq!(d.selection(), None);
10264        assert_eq!(d.caret, 7 + "![a cat](cat.png)".len());
10265    }
10266
10267    /// The bug a real vault hit: a filename with spaces in it. Markdown ends a
10268    /// destination at the first space, so the `format!` this used to be wrote
10269    /// something that was not an image at all — and the reader saw the markup as
10270    /// text. twig owns the spelling now, and moves it into the angle form.
10271    #[test]
10272    fn insert_image_spells_a_destination_with_spaces_so_it_stays_an_image() {
10273        let mut d = doc_with("img_space", "x\n");
10274        d.caret = 0;
10275        d.insert_image("Jesus Commands the Apostles to Rest.jpg", "");
10276        assert_eq!(
10277            d.source,
10278            "![](<Jesus Commands the Apostles to Rest.jpg>)x\n"
10279        );
10280        // And it reads back as an image pointing at the unescaped path — the angle
10281        // brackets are spelling, not part of the destination.
10282        d.caret = 2;
10283        assert_eq!(
10284            d.image_destination_at_caret(),
10285            Some("Jesus Commands the Apostles to Rest.jpg".to_string())
10286        );
10287    }
10288
10289    /// A `)` in a caption or a filename must not close the image early.
10290    #[test]
10291    fn insert_image_escapes_a_paren_in_either_half() {
10292        let mut d = doc_with("img_paren", "x\n");
10293        d.caret = 0;
10294        d.insert_image("a)b.png", "");
10295        assert_eq!(d.source, "![](a\\)b.png)x\n");
10296        d.caret = 2;
10297        assert_eq!(d.image_destination_at_caret(), Some("a)b.png".to_string()));
10298    }
10299
10300    #[test]
10301    fn insert_image_uses_the_selection_as_alt_text() {
10302        let mut d = doc_with("img_sel", "caption here\n");
10303        d.anchor = Some(0);
10304        d.caret = 7; // "caption"
10305        d.insert_image("p.png", "ignored fallback");
10306        assert_eq!(d.source, "![caption](p.png) here\n");
10307    }
10308
10309    #[test]
10310    fn insert_image_with_no_alt_leaves_empty_brackets() {
10311        let mut d = doc_with("img_noalt", "\n");
10312        d.caret = 0;
10313        d.insert_image("logo.svg", "");
10314        assert_eq!(d.source, "![](logo.svg)\n");
10315    }
10316
10317    // ── move_block ─────────────────────────────────────────────────────────────
10318
10319    /// A move, then its undo: one step takes the whole thing back.
10320    fn moved(name: &str, body: &str, from: usize, to: usize) -> (String, Doc) {
10321        let mut d = doc_with(name, body);
10322        d.caret = from;
10323        let steps = d.undo_steps;
10324        d.move_block(from, to);
10325        let after = d.source.clone();
10326        assert_eq!(d.undo_steps, steps + 1, "one undo step");
10327        assert!(d.dirty);
10328        d.undo();
10329        assert_eq!(d.source, body, "one undo restores the original");
10330        (after, d)
10331    }
10332
10333    #[test]
10334    fn move_block_carries_a_paragraph_between_two_paragraphs_and_to_the_end() {
10335        let body = "a\n\nb\n\nc\n";
10336        assert_eq!(moved("mv_p1", body, 6, 3).0, "a\n\nc\n\nb\n");
10337        assert_eq!(moved("mv_p2", body, 0, body.len()).0, "b\n\nc\n\na\n");
10338        assert_eq!(moved("mv_p3", body, 3, 0).0, "b\n\na\n\nc\n");
10339    }
10340
10341    #[test]
10342    fn move_block_carries_an_image_block() {
10343        let body = "a\n\n![p](x.png)\n\nc\n";
10344        assert_eq!(moved("mv_img1", body, 4, 0).0, "![p](x.png)\n\na\n\nc\n");
10345        assert_eq!(
10346            moved("mv_img2", body, 4, body.len()).0,
10347            "a\n\nc\n\n![p](x.png)\n"
10348        );
10349    }
10350
10351    #[test]
10352    fn move_block_carries_a_table() {
10353        let body = "a\n\n| h |\n|---|\n| c |\n\nc\n";
10354        assert_eq!(
10355            moved("mv_tbl1", body, 5, 0).0,
10356            "| h |\n|---|\n| c |\n\na\n\nc\n"
10357        );
10358        assert_eq!(
10359            moved("mv_tbl2", body, 5, body.len()).0,
10360            "a\n\nc\n\n| h |\n|---|\n| c |\n"
10361        );
10362    }
10363
10364    #[test]
10365    fn move_block_carries_a_code_block() {
10366        let body = "a\n\n```rs\nx\n```\n\nc\n";
10367        assert_eq!(moved("mv_code1", body, 8, 0).0, "```rs\nx\n```\n\na\n\nc\n");
10368        assert_eq!(
10369            moved("mv_code2", body, 8, body.len()).0,
10370            "a\n\nc\n\n```rs\nx\n```\n"
10371        );
10372    }
10373
10374    #[test]
10375    fn move_block_onto_its_own_boundary_is_a_quiet_no_op() {
10376        let mut d = doc_with("mv_noop", "a\n\nb\n");
10377        let steps = d.undo_steps;
10378        d.move_block(0, 0);
10379        d.move_block(0, 3);
10380        assert_eq!(d.source, "a\n\nb\n");
10381        assert_eq!(d.undo_steps, steps);
10382        assert_eq!(d.status, None);
10383        assert!(!d.dirty);
10384    }
10385
10386    #[test]
10387    fn move_block_into_a_fence_is_refused_with_a_status() {
10388        let mut d = doc_with("mv_fence", "a\n\n```\nx\ny\n```\n");
10389        d.move_block(0, 7);
10390        assert_eq!(d.source, "a\n\n```\nx\ny\n```\n");
10391        assert!(
10392            d.status
10393                .as_deref()
10394                .is_some_and(|s| s.starts_with("move block"))
10395        );
10396    }
10397
10398    #[test]
10399    fn move_block_rides_the_caret_with_the_block() {
10400        // Down: "second" is line 0 of its block, caret 3 bytes from its end.
10401        let mut d = doc_with("mv_caret1", "first\n\nsecond\n\nthird\n");
10402        d.caret = 10; // "sec|ond"
10403        d.move_block(10, d.source.len());
10404        assert_eq!(d.source, "first\n\nthird\n\nsecond\n");
10405        assert_eq!(&d.source[d.caret..], "ond\n");
10406        // Up, across a wrapped paragraph's second line.
10407        let mut d = doc_with("mv_caret2", "first\n\nsecond\nline two\n");
10408        d.caret = 16; // "li|ne two"
10409        d.move_block(16, 0);
10410        assert_eq!(d.source, "second\nline two\n\nfirst\n");
10411        assert_eq!(&d.source[d.caret..], "ne two\n\nfirst\n");
10412        assert_eq!(d.selection(), None);
10413    }
10414
10415    #[test]
10416    fn move_block_keeps_the_caret_on_its_line_through_a_quotes_prefix() {
10417        let mut d = doc_with("mv_quote_caret", "para\n\n> a\n");
10418        d.caret = 2; // "pa|ra"
10419        d.move_block(2, 9); // after a, inside the quote
10420        assert_eq!(d.source, "> a\n>\n> para\n");
10421        assert_eq!(&d.source[d.caret..], "ra\n");
10422    }
10423
10424    #[test]
10425    fn move_block_up_and_down_step_over_siblings() {
10426        let mut d = doc_with("mv_updown", "a\n\nb\n\nc\n");
10427        d.caret = 3;
10428        d.move_block_down();
10429        assert_eq!(d.source, "a\n\nc\n\nb\n");
10430        assert_eq!(&d.source[d.caret..], "b\n");
10431        d.move_block_down();
10432        assert_eq!(d.source, "a\n\nc\n\nb\n", "nothing below the last block");
10433        assert_eq!(d.status.as_deref(), Some("move block: nothing below"));
10434        d.move_block_up();
10435        d.move_block_up();
10436        assert_eq!(d.source, "b\n\na\n\nc\n");
10437        assert_eq!(&d.source[d.caret..], "b\n\na\n\nc\n");
10438        d.move_block_up();
10439        assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
10440    }
10441
10442    #[test]
10443    fn move_block_up_and_down_reorder_list_items_with_their_children() {
10444        let mut d = doc_with("mv_items", "- a\n  - x\n- b\n- c\n");
10445        d.caret = 12; // in "b"
10446        d.move_block_up();
10447        assert_eq!(d.source, "- b\n- a\n  - x\n- c\n");
10448        assert_eq!(&d.source[d.caret..], "b\n- a\n  - x\n- c\n");
10449        d.caret = 6; // in "a"
10450        d.move_block_down();
10451        assert_eq!(d.source, "- b\n- c\n- a\n  - x\n");
10452        assert_eq!(&d.source[d.caret..], "a\n  - x\n");
10453        // A nested item leaves its list upward, as an item of the outer one.
10454        d.caret = 16; // in "x"
10455        d.move_block_up();
10456        assert_eq!(d.source, "- b\n- c\n- x\n- a\n");
10457    }
10458
10459    #[test]
10460    fn move_block_on_a_lone_item_that_stays_a_bullet_is_no_step() {
10461        let mut d = doc_with("mv_lone_item", "x\n\n- b\n\ny\n");
10462        d.caret = 5;
10463        let steps = d.undo_steps;
10464        d.move_block_up();
10465        assert_eq!(d.source, "x\n\n- b\n\ny\n");
10466        assert_eq!(
10467            d.undo_steps, steps,
10468            "a move that rewrote nothing is no undo step"
10469        );
10470        assert_eq!(d.status.as_deref(), Some("move block: nothing above"));
10471    }
10472
10473    #[test]
10474    fn move_block_up_leaves_a_container_at_its_first_block_and_down_at_its_last() {
10475        let mut d = doc_with("mv_leave", "x\n\n> a\n>\n> b\n\ny\n");
10476        d.caret = 5; // "a"
10477        d.move_block_up();
10478        assert_eq!(d.source, "x\n\na\n\n> b\n\ny\n");
10479        d.caret = 8; // "b"
10480        d.move_block_down();
10481        assert_eq!(d.source, "x\n\na\n\nb\n\ny\n");
10482        // A tail block leaves its item into the next item's tail, then the list.
10483        let mut d = doc_with("mv_tail", "- a\n\n  t\n- b\n");
10484        d.caret = 7;
10485        d.move_block_down();
10486        assert_eq!(d.source, "- a\n- b\n\n  t\n");
10487        d.move_block_down();
10488        assert_eq!(d.source, "- a\n- b\n\nt\n");
10489        // And a paragraph after a quote steps over the whole quote, not into it.
10490        let mut d = doc_with("mv_over", "x\n\n> a\n>\n> b\n\ny\n");
10491        d.caret = 14;
10492        d.move_block_up();
10493        assert_eq!(d.source, "x\n\ny\n\n> a\n>\n> b\n");
10494        assert_eq!(d.caret, 3);
10495        d.move_block_down();
10496        assert_eq!(d.status, None);
10497        assert_eq!(d.source, "x\n\n> a\n>\n> b\n\ny\n");
10498        // Above a quote that opens the document is offset 0 — before the
10499        // quote, not inside it.
10500        let mut d = doc_with("mv_over_top", "> a\n\ny\n");
10501        d.caret = 5;
10502        d.move_block_up();
10503        assert_eq!(d.source, "y\n\n> a\n");
10504        assert_eq!(d.caret, 0);
10505    }
10506
10507    #[test]
10508    fn move_block_up_from_the_first_block_of_a_quote_that_opens_the_document_leaves_it() {
10509        let mut d = doc_with("mv_top_quote", "> a\n>\n> b\n");
10510        d.caret = 2;
10511        d.move_block_up();
10512        assert_eq!(d.source, "a\n\n> b\n");
10513        assert_eq!(d.caret, 0);
10514        assert_eq!(d.status, None);
10515    }
10516
10517    #[test]
10518    fn move_block_on_a_blank_line_or_read_only_does_nothing() {
10519        let mut d = doc_with("mv_blank", "a\n\nb\n");
10520        d.caret = 2;
10521        d.move_block_down();
10522        assert_eq!(d.source, "a\n\nb\n");
10523        assert_eq!(d.status.as_deref(), Some("move block: no block here"));
10524        d.read_only = true;
10525        d.caret = 0;
10526        d.move_block_down();
10527        d.move_block(0, 5);
10528        assert_eq!(d.source, "a\n\nb\n");
10529    }
10530
10531    #[test]
10532    fn move_block_never_lands_above_hidden_frontmatter() {
10533        let mut d = wysiwyg_doc("mv_fm", "---\nt: x\n---\n\na\n\nb\n");
10534        let b = d.source.find('b').unwrap();
10535        d.move_block(b, 0);
10536        assert_eq!(d.source, "---\nt: x\n---\n\nb\n\na\n");
10537    }
10538
10539    #[test]
10540    fn block_range_at_is_the_block_a_move_picks_up() {
10541        let mut d = doc_with("mv_range", "a\n\n- b\n  - c\n\nd\n");
10542        assert_eq!(d.block_range_at(0), Some(0..1));
10543        assert_eq!(d.block_range_at(5), Some(3..12), "the item with its child");
10544        assert_eq!(d.block_range_at(10), Some(7..12), "the nested item alone");
10545        assert_eq!(d.block_range_at(2), None, "a blank line");
10546    }
10547
10548    #[test]
10549    fn drop_target_at_splits_a_block_at_its_middle_row_and_ends_below_everything() {
10550        let long = "two ".repeat(40).trim_end().to_string(); // wraps to three rows at 80
10551        let mut d = wysiwyg_doc("drop", &format!("one\n\n{long} four\n\nfive\n"));
10552        let rows = d.vmap.rows.len();
10553        assert_eq!(rows, 7, "one, gap, three wrapped rows, gap, five");
10554        assert_eq!(d.drop_target_at(0), Some(DropTarget { offset: 0, row: 0 }));
10555        let two = d.source.find("two").unwrap();
10556        assert_eq!(
10557            d.drop_target_at(1),
10558            Some(DropTarget {
10559                offset: two,
10560                row: 2
10561            }),
10562            "the gap resolves to the block under it"
10563        );
10564        assert_eq!(
10565            d.drop_target_at(2),
10566            Some(DropTarget {
10567                offset: two,
10568                row: 2
10569            })
10570        );
10571        assert_eq!(
10572            d.drop_target_at(3),
10573            Some(DropTarget {
10574                offset: two,
10575                row: 2
10576            })
10577        );
10578        let four_end = d.source.find("four").unwrap() + 4;
10579        assert_eq!(
10580            d.drop_target_at(4),
10581            Some(DropTarget {
10582                offset: four_end,
10583                row: 5
10584            })
10585        );
10586        assert_eq!(
10587            d.drop_target_at(rows + 3),
10588            Some(DropTarget {
10589                offset: d.source.len(),
10590                row: rows
10591            })
10592        );
10593        // And the offsets are ones move_block takes.
10594        let five = d.source.find("five").unwrap();
10595        let t = d.drop_target_at(2).unwrap();
10596        d.move_block(five, t.offset);
10597        assert_eq!(d.source, format!("one\n\nfive\n\n{long} four\n"));
10598    }
10599
10600    #[test]
10601    fn append_media_lands_at_the_end_as_its_own_block() {
10602        let mut d = doc_with("append_mid", "first word and more\n");
10603        d.caret = 0; // an editor nobody has tapped: the caret is at the start
10604        d.append_media(MediaKind::Image, "cat.png", "");
10605        assert_eq!(d.source, "first word and more\n\n![](cat.png)");
10606        assert_eq!(d.selection(), None);
10607        assert_eq!(d.caret, "first word and more\n\n![](cat.png)".len());
10608    }
10609
10610    #[test]
10611    fn append_media_needs_no_separator_after_a_blank_line_or_in_an_empty_document() {
10612        let mut d = doc_with("append_blank", "para\n\n");
10613        d.append_media(MediaKind::Image, "a.png", "");
10614        assert_eq!(d.source, "para\n\n![](a.png)");
10615
10616        let mut e = doc_with("append_empty", "");
10617        e.append_media(MediaKind::Image, "b.png", "");
10618        assert_eq!(e.source, "![](b.png)");
10619
10620        let mut f = doc_with("append_noeol", "no newline at end");
10621        f.anchor = Some(0);
10622        f.caret = 2; // a selection, which the verb ignores
10623        f.append_media(MediaKind::Video, "clip.mp4", "");
10624        assert_eq!(
10625            f.source,
10626            "no newline at end\n\n<video src=\"clip.mp4\" controls></video>"
10627        );
10628    }
10629
10630    #[test]
10631    fn insert_media_spells_a_video_as_html_and_reads_it_back_as_a_block() {
10632        // The round trip is the point: it's no use writing markup the reader
10633        // can't pick up again. This is the pair that only holds from twig 2.5.1
10634        // on — before it, the one-line form went in fine and came back as a
10635        // paragraph of raw tags, publishing no media at all.
10636        let mut d = doc_with("vid_rt", "\n");
10637        d.caret = 0;
10638        d.insert_media(MediaKind::Video, "clip.mp4", "a clip");
10639        assert_eq!(
10640            d.source,
10641            "<video src=\"clip.mp4\" controls>a clip</video>\n"
10642        );
10643
10644        d.build_visual(80);
10645        assert_eq!(d.vmap.media.len(), 1, "reads back as one block media");
10646        assert_eq!(d.vmap.media[0].kind, MediaKind::Video);
10647        assert_eq!(d.vmap.media[0].destination, "clip.mp4");
10648        assert_eq!(d.vmap.media[0].alt, "a clip");
10649    }
10650
10651    #[test]
10652    fn insert_media_spells_audio_with_its_own_tag() {
10653        let mut d = doc_with("aud_rt", "\n");
10654        d.caret = 0;
10655        d.insert_media(MediaKind::Audio, "take.mp3", "");
10656        assert_eq!(d.source, "<audio src=\"take.mp3\" controls></audio>\n");
10657        d.build_visual(80);
10658        assert_eq!(d.vmap.media[0].kind, MediaKind::Audio);
10659    }
10660
10661    #[test]
10662    fn insert_media_uses_the_selection_as_fallback_text() {
10663        // The same courtesy `insert_image` does with alt: select a caption,
10664        // insert, and the caption labels the thing rather than being replaced.
10665        let mut d = doc_with("vid_sel", "the talk here\n");
10666        d.anchor = Some(0);
10667        d.caret = 8; // "the talk"
10668        d.insert_media(MediaKind::Video, "talk.mp4", "ignored fallback");
10669        assert_eq!(
10670            d.source,
10671            "<video src=\"talk.mp4\" controls>the talk</video> here\n"
10672        );
10673    }
10674
10675    #[test]
10676    fn insert_media_with_an_image_kind_is_just_insert_image() {
10677        let mut d = doc_with("img_via_media", "\n");
10678        d.caret = 0;
10679        d.insert_media(MediaKind::Image, "logo.svg", "x");
10680        assert_eq!(d.source, "![x](logo.svg)\n");
10681    }
10682
10683    // ── thematic breaks ─────────────────────────────────────────────────────
10684
10685    /// The node the source parses as at `caret` — what confirms an inserted
10686    /// `---` actually reads back as a rule, not stray text or a setext heading.
10687    ///
10688    /// The *narrowest* node covering the offset. Every ancestor covers it too,
10689    /// and since twig 2.8 that includes the `doc` root, which now carries a real
10690    /// span (it reported none before, so taking the first match used to land on
10691    /// the block by luck and now always answers `"doc"`).
10692    fn kind_at(d: &mut Doc, caret: usize) -> Option<Kind> {
10693        d.nodes()
10694            .into_iter()
10695            .filter(|n| n.span.start <= caret && caret < n.span.end)
10696            .min_by_key(|n| n.span.end - n.span.start)
10697            .map(|n| n.kind)
10698    }
10699
10700    #[test]
10701    fn a_task_box_toggles_at_the_caret_and_reads_back() {
10702        let mut d = doc_with("task_toggle", "- [ ] todo\n- [x] done\n");
10703        d.caret = 8; // inside "todo"
10704        assert_eq!(d.task_checked_at_caret(), Some(false));
10705        d.toggle_task_checked();
10706        assert_eq!(d.source, "- [x] todo\n- [x] done\n");
10707        assert_eq!(d.task_checked_at_caret(), Some(true));
10708        d.toggle_task_checked();
10709        assert_eq!(d.source, "- [ ] todo\n- [x] done\n");
10710    }
10711
10712    #[test]
10713    fn a_click_toggles_a_box_without_taking_the_caret_with_it() {
10714        // The whole reason `toggle_task_at` exists apart from the caret form:
10715        // ticking a box elsewhere must not move the cursor out of what's being
10716        // typed.
10717        let mut d = doc_with("task_click", "- [ ] first\n- [ ] second\n");
10718        d.caret = 8; // inside "first"
10719        let second = d.source.find("second").unwrap();
10720        d.toggle_task_at(second);
10721        assert_eq!(d.source, "- [ ] first\n- [x] second\n");
10722        assert_eq!(d.caret, 8, "the caret stayed in the first item");
10723    }
10724
10725    #[test]
10726    fn a_plain_item_gains_and_loses_a_box() {
10727        let mut d = doc_with("task_mint", "- plain\n");
10728        d.caret = 4;
10729        assert_eq!(d.task_checked_at_caret(), None);
10730        d.toggle_task_item();
10731        assert_eq!(d.source, "- [ ] plain\n");
10732        assert_eq!(
10733            d.task_checked_at_caret(),
10734            Some(false),
10735            "a new box arrives unticked"
10736        );
10737        d.toggle_task_item();
10738        assert_eq!(d.source, "- plain\n");
10739    }
10740
10741    #[test]
10742    fn ticking_a_box_that_isnt_there_reports_rather_than_minting_one() {
10743        // `set checked` must not silently convert a bullet into a task — that is
10744        // `toggle_task_item`'s job, and twig refuses it here.
10745        let mut d = doc_with("task_none", "- plain\n");
10746        d.caret = 4;
10747        d.toggle_task_checked();
10748        assert_eq!(d.source, "- plain\n", "nothing written");
10749        assert!(
10750            d.status.is_some(),
10751            "the refusal should reach the status line"
10752        );
10753    }
10754
10755    #[test]
10756    fn a_task_item_in_a_quote_is_found_past_the_quote_marker() {
10757        let mut d = doc_with("task_quote", "> - [ ] nested\n");
10758        d.caret = d.source.find("nested").unwrap();
10759        assert_eq!(d.task_checked_at_caret(), Some(false));
10760        d.toggle_task_checked();
10761        assert_eq!(d.source, "> - [x] nested\n");
10762    }
10763
10764    #[test]
10765    fn insert_thematic_break_parts_the_paragraph_around_the_caret() {
10766        // A rule is a block, so twig's `insert_thematic_break` alone lands it
10767        // after the whole paragraph. `split_block` parts the paragraph first and
10768        // the rule is aimed at the *first* half, which is what a rule button is
10769        // understood to do — and what leaf spelled by hand until twig grew both
10770        // halves of the gesture.
10771        let mut d = doc_with("hr_mid", "before after\n");
10772        d.caret = 7; // between "before " and "after"
10773        d.insert_thematic_break();
10774        assert_eq!(d.source, "before \n\n---\n\nafter\n");
10775        assert_eq!(d.selection(), None);
10776        assert_eq!(
10777            kind_at(&mut d, "before \n\n".len()),
10778            Some(Kind::ThematicBreak)
10779        );
10780    }
10781
10782    #[test]
10783    fn insert_thematic_break_at_a_paragraph_s_end_splits_nothing() {
10784        // At the end there is nothing to part, and a split there writes the
10785        // separator anyway — a blank line and the empty slot the next paragraph
10786        // would fill — which the rule then landed above: `para\n\n* * *\n\n\n`,
10787        // two blank lines nothing fills. Now the rule lands after the paragraph,
10788        // where the split-and-aim was sending it regardless. Both formats, and
10789        // both shapes of a last line — terminated, and still being typed —
10790        // because the two reach the split through different doors: Markdown's
10791        // paragraph span stops before its newline, so `para\n` at 4 never split
10792        // there, but `para` at 4 did.
10793        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
10794            for src in ["para\n", "para"] {
10795                let mut d = Doc::from_source(src.into(), fmt).unwrap();
10796                d.caret = 4;
10797                d.insert_thematic_break();
10798                assert_eq!(d.source, format!("para\n\n{rule}\n"), "{fmt:?} {src:?}");
10799                assert_eq!(d.caret, d.source.len());
10800            }
10801            // Mid-document the slot sat between the rule and the next block.
10802            let mut d = Doc::from_source("para\n\nnext\n".into(), fmt).unwrap();
10803            d.caret = 4;
10804            d.insert_thematic_break();
10805            assert_eq!(d.source, format!("para\n\n{rule}\n\nnext\n"), "{fmt:?}");
10806            // Trailing whitespace is nothing to part either.
10807            let mut d = Doc::from_source("para  \n".into(), fmt).unwrap();
10808            d.caret = 4;
10809            d.insert_thematic_break();
10810            assert_eq!(d.source, format!("para  \n\n{rule}\n"), "{fmt:?}");
10811        }
10812    }
10813
10814    #[test]
10815    fn insert_thematic_break_at_a_paragraph_s_start_lands_before_it() {
10816        // The split at the start parts nothing, but it is kept on purpose:
10817        // `|para` becomes `\npara` with the caret on a blank line, and twig
10818        // (3.5.2) writes a rule aimed at a blank line ON that line — the only
10819        // way "before the paragraph" is reachable through a gesture that only
10820        // places after. Before 3.5.2 this came out as `\n\n---\n\npara`.
10821        for (fmt, rule) in [(Format::Markdown, "---"), (Format::Djot, "* * *")] {
10822            let mut d = Doc::from_source("para\n".into(), fmt).unwrap();
10823            d.caret = 0;
10824            d.insert_thematic_break();
10825            assert_eq!(d.source, format!("{rule}\n\npara\n"), "{fmt:?}");
10826            let mut d = Doc::from_source("prev\n\npara\n".into(), fmt).unwrap();
10827            d.caret = 6;
10828            d.insert_thematic_break();
10829            assert_eq!(d.source, format!("prev\n\n{rule}\n\npara\n"), "{fmt:?}");
10830        }
10831    }
10832
10833    #[test]
10834    fn insert_thematic_break_on_a_blank_line_takes_that_line() {
10835        // The gap between two blocks is where a click lands the caret; the
10836        // rule goes on the blank, one blank each side.
10837        let mut d = doc_with("hr_gap", "a\n\nb\n");
10838        d.caret = 2;
10839        d.insert_thematic_break();
10840        assert_eq!(d.source, "a\n\n---\n\nb\n");
10841    }
10842
10843    #[test]
10844    fn insert_table_at_a_paragraph_s_end_splits_nothing() {
10845        // The same door as the rule's, through the placement they share.
10846        let mut d = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10847        d.caret = 4;
10848        d.insert_table(1, 1);
10849        assert_eq!(d.source, "para\n\n|  |\n|---|\n|  |\n");
10850        let mut d = doc_with("table_end_typed", "para");
10851        d.caret = 4;
10852        d.insert_table(1, 1);
10853        assert_eq!(d.source, "para\n\n|  |\n| --- |\n|  |\n");
10854        assert!(d.caret_in_table());
10855    }
10856
10857    #[test]
10858    fn insert_thematic_break_spells_the_rule_the_format_s_own_way() {
10859        // The whole point of delegating: `---` is Markdown's, `* * *` is djot's,
10860        // and leaf wrote the first into both until twig started spelling it.
10861        let mut md = doc_with("hr_md", "para\n");
10862        md.caret = 2;
10863        md.insert_thematic_break();
10864        assert_eq!(md.source, "pa\n\n---\n\nra\n");
10865
10866        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10867        dj.caret = 2;
10868        dj.insert_thematic_break();
10869        assert_eq!(dj.source, "pa\n\n* * *\n\nra\n");
10870    }
10871
10872    #[test]
10873    fn insert_table_parts_the_paragraph_and_lands_in_the_first_header_cell() {
10874        // The table goes *at* the caret the way the rule does: the paragraph is
10875        // parted first, and twig writes the grid after its first half. The
10876        // caret then sits in the first header cell — selected, as Tab would
10877        // leave it — so the next keystroke is the heading.
10878        let mut d = doc_with("table_mid", "before after\n");
10879        d.caret = 7;
10880        d.insert_table(2, 3);
10881        assert_eq!(
10882            d.source,
10883            "before \n\n|  |  |  |\n| --- | --- | --- |\n|  |  |  |\n|  |  |  |\n\nafter\n"
10884        );
10885        assert!(d.caret_in_table());
10886        let first_bar = d.source.find('|').unwrap();
10887        assert!(
10888            d.caret > first_bar && d.caret < d.source.find("| ---").unwrap(),
10889            "caret {} is not in the header row",
10890            d.caret
10891        );
10892        d.insert("Name");
10893        assert!(d.source.starts_with("before \n\n| Name |  |  |\n"));
10894        // And the grid the table was written into is one the table keys walk
10895        // (over the map a frontend rebuilds after every edit).
10896        d.build_visual(80);
10897        assert!(d.cell_tab(true));
10898        d.insert("Qty");
10899        assert!(d.source.starts_with("before \n\n| Name | Qty |  |\n"));
10900    }
10901
10902    #[test]
10903    fn insert_table_spells_the_grid_the_format_s_own_way() {
10904        // Djot's delimiter row is unpadded, and leaf never has to know that.
10905        let mut dj = Doc::from_source("para\n".into(), Format::Djot).unwrap();
10906        dj.caret = 2;
10907        dj.insert_table(1, 2);
10908        assert_eq!(dj.source, "pa\n\n|  |  |\n|---|---|\n|  |  |\n\nra\n");
10909        assert!(dj.caret_in_table());
10910    }
10911
10912    #[test]
10913    fn insert_table_refuses_where_the_format_spells_no_table() {
10914        let mut d = Doc::from_source("<p>ab</p>\n".into(), Format::Html).unwrap();
10915        d.caret = 4;
10916        d.insert_table(1, 1);
10917        assert_eq!(d.source, "<p>ab</p>\n");
10918        assert!(d.status.as_deref().unwrap_or("").contains("not supported"));
10919        assert!(!d.capabilities().table);
10920    }
10921
10922    #[test]
10923    fn insert_table_reports_a_zero_shape_and_writes_nothing() {
10924        let mut d = doc_with("table_zero", "para\n");
10925        d.caret = 2;
10926        d.insert_table(0, 2);
10927        assert_eq!(d.source, "para\n");
10928        assert!(d.status.as_deref().unwrap_or("").starts_with("table:"));
10929    }
10930
10931    #[test]
10932    fn clicking_below_a_final_thematic_break_can_type_after_it() {
10933        let mut d = wysiwyg_doc("hr_final_click", "---\n");
10934        d.build_visual(80);
10935        d.click(d.vmap.num_rows() + 2, 0, false);
10936        assert_eq!(d.caret, d.source.len(), "the caret belongs after the rule");
10937        d.insert("after");
10938        assert_eq!(d.source, "---\nafter");
10939    }
10940
10941    #[test]
10942    fn enter_in_a_nested_list_item_keeps_the_new_item_nested() {
10943        // The same bytes are two documents. In Markdown `  - b` is a nested item
10944        // and the next one belongs beside it, at its indent. In Djot a list
10945        // marker can't interrupt a paragraph, so those bytes are literal text in
10946        // item `a` and there is only one item — writing `  - ` under it would add
10947        // no item at all, just more text, and the new sibling has to go to
10948        // column zero. Both spellings come out of the *enclosing item's* line.
10949        let mut md = wysiwyg_doc("enter_nested_md", "- a\n  - b\n");
10950        md.caret = "- a\n  - b".len();
10951        md.newline();
10952        assert_eq!(md.source, "- a\n  - b\n  - \n");
10953        assert_eq!(list_items(&mut md), 3);
10954
10955        let mut dj = Doc::from_source("- a\n  - b\n".into(), Format::Djot).unwrap();
10956        dj.view = View::Wysiwyg;
10957        dj.build_visual(80);
10958        dj.caret = "- a\n  - b".len();
10959        dj.newline();
10960        assert_eq!(dj.source, "- a\n  - b\n- \n");
10961        assert_eq!(list_items(&mut dj), 2);
10962
10963        // Where Djot's nesting is real — opened by a blank line — the indent is
10964        // reproduced there too, and the two formats agree again.
10965        let mut dj = Doc::from_source("- a\n\n  - b\n".into(), Format::Djot).unwrap();
10966        dj.view = View::Wysiwyg;
10967        dj.build_visual(80);
10968        dj.caret = "- a\n\n  - b".len();
10969        dj.newline();
10970        assert_eq!(dj.source, "- a\n\n  - b\n  - \n");
10971        assert_eq!(list_items(&mut dj), 3);
10972    }
10973
10974    #[test]
10975    fn tab_nests_an_item_at_the_column_its_own_marker_asks_for() {
10976        // Tab replaces the line's whole prefix with the one twig spells, so the
10977        // quote markers, the parent's indent and an ordered marker's extra
10978        // column are all its answer rather than leaf's arithmetic.
10979        for (name, body, caret, want) in [
10980            ("bullet", "- a\n- b\n", 6, "- a\n  - b\n"),
10981            ("ordered", "1. a\n2. b\n", 8, "1. a\n   1. b\n"),
10982            ("quoted", "> - a\n> - b\n", 10, "> - a\n>   - b\n"),
10983            // A checkbox is markup the item's own text wraps past, but a nested
10984            // list may only open at the *list* marker's column — four in from
10985            // there is a paragraph continuation, and `- [ ] a\n      - [ ] b`
10986            // parses as one item, not two.
10987            ("task", "- [ ] a\n- [ ] b\n", 14, "- [ ] a\n  - [ ] b\n"),
10988            (
10989                "quoted task",
10990                "> - [ ] a\n> - [ ] b\n",
10991                18,
10992                "> - [ ] a\n>   - [ ] b\n",
10993            ),
10994        ] {
10995            let mut doc = wysiwyg_doc(name, body);
10996            doc.caret = caret;
10997            doc.indent();
10998            assert_eq!(doc.source, want, "{name}");
10999            // The nesting is real, not just indented text.
11000            assert_eq!(list_items(&mut doc), 2, "{name}");
11001        }
11002    }
11003
11004    #[test]
11005    fn backspace_only_outdents_where_the_format_says_there_is_an_item() {
11006        // The same bytes, the two formats disagreeing, and a gesture that used
11007        // to read the bytes. `  - b` is a nested item in Markdown, so Backspace
11008        // at its marker outdents. In Djot a marker can't interrupt a paragraph,
11009        // so those bytes are literal text inside item `a` — there is nothing to
11010        // outdent, and treating them as a marker turned one item into two, a
11011        // structural edit from a keystroke that should delete one character.
11012        //
11013        // twig's `line_prefix` is what tells them apart: it reports the marker
11014        // on the Markdown line and nothing on the Djot one, which is a
11015        // continuation. No byte scan can reach that answer.
11016        let src = "- a\n  - b\n";
11017        let at = "- a\n  - ".len();
11018
11019        let mut md = Doc::from_source(src.into(), Format::Markdown).unwrap();
11020        md.view = View::Wysiwyg;
11021        md.build_visual(80);
11022        md.caret = at;
11023        md.backspace();
11024        assert_eq!(md.source, "- a\n- b\n");
11025        assert_eq!(list_items(&mut md), 2);
11026
11027        let mut dj = Doc::from_source(src.into(), Format::Djot).unwrap();
11028        dj.view = View::Wysiwyg;
11029        dj.build_visual(80);
11030        dj.caret = at;
11031        dj.backspace();
11032        assert_eq!(dj.source, "- a\n  -b\n"); // an ordinary character delete
11033        assert_eq!(list_items(&mut dj), 1); // and the structure is untouched
11034    }
11035
11036    #[test]
11037    fn enter_in_a_checklist_item_starts_another_unchecked_one() {
11038        // Leaf used to spell the next item from the marker bytes it scanned, and
11039        // its scanner stopped at the bullet — so Enter in a checklist wrote `- `
11040        // and dropped out of the checklist. twig reproduces the whole
11041        // continuation, and a fresh item is always unticked however the one above
11042        // it stands.
11043        for (name, body, want) in [
11044            ("unchecked", "- [ ] a\n", "- [ ] a\n- [ ] \n"),
11045            ("checked", "- [x] a\n", "- [x] a\n- [ ] \n"),
11046        ] {
11047            let mut doc = wysiwyg_doc(name, body);
11048            doc.caret = body.trim_end_matches('\n').len();
11049            doc.newline();
11050            assert_eq!(doc.source, want, "{name}");
11051            // Both items are checklist items — the new one is a box, not the
11052            // plain bullet the old marker scan left behind — and it is unticked
11053            // whichever way the one above it faces.
11054            let boxes: Vec<Option<bool>> = doc
11055                .nodes()
11056                .iter()
11057                .filter(|n| n.kind == Kind::TaskListItem)
11058                .map(|n| n.checked)
11059                .collect();
11060            assert_eq!(boxes.len(), 2, "{name}");
11061            assert_eq!(boxes[1], Some(false), "{name}");
11062        }
11063    }
11064
11065    #[test]
11066    fn a_split_takes_the_space_the_caret_was_in_front_of() {
11067        // Splicing a break at the caret strands the space the words were parted
11068        // at on the head of the second block, where it reads as an indent nobody
11069        // typed. twig's split consumes it.
11070        for (name, body, caret, want) in [
11071            ("para", "one two\n", 3, "one\n\ntwo\n"),
11072            ("item", "- one two\n", 5, "- one\n- two\n"),
11073            ("quote", "> one two\n", 5, "> one\n>\n> two\n"),
11074            // A heading takes leaf's own path, which has to match.
11075            ("heading", "# one two\n", 5, "# one\n\ntwo\n"),
11076        ] {
11077            let mut doc = wysiwyg_doc(name, body);
11078            doc.caret = caret;
11079            doc.newline();
11080            assert_eq!(doc.source, want, "{name}");
11081        }
11082    }
11083
11084    #[test]
11085    fn enter_at_the_end_of_a_heading_opens_a_paragraph() {
11086        // The one place leaf keeps its own break: `split_block` repeats the `#`,
11087        // and Enter after a title is how the body under it is asked for.
11088        let mut doc = wysiwyg_doc("head_enter", "# Title\n");
11089        doc.caret = "# Title".len();
11090        doc.newline();
11091        doc.insert("body");
11092        assert_eq!(doc.source, "# Title\n\nbody\n");
11093        assert_eq!(
11094            doc.nodes()
11095                .iter()
11096                .filter(|n| n.kind == Kind::Heading)
11097                .count(),
11098            1
11099        );
11100    }
11101
11102    #[test]
11103    fn enter_in_a_quoted_list_item_starts_the_next_quoted_item() {
11104        // A quoted item's marker doesn't open its line, so a scan that starts at
11105        // column zero finds a `>` where it wanted a bullet, calls the line "not a
11106        // list" and hands Enter to the plain-quote branch — which writes `> ` and
11107        // drops the list. The next item has to carry the whole prefix.
11108        for (name, body, want) in [
11109            ("flat", "> - a\n", "> - a\n> - \n"),
11110            ("sibling", "> - a\n> - b\n", "> - a\n> - b\n> - \n"),
11111            ("nested", "> - a\n>   - b\n", "> - a\n>   - b\n>   - \n"),
11112            ("ordered", "> 1. a\n> 2. b\n", "> 1. a\n> 2. b\n> 3. \n"),
11113            ("twice quoted", "> > - a\n", "> > - a\n> > - \n"),
11114        ] {
11115            let mut doc = wysiwyg_doc(name, body);
11116            doc.caret = body.trim_end_matches('\n').len();
11117            doc.newline();
11118            assert_eq!(doc.source, want, "{name}");
11119            // The marker isn't just spelled right, it parses as an item.
11120            assert_eq!(list_items(&mut doc), body.lines().count() + 1, "{name}");
11121        }
11122    }
11123
11124    #[test]
11125    fn an_empty_quoted_item_leaves_the_list_and_stays_in_the_quote() {
11126        // Double-Enter exits the list. Unquoted that means a blank line, but a
11127        // *bare* blank line would end the quote too and drop the caret out of it,
11128        // so the separator keeps its `>` and the caret's line keeps its `> `.
11129        let mut doc = wysiwyg_doc("quoted_exit", "> - a\n> - \n");
11130        doc.caret = "> - a\n> - ".len();
11131        doc.newline();
11132        assert_eq!(doc.source, "> - a\n>\n> \n");
11133        assert_eq!(list_items(&mut doc), 1);
11134        // What "still in the quote" means for the next keystroke: the caret sits
11135        // behind the prefix, and what's typed there lands inside the quote as a
11136        // paragraph of its own — not as more of item `a`.
11137        doc.insert("x");
11138        assert_eq!(doc.source, "> - a\n>\n> x\n");
11139        assert!(
11140            doc.editor
11141                .ancestors_at(doc.caret - 1)
11142                .is_ok_and(|c| c.into_iter().any(|m| m.kind == Kind::BlockQuote))
11143        );
11144    }
11145
11146    #[test]
11147    fn backspace_at_a_quoted_marker_takes_the_marker_and_leaves_the_quote() {
11148        // The marker is hidden block markup, so Backspace over it is structural —
11149        // but only the marker is the list's. Splicing from the line start would
11150        // take the `>` with it and silently unquote the line.
11151        let mut doc = wysiwyg_doc("quoted_bksp", "> - a\n");
11152        doc.caret = "> - ".len();
11153        doc.backspace();
11154        assert_eq!(doc.source, "> a\n");
11155        assert_eq!(list_items(&mut doc), 0);
11156
11157        // A nested one outdents instead, moving the bullet within the quote
11158        // rather than moving the quote.
11159        let mut doc = wysiwyg_doc("quoted_outdent", "> - a\n>   - b\n");
11160        doc.caret = "> - a\n>   - ".len();
11161        doc.backspace();
11162        assert_eq!(doc.source, "> - a\n> - b\n");
11163        assert_eq!(list_items(&mut doc), 2);
11164    }
11165
11166    #[test]
11167    fn only_a_bare_paragraph_is_parted_around_the_caret() {
11168        // The split is deliberately narrow. Parting a fenced block would leave
11169        // two fences with a rule between them, and parting a list item would
11170        // mint an item nobody asked for on the way to a rule that lands after
11171        // the list either way — so both keep the whole block intact and take the
11172        // rule after it. A caret in a quote is likewise left alone.
11173        for (name, body, caret, want) in [
11174            (
11175                "code",
11176                "```\nfn x() {}\n```\n",
11177                8,
11178                "```\nfn x() {}\n```\n\n---\n",
11179            ),
11180            ("list", "- one two\n", 6, "- one two\n\n---\n"),
11181            ("quote", "> one two\n", 6, "> one two\n>\n> ---\n"),
11182        ] {
11183            let mut d = doc_with(&format!("hr_narrow_{name}"), body);
11184            d.caret = caret;
11185            d.insert_thematic_break();
11186            assert_eq!(d.source, want, "{name}: the block should stay whole");
11187        }
11188    }
11189
11190    #[test]
11191    fn insert_thematic_break_replaces_the_selection() {
11192        // Now that the rule lands *at* the caret again, replacing the selection
11193        // is coherent once more: the text goes, and the rule takes its place.
11194        // The space the deletion left leading the second half is consumed by the
11195        // split rather than opening the new paragraph with it.
11196        let mut d = doc_with("hr_sel", "one two three\n");
11197        d.anchor = Some(4);
11198        d.caret = 7; // "two"
11199        d.insert_thematic_break();
11200        assert_eq!(d.source, "one \n\n---\n\nthree\n");
11201        assert_eq!(d.selection(), None);
11202    }
11203
11204    #[test]
11205    fn insert_thematic_break_clears_a_code_block_and_a_table_rather_than_refusing() {
11206        // Both are blocks the rule lands *after*. Leaf used to refuse a fence,
11207        // because writing `---` into one is code, not a rule — twig now walks out
11208        // to the block that owns the caret's line, so there is nothing to refuse.
11209        let mut code = doc_with("hr_code", "```\nfn x() {}\n```\n");
11210        code.caret = 5; // inside the fenced code
11211        code.insert_thematic_break();
11212        assert_eq!(code.source, "```\nfn x() {}\n```\n\n---\n");
11213        assert_eq!(code.status, None, "no refusal to report any more");
11214
11215        let mut table = doc_with("hr_table", "| a | b |\n|---|---|\n| 1 | 2 |\n");
11216        table.caret = 3; // in the header row
11217        table.insert_thematic_break();
11218        assert_eq!(table.source, "| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n");
11219    }
11220
11221    #[test]
11222    fn insert_thematic_break_in_a_list_item_ends_the_list() {
11223        // The un-indented rule cannot continue the list, so it closes the list
11224        // and lands at the top level rather than nested inside it.
11225        let mut d = doc_with("hr_list", "- one\n- two\n");
11226        d.caret = "- one\n- tw".len(); // mid "two"
11227        d.insert_thematic_break();
11228        d.build_visual(80);
11229        let rule_at = d.source.find("---").unwrap();
11230        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
11231        assert!(
11232            !d.nodes().iter().any(|n| n.kind == Kind::BulletList
11233                && n.span.start <= rule_at
11234                && rule_at < n.span.end),
11235            "the rule must not be nested inside the list"
11236        );
11237    }
11238
11239    #[test]
11240    fn insert_thematic_break_in_a_blockquote_stays_in_the_quote() {
11241        // Leaf used to end the quote. twig gives the rule the quote's own prefix,
11242        // which is the document the gesture was actually asked for.
11243        let mut d = doc_with("hr_quote", "> hello\n");
11244        d.caret = 4; // inside the quoted text
11245        d.insert_thematic_break();
11246        assert_eq!(d.source, "> hello\n>\n> ---\n");
11247        d.build_visual(80);
11248        let rule_at = d.source.find("---").unwrap();
11249        assert_eq!(kind_at(&mut d, rule_at), Some(Kind::ThematicBreak));
11250        assert!(
11251            d.nodes().iter().any(|n| n.kind == Kind::BlockQuote
11252                && n.span.start <= rule_at
11253                && rule_at < n.span.end),
11254            "the rule belongs to the quote it was asked for"
11255        );
11256    }
11257
11258    // ── typing against a block picture ────────────────────────────────────────
11259
11260    /// A rendered-view document with the caret parked on one of the picture's two
11261    /// stops, and the map already built — the state a frontend is in between
11262    /// drawing a frame and the next keystroke.
11263    fn doc_at_picture(name: &str, src: &str, side: MediaStop) -> Doc {
11264        let mut d = doc_in(View::Wysiwyg, name, src);
11265        d.build_visual_unwrapped();
11266        let start = src.find("![").unwrap();
11267        d.caret = match side {
11268            MediaStop::Before => start,
11269            MediaStop::After => start + "![](p.png)".len(),
11270        };
11271        d
11272    }
11273
11274    /// The block media the map publishes, after rebuilding it — "is this still a
11275    /// picture, or has it become a line of text with an image in it?"
11276    fn media_count(d: &mut Doc) -> usize {
11277        d.build_visual_unwrapped();
11278        d.vmap.media.len()
11279    }
11280
11281    #[test]
11282    fn typing_past_a_block_picture_opens_a_paragraph_under_it() {
11283        // The accident this prevents: tap the blank page under a photo (which
11284        // lands on the picture's trailing stop), type, and `![](p.png)xy` is a
11285        // paragraph with an *inline* image — the photo stops being drawn.
11286        let mut d = doc_at_picture("pic_after", "hi\n\n![](p.png)\n", MediaStop::After);
11287        d.insert("xy");
11288        assert_eq!(d.source, "hi\n\n![](p.png)\n\nxy\n");
11289        assert_eq!(media_count(&mut d), 1, "still a picture");
11290    }
11291
11292    #[test]
11293    fn typing_in_front_of_a_block_picture_opens_a_paragraph_above_it() {
11294        let mut d = doc_at_picture("pic_before", "hi\n\n![](p.png)\n", MediaStop::Before);
11295        d.insert("xy");
11296        assert_eq!(d.source, "hi\n\nxy\n\n![](p.png)\n");
11297        assert_eq!(media_count(&mut d), 1);
11298    }
11299
11300    #[test]
11301    fn a_picture_that_opens_the_document_still_takes_a_paragraph_above_it() {
11302        let mut d = doc_at_picture("pic_first", "![](p.png)\n", MediaStop::Before);
11303        d.insert("x");
11304        assert_eq!(d.source, "x\n\n![](p.png)\n");
11305        assert_eq!(media_count(&mut d), 1);
11306    }
11307
11308    #[test]
11309    fn one_undo_puts_the_picture_back_the_way_it_was_found() {
11310        // The opened paragraph is part of the keystroke, not an edit the writer
11311        // made — so it undoes with the character, not a step later.
11312        let mut d = doc_at_picture("pic_undo", "hi\n\n![](p.png)\n", MediaStop::After);
11313        d.insert("x");
11314        assert_eq!(d.source, "hi\n\n![](p.png)\n\nx\n");
11315        d.undo();
11316        assert_eq!(d.source, "hi\n\n![](p.png)\n");
11317    }
11318
11319    #[test]
11320    fn pasting_against_a_block_picture_opens_a_paragraph_too() {
11321        // ⌘V dissolves the picture exactly as a keystroke does.
11322        let mut d = doc_at_picture("pic_paste", "hi\n\n![](p.png)\n", MediaStop::After);
11323        d.paste("pasted");
11324        assert_eq!(d.source, "hi\n\n![](p.png)\n\npasted\n");
11325        assert_eq!(media_count(&mut d), 1);
11326    }
11327
11328    #[test]
11329    fn typing_beside_an_inline_image_is_ordinary_editing() {
11330        // An inline image has no placeholder row and no stops of its own. Opening
11331        // a paragraph mid-sentence would be the bug, not the fix.
11332        let mut d = doc_in(View::Wysiwyg, "pic_inline", "see ![](p.png) here\n");
11333        d.build_visual_unwrapped();
11334        d.caret = "see ![](p.png)".len();
11335        d.insert("!");
11336        assert_eq!(d.source, "see ![](p.png)! here\n");
11337    }
11338
11339    #[test]
11340    fn source_view_types_raw_markup_against_an_image_untouched() {
11341        // Source view is for writing the markup itself; a break inserted behind
11342        // the writer's back there would be the editor arguing with them.
11343        let mut d = doc_in(View::Source, "pic_src", "![](p.png)\n");
11344        d.caret = "![](p.png)".len();
11345        d.insert("x");
11346        assert_eq!(d.source, "![](p.png)x\n");
11347    }
11348
11349    #[test]
11350    fn typing_over_a_selection_that_starts_at_a_picture_stop_replaces_it() {
11351        // A selection is replaced, not joined into, so there is nothing to
11352        // protect: the range takes the picture with it.
11353        let mut d = doc_at_picture("pic_sel", "hi\n\n![](p.png)\n", MediaStop::Before);
11354        d.anchor = Some(d.caret);
11355        d.caret = d.source.find("![").unwrap() + "![](p.png)".len();
11356        d.insert("x");
11357        assert_eq!(d.source, "hi\n\nx\n");
11358    }
11359
11360    #[test]
11361    fn backspace_past_a_block_picture_deletes_the_picture_not_its_last_byte() {
11362        // What this actually cost: a real vault's photo, to one stray Backspace.
11363        // The caret past `![](p.png)` was deleting the closing paren — invisible
11364        // in the rendered view — and the photo became the text `![](p.png`.
11365        let mut d = doc_at_picture("pic_bs", "hi\n\n![](p.png)\n", MediaStop::After);
11366        d.backspace();
11367        assert_eq!(d.source, "hi\n");
11368        assert_eq!(media_count(&mut d), 0, "the picture went, in one piece");
11369        d.undo();
11370        assert_eq!(
11371            d.source, "hi\n\n![](p.png)\n",
11372            "and comes back in one piece"
11373        );
11374    }
11375
11376    #[test]
11377    fn backspace_in_front_of_a_block_picture_steps_out_instead_of_merging_it() {
11378        // Deleting the break here would join the picture to the paragraph above,
11379        // where it is an *inline* image and stops being drawn. Step over the
11380        // boundary; the next press deletes in the paragraph the caret reached.
11381        let mut d = doc_at_picture("pic_bs_before", "hi\n\n![](p.png)\n", MediaStop::Before);
11382        d.backspace();
11383        assert_eq!(d.source, "hi\n\n![](p.png)\n", "nothing deleted");
11384        assert_eq!(d.caret, 2, "the caret stepped up to the end of `hi`");
11385        d.backspace();
11386        assert_eq!(d.source, "h\n\n![](p.png)\n", "and now it deletes there");
11387        assert_eq!(media_count(&mut d), 1, "the picture was never at risk");
11388    }
11389
11390    #[test]
11391    fn forward_delete_in_front_of_a_block_picture_deletes_the_picture() {
11392        // The mirror. A byte-step here eats the `!` and leaves a link.
11393        let mut d = doc_at_picture("pic_del", "hi\n\n![](p.png)\n\nbye\n", MediaStop::Before);
11394        d.delete_forward();
11395        assert_eq!(d.source, "hi\n\nbye\n");
11396        assert_eq!(media_count(&mut d), 0);
11397    }
11398
11399    #[test]
11400    fn forward_delete_past_a_block_picture_steps_over_the_boundary() {
11401        let mut d = doc_at_picture(
11402            "pic_del_after",
11403            "hi\n\n![](p.png)\n\nbye\n",
11404            MediaStop::After,
11405        );
11406        d.delete_forward();
11407        assert_eq!(d.source, "hi\n\n![](p.png)\n\nbye\n", "nothing deleted");
11408        assert_eq!(
11409            d.caret,
11410            d.source.find("bye").unwrap(),
11411            "the caret stepped down to `bye`"
11412        );
11413    }
11414
11415    #[test]
11416    fn a_picture_that_is_the_whole_document_still_deletes_cleanly() {
11417        let mut d = doc_at_picture("pic_only", "![](p.png)\n", MediaStop::After);
11418        d.backspace();
11419        assert_eq!(d.source, "\n");
11420        assert_eq!(media_count(&mut d), 0);
11421    }
11422
11423    #[test]
11424    fn a_word_delete_takes_the_picture_whole_or_steps_out_of_it() {
11425        // ⌥⌫ past a picture would otherwise eat a "word" of its markup.
11426        let mut d = doc_at_picture("pic_wordbs", "hi there\n\n![](p.png)\n", MediaStop::After);
11427        d.delete_word_back();
11428        assert_eq!(d.source, "hi there\n");
11429
11430        // And in front of one it runs *through* the paragraph break into the
11431        // prose above, which merges the picture inline — so it steps out first,
11432        // and the second press deletes the word it was aimed at.
11433        let mut d = doc_at_picture("pic_wordbs2", "hi there\n\n![](p.png)\n", MediaStop::Before);
11434        d.delete_word_back();
11435        assert_eq!(d.source, "hi there\n\n![](p.png)\n");
11436        d.delete_word_back();
11437        assert_eq!(
11438            d.source, "hi \n\n![](p.png)\n",
11439            "the word above went, the picture stayed"
11440        );
11441        assert_eq!(media_count(&mut d), 1);
11442    }
11443
11444    #[test]
11445    fn source_view_deletes_raw_markup_against_an_image_untouched() {
11446        let mut d = doc_in(View::Source, "pic_src_del", "![](p.png)\n");
11447        d.caret = "![](p.png)".len();
11448        d.backspace();
11449        assert_eq!(d.source, "![](p.png\n", "raw editing, byte by byte");
11450    }
11451
11452    #[test]
11453    fn image_destination_at_caret_reads_the_image_under_the_caret() {
11454        let mut d = doc_with("img_read", "![a cat](cat.png)\n");
11455        d.caret = 3; // inside the image markup
11456        assert_eq!(d.image_destination_at_caret(), Some("cat.png".to_string()));
11457        // Past the image, the caret is in no image.
11458        d.caret = "![a cat](cat.png)".len();
11459        assert_eq!(d.image_destination_at_caret(), None);
11460    }
11461
11462    #[test]
11463    fn set_media_rows_reserves_blank_filler_rows_the_frontend_paints_over() {
11464        // The image is one placeholder row by default, and `set_media_rows` grows
11465        // it to the height the frontend measured: the label row plus blank
11466        // `decoration` fillers that hold the vertical space a raster is drawn into.
11467        let mut d = wysiwyg_doc("img_rows", "intro\n\n![a cat](cat.png)\n\nend\n");
11468        assert_eq!(d.vmap.media.len(), 1);
11469        let img_row = d.vmap.media[0].rows_span.start;
11470        assert_eq!(
11471            d.vmap.media[0].rows_span,
11472            img_row..img_row + 1,
11473            "default is one row"
11474        );
11475
11476        d.set_media_rows(HashMap::from([("cat.png".to_string(), 4)]));
11477        d.build_visual(80);
11478        assert_eq!(d.vmap.media.len(), 1, "still one image, now taller");
11479        let span = d.vmap.media[0].rows_span.clone();
11480        assert_eq!(span.end - span.start, 4, "reserves the four rows asked for");
11481        // The label row carries the mark and its glyphs; the three below are blank
11482        // decoration — drawn, but no caret and no text.
11483        assert!(
11484            d.vmap.rows[span.start].media.is_some(),
11485            "mark rides the first row"
11486        );
11487        for r in (span.start + 1)..span.end {
11488            assert!(d.vmap.rows[r].decoration, "filler row {r} is decoration");
11489            assert!(d.vmap.rows[r].glyphs.is_empty(), "filler row {r} is blank");
11490            assert!(
11491                d.vmap.rows[r].media.is_none(),
11492                "only the first row is marked"
11493            );
11494        }
11495    }
11496
11497    #[test]
11498    fn a_taller_image_adds_no_caret_stops_and_motion_steps_over_its_fillers() {
11499        // The extra rows are pure spacers: the caret's only homes stay the stop in
11500        // front of the image and the one just past it, so walking the document top
11501        // to bottom visits the same offsets whether the image is 1 row or 5.
11502        let body = "ab\n\n![x](p.png)\n\ncd\n";
11503        let stops_at = |rows: usize| -> Vec<usize> {
11504            let mut d = wysiwyg_doc("img_stops", body);
11505            if rows > 1 {
11506                d.set_media_rows(HashMap::from([("p.png".to_string(), rows)]));
11507                d.build_visual(80);
11508            }
11509            d.caret = 0;
11510            let mut seen = vec![d.caret];
11511            loop {
11512                d.move_right(false);
11513                if *seen.last().unwrap() == d.caret {
11514                    break;
11515                }
11516                seen.push(d.caret);
11517            }
11518            seen
11519        };
11520        assert_eq!(
11521            stops_at(1),
11522            stops_at(5),
11523            "reserving rows must not add stops"
11524        );
11525    }
11526
11527    #[test]
11528    fn insert_link_repoints_the_link_at_a_bare_caret() {
11529        let mut d = doc_with("link_repoint", "[word](http://x.dev)\n");
11530        d.caret = 3; // in the link's text, nothing selected
11531        d.insert_link("http://y.dev");
11532        assert_eq!(d.source, "[word](http://y.dev)\n");
11533        assert_eq!(d.selected_text(), Some("word"));
11534    }
11535
11536    #[test]
11537    fn insert_link_on_an_empty_range_autolinks_a_url() {
11538        // A link with no text of its own is an autolink, and twig spells it —
11539        // `<…>` is the canonical form and needs no text typed into it, so the
11540        // caret lands after it rather than selecting a finished link.
11541        let mut d = doc_with("link_empty", "\n");
11542        d.caret = 0;
11543        d.insert_link("http://x.dev");
11544        assert_eq!(d.source, "<http://x.dev>\n");
11545        assert_eq!(d.selection(), None);
11546        assert_eq!(d.caret, 14);
11547    }
11548
11549    #[test]
11550    fn insert_link_on_an_empty_range_falls_back_for_a_non_url() {
11551        // `<./notes.md>` is literal text in both formats and `<foo>` is raw HTML
11552        // in Markdown, so a destination that can't autolink doubles as the text
11553        // instead — which is then selected, ready to be typed over.
11554        let mut d = doc_with("link_rel", "\n");
11555        d.caret = 0;
11556        d.insert_link("./notes.md");
11557        assert_eq!(d.source, "[./notes.md](./notes.md)\n");
11558        assert_eq!(d.selection(), Some((1, 11)));
11559        d.insert("Notes");
11560        assert_eq!(d.source, "[Notes](./notes.md)\n");
11561    }
11562
11563    #[test]
11564    fn insert_link_repoints_the_autolink_the_caret_stands_in() {
11565        // The autolink's text is its URL, so re-pointing replaces the whole
11566        // node — the caret must not splice a second link inside the first.
11567        let mut d = doc_with("link_repoint_auto", "see <https://x.dev> ok\n");
11568        d.caret = 10;
11569        d.insert_link("https://y.dev");
11570        assert_eq!(d.source, "see <https://y.dev> ok\n");
11571    }
11572
11573    #[test]
11574    fn code_language_reads_and_edits_through_the_fence() {
11575        let mut d = doc_with("code_lang", "```rust\nlet x = 1;\n```\n");
11576        d.caret = 10; // inside the code body
11577        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
11578        assert!(d.caret_in_fenced_code());
11579
11580        d.set_code_language("python");
11581        assert!(
11582            d.source.starts_with("```python\n"),
11583            "source: {:?}",
11584            d.source
11585        );
11586        assert_eq!(d.code_language_at_caret().as_deref(), Some("python"));
11587
11588        // Clearing it leaves a bare fence and no label.
11589        d.set_code_language("");
11590        assert!(d.source.starts_with("```\n"), "source: {:?}", d.source);
11591        assert_eq!(d.code_language_at_caret(), None);
11592
11593        // A caret outside any code block edits nothing.
11594        let mut p = doc_with("code_lang_none", "just prose\n");
11595        assert!(!p.caret_in_fenced_code());
11596        p.set_code_language("rust");
11597        assert_eq!(p.source, "just prose\n");
11598    }
11599
11600    #[test]
11601    fn a_language_the_fence_cannot_carry_is_refused_not_written() {
11602        // Markdown's info string ends at whitespace, so `two words` would write
11603        // a fence that reads back with a different language than the one asked
11604        // for. twig refuses it; leaf reports that and leaves the source alone.
11605        // The old splice trimmed the ends and wrote whatever was left.
11606        let mut d = doc_with("code_lang_bad", "```rust\nx\n```\n");
11607        d.caret = 10;
11608        d.set_code_language("two words");
11609        assert_eq!(d.source, "```rust\nx\n```\n", "source should be untouched");
11610        assert!(d.status.is_some(), "the refusal should be reported");
11611        assert_eq!(d.code_language_at_caret().as_deref(), Some("rust"));
11612    }
11613
11614    #[test]
11615    fn link_destination_at_caret_reads_both_spellings() {
11616        let mut d = doc_with("link_dest", "see [t](https://x.dev) ok\n");
11617        d.caret = 5;
11618        assert_eq!(
11619            d.link_destination_at_caret().as_deref(),
11620            Some("https://x.dev")
11621        );
11622        d.caret = 0;
11623        assert_eq!(d.link_destination_at_caret(), None);
11624
11625        // An autolink has no `destination`; its text is the URL.
11626        let mut a = doc_with("link_dest_auto", "see <https://x.dev> ok\n");
11627        a.caret = 10;
11628        assert_eq!(
11629            a.link_destination_at_caret().as_deref(),
11630            Some("https://x.dev")
11631        );
11632        a.caret = 21;
11633        assert_eq!(a.link_destination_at_caret(), None);
11634    }
11635
11636    #[test]
11637    fn locate_finds_the_block_a_declared_id_names() {
11638        // The Book of Mormon shape: one document per chapter, one `{#v…}` per
11639        // verse. The locator has to land on the *verse*, which is the whole
11640        // reason a link carries one.
11641        let src = "{#v1}\nI, Nephi, having been born of goodly parents.\n\n\
11642                   {#v2}\nYea, I make a record in the language of my father.\n";
11643        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11644        let v2 = d.locate("v2").expect("the document declares `{#v2}`");
11645        assert_eq!(
11646            d.source[v2.start..v2.end].trim_end(),
11647            "Yea, I make a record in the language of my father."
11648        );
11649        // The attribute line is not part of it: `start` is a place to put a
11650        // caret, and `{#v2}` is markup the caret has no business landing in.
11651        assert!(d.source[..v2.start].ends_with("{#v2}\n"));
11652        assert_eq!(d.locate("v99"), None);
11653    }
11654
11655    #[test]
11656    fn locate_reads_a_heading_by_its_words_when_the_format_mints_no_ids() {
11657        // Markdown has no ids at all — twig mints none, and `{#custom}` in a
11658        // Markdown heading is literal text. So `#the-second-part` can only be
11659        // the heading's own words, which is the rule every Markdown renderer
11660        // already follows and therefore the one a link was authored against.
11661        let src = "# Title\n\nintro\n\n## The Second Part\n\nbody\n\n## Third\n\nmore\n";
11662        let mut d = doc_with("locate_md", src);
11663        let hit = d.locate("the-second-part").expect("the heading's slug");
11664        assert!(d.source[hit.start..].starts_with("## The Second Part"));
11665        // Bounded by the next heading that isn't under it, so a peek shows the
11666        // section rather than only its title.
11667        assert_eq!(
11668            &d.source[hit.start..hit.end],
11669            "## The Second Part\n\nbody\n\n"
11670        );
11671
11672        // A subsection does not end its parent: `# Title` runs to `## Third`'s
11673        // sibling only because there is no other `#`, so it covers the lot.
11674        let title = d.locate("title").expect("the top heading");
11675        assert_eq!(title.end, d.source.len());
11676    }
11677
11678    #[test]
11679    fn locate_reads_a_djot_auto_id_however_the_link_spelled_it() {
11680        // djot mints `Some-Heading-Here`; a link to it is written
11681        // `#some-heading-here` by nearly everything that writes links. Both
11682        // spellings are one question.
11683        let src = "## Some Heading Here\n\nbody\n";
11684        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11685        let exact = d.locate("Some-Heading-Here").expect("djot's own spelling");
11686        let slugged = d.locate("some-heading-here").expect("the link's spelling");
11687        assert_eq!(exact, slugged);
11688        // The section, not the heading line — there is more to show than a title.
11689        assert_eq!(&d.source[exact.start..exact.end], src);
11690    }
11691
11692    #[test]
11693    fn locate_ignores_an_empty_locator_and_one_that_slugs_to_nothing() {
11694        let mut d = doc_with("locate_empty", "# Title\n\nbody\n");
11695        assert_eq!(d.locate(""), None);
11696        assert_eq!(d.locate("   "), None);
11697        // All punctuation: it names nothing, and must not be read as "match the
11698        // first heading whose slug is also empty".
11699        assert_eq!(d.locate("!!!"), None);
11700    }
11701
11702    #[test]
11703    fn locate_gives_a_duplicated_id_to_the_first_block_that_claims_it() {
11704        // The document's mistake, and the answer every other anchor
11705        // implementation gives — the alternative is for a link to mean whichever
11706        // of the two a walk happened to reach first.
11707        let src = "{#dup}\nfirst.\n\n{#dup}\nsecond.\n";
11708        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11709        let hit = d.locate("dup").expect("the first `{#dup}`");
11710        assert_eq!(d.source[hit.start..hit.end].trim_end(), "first.");
11711    }
11712
11713    #[test]
11714    fn insert_footnote_writes_both_halves_and_lands_the_caret_in_the_note() {
11715        // The button's whole job: a reference where the caret was, a definition
11716        // to give it meaning, and the caret waiting in the empty note so the
11717        // next keystroke is the note's first word.
11718        let mut d = doc_with("fn_insert", "A claim and more.\n");
11719        d.caret = 7; // just past "A claim"
11720        d.insert_footnote();
11721        assert!(
11722            d.source.starts_with("A claim[^1] and more."),
11723            "{:?}",
11724            d.source
11725        );
11726        assert!(
11727            d.source.contains("[^1]:"),
11728            "the definition too: {:?}",
11729            d.source
11730        );
11731        assert_eq!(d.status, None);
11732
11733        let reference = d.source.find("[^1]").unwrap();
11734        let note = d
11735            .footnote_at(reference + 2)
11736            .expect("the reference just written");
11737        assert_eq!(note.label, "1");
11738        assert_eq!(note.text.as_deref(), Some(""), "the note starts empty");
11739        assert_eq!(Some(d.caret), note.offset, "the caret waits in the note");
11740        // …and typing there is typing into the note, not near it.
11741        d.insert("the note");
11742        assert_eq!(
11743            d.footnote_at(reference + 2).and_then(|f| f.text),
11744            Some("the note".to_string())
11745        );
11746    }
11747
11748    #[test]
11749    fn insert_footnote_numbers_past_the_notes_already_written() {
11750        // A second press must not hand back a label somebody else is using: twig
11751        // reuses a defined label rather than appending a rival definition, so a
11752        // repeat of `1` would quietly point the new reference at the old note.
11753        let mut d = doc_with("fn_insert_number", "One[^1] two.\n\n[^1]: first\n");
11754        d.caret = 7; // past `[^1]`, before " two."
11755        d.insert_footnote();
11756        assert!(d.source.starts_with("One[^1][^2] two."), "{:?}", d.source);
11757        assert_eq!(d.source.matches("[^2]:").count(), 1);
11758    }
11759
11760    #[test]
11761    fn insert_footnote_counts_a_dangling_reference_and_ignores_a_named_one() {
11762        // `[^2]` with no definition is still a 2 that means something to whoever
11763        // wrote it — stepping over it would mint a note for their reference. A
11764        // word label takes no number, so it blocks none.
11765        let mut d = doc_with("fn_insert_dangling", "a[^2] b[^why] c\n\n[^why]: named\n");
11766        d.caret = d.source.find(" c").unwrap();
11767        d.insert_footnote();
11768        assert!(d.source.contains("[^1]:"), "1 is free: {:?}", d.source);
11769        assert!(
11770            d.source.starts_with("a[^2] b[^why][^1] c"),
11771            "{:?}",
11772            d.source
11773        );
11774    }
11775
11776    #[test]
11777    fn insert_footnote_marks_the_selection_rather_than_replacing_it() {
11778        // A reference annotates the words before it. Consuming the selection —
11779        // which is what an insert normally does — would delete the very claim
11780        // the author selected in order to footnote.
11781        let mut d = doc_with("fn_insert_sel", "A claim and more.\n");
11782        d.anchor = Some(2);
11783        d.caret = 7; // "claim" selected
11784        d.insert_footnote();
11785        assert!(
11786            d.source.starts_with("A claim[^1] and more."),
11787            "{:?}",
11788            d.source
11789        );
11790    }
11791
11792    #[test]
11793    fn a_note_just_written_still_knows_where_its_reference_is() {
11794        // The authoring loop in one test: press the button, type the note, ask to
11795        // go back. The caret ends at the note's last byte — which is the *end* of
11796        // the definition's span, the one offset the query used to exclude — so
11797        // this is where the round trip either works or doesn't.
11798        let mut d = doc_with("fn_insert_return", "A claim and more.\n");
11799        d.caret = 7;
11800        d.insert_footnote();
11801        d.insert("the note");
11802        assert_eq!(d.source, "A claim[^1] and more.\n\n[^1]: the note\n");
11803        let back = d
11804            .footnote_definition_at_caret()
11805            .expect("still in the note we just typed");
11806        assert_eq!(back.label, "1");
11807        // …and following it lands on the reference's label, where a reader's
11808        // return leg lands.
11809        assert_eq!(back.offset, Some(9));
11810        assert_eq!(&d.source[9..10], "1");
11811    }
11812
11813    #[test]
11814    fn insert_footnote_takes_one_undo_for_both_halves() {
11815        // twig writes the pair as a single edit; the point of that is here.
11816        let before = "A claim and more.\n";
11817        let mut d = doc_with("fn_insert_undo", before);
11818        d.caret = 7;
11819        d.insert_footnote();
11820        assert_ne!(d.source, before);
11821        d.undo();
11822        assert_eq!(d.source, before, "one undo takes back both halves");
11823    }
11824
11825    #[test]
11826    fn insert_footnote_refuses_a_format_that_cannot_spell_one() {
11827        // HTML is authorable — it spells the inline marks — and has no footnote.
11828        // The refusal says so rather than writing brackets that would render as
11829        // brackets.
11830        let src = "<p>A claim.</p>\n";
11831        let mut d = Doc::from_source(src.to_string(), Format::Html).unwrap();
11832        assert!(!Capabilities::of(Format::Html).footnote);
11833        d.caret = 5;
11834        d.insert_footnote();
11835        assert_eq!(d.source, src, "nothing written");
11836        assert!(d.status.is_some_and(|s| s.starts_with("footnote:")));
11837    }
11838
11839    #[test]
11840    fn insert_footnote_leaves_the_caret_on_a_real_stop_in_the_rich_view() {
11841        // The empty body is the one place this could go wrong: the definition
11842        // renders as a `[1] ` marker the caret cannot occupy, so a caret aimed a
11843        // byte early would draw up in the paragraph above the note it belongs to.
11844        let mut d = doc_in(View::Wysiwyg, "fn_insert_stop", "A claim and more.\n");
11845        d.place_caret(7, false);
11846        d.insert_footnote();
11847        d.build_visual(80); // the frame a frontend draws after the edit
11848        assert_eq!(
11849            d.vmap.snap_to_stop(d.caret),
11850            d.caret,
11851            "the caret sits on a stop"
11852        );
11853        let (row, _) = d.caret_pos();
11854        assert!(
11855            drawn_rows(&d)[row].contains("[1]"),
11856            "the caret is on the note's row, not above it: {:?}",
11857            drawn_rows(&d)
11858        );
11859    }
11860
11861    #[test]
11862    fn footnote_at_caret_resolves_a_reference_to_its_note() {
11863        // `[^1]` spans 7..11; its label byte is at 9. The definition follows a
11864        // blank line, as one has to.
11865        let mut d = doc_with("fn_at_caret", "A claim[^1] and more.\n\n[^1]: the note\n");
11866        d.caret = 9;
11867        let f = d
11868            .footnote_at_caret()
11869            .expect("the caret stands in a reference");
11870        assert_eq!(f.label, "1");
11871        assert_eq!(f.text.as_deref(), Some("the note"));
11872        // The offset points at the note's first word, not at the definition's
11873        // `[` — the marker is decoration with no caret stop on it.
11874        assert_eq!(f.offset, Some(29));
11875        assert_eq!(&d.source[29..37], "the note");
11876        // …and `end` closes the range, so a frontend can ask which rendered rows
11877        // the note occupies rather than re-deriving them from the text.
11878        assert_eq!(f.end, Some(37));
11879        assert_eq!(&d.source[f.offset.unwrap()..f.end.unwrap()], "the note");
11880    }
11881
11882    /// Two definitions in a row: each is its own note, and neither reaches into
11883    /// the other.
11884    ///
11885    /// A djot definition's span used to run past the blank line into the first
11886    /// byte of whatever followed, so this answered `"first note.\n\n["` — and the
11887    /// offsets named the *next* note's rows too, showing a reader two footnotes
11888    /// when they had asked about one. twig 3.1 ends the span after the block's
11889    /// own last line; the test outlives the workaround leaf carried for it.
11890    #[test]
11891    fn footnote_at_stops_a_note_at_the_definition_after_it() {
11892        let src = "Claim[^2a] and [^2b].\n\n[^2a]: first note.\n\n[^2b]: second note.\n";
11893        for format in [Format::Markdown, Format::Djot] {
11894            let mut d = Doc::from_source(src.to_string(), format).unwrap();
11895            d.caret = 7;
11896            let f = d.footnote_at_caret().expect("a reference");
11897            assert_eq!(f.text.as_deref(), Some("first note."), "in {format:?}");
11898            assert_eq!(
11899                &src[f.offset.unwrap()..f.end.unwrap()],
11900                "first note.",
11901                "in {format:?}"
11902            );
11903        }
11904    }
11905
11906    /// The other side of that boundary: a blank line *inside* a definition is
11907    /// interior to it, and the note keeps its second paragraph.
11908    ///
11909    /// This is what the old body scan cost. It stopped at the first line not
11910    /// indented under the note — a blank line is not — so a two-paragraph note
11911    /// came back as its first paragraph, and "go to note" framed half of it.
11912    /// Reading the span twig gives is both simpler and right.
11913    #[test]
11914    fn footnote_at_keeps_a_notes_second_paragraph() {
11915        let src = "Claim[^1].\n\n[^1]: first para.\n\n    second para.\n\nAfter.\n";
11916        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
11917        d.caret = 7;
11918        let f = d.footnote_at_caret().expect("a reference");
11919        assert_eq!(f.text.as_deref(), Some("first para.\n\n    second para."));
11920        // And it stops there — `After.` is the next block, not more note.
11921        assert_eq!(
11922            &src[f.offset.unwrap()..f.end.unwrap()],
11923            f.text.as_deref().unwrap()
11924        );
11925        assert!(!f.text.as_deref().unwrap().contains("After"));
11926    }
11927
11928    #[test]
11929    fn footnote_at_bounds_a_note_whose_body_is_empty() {
11930        // `[^1]:` with nothing after it. The range is empty rather than
11931        // inverted, and still points inside the definition — which is what keeps
11932        // a frontend's row lookup from walking off into the block above.
11933        let src = "A claim[^1].\n\n[^1]:\n";
11934        let mut d = doc_with("fn_empty_body", src);
11935        d.caret = 9;
11936        let f = d.footnote_at_caret().expect("a reference");
11937        assert_eq!(f.text.as_deref(), Some(""));
11938        assert_eq!(f.offset, f.end, "an empty note is an empty range");
11939        assert!(f.offset.unwrap() >= src.find("[^1]:").unwrap());
11940    }
11941
11942    #[test]
11943    fn footnote_at_caret_ignores_a_caret_that_stands_in_no_reference() {
11944        let mut d = doc_with(
11945            "fn_at_caret_none",
11946            "A claim[^1] and more.\n\n[^1]: the note\n",
11947        );
11948        d.caret = 2; // in the prose
11949        assert_eq!(d.footnote_at_caret(), None);
11950    }
11951
11952    #[test]
11953    fn footnote_at_caret_is_not_a_link_query_and_vice_versa() {
11954        // The two are deliberately separate: a reference names a note in this
11955        // document, a link names somewhere to leave for, and answering one with
11956        // the other is what made a reference click do nothing at all.
11957        let mut d = doc_with("fn_vs_link", "a[^1] b [t](https://x.dev)\n\n[^1]: note\n");
11958        d.caret = 3; // the `1` of `[^1]`
11959        assert!(d.footnote_at_caret().is_some());
11960        assert_eq!(
11961            d.link_destination_at_caret(),
11962            None,
11963            "a reference is not a link"
11964        );
11965
11966        d.caret = 10; // inside the link's label
11967        assert_eq!(d.footnote_at_caret(), None, "a link is not a reference");
11968        assert_eq!(
11969            d.link_destination_at_caret().as_deref(),
11970            Some("https://x.dev")
11971        );
11972    }
11973
11974    #[test]
11975    fn footnote_at_caret_reports_an_undefined_reference_rather_than_nothing() {
11976        // A `[^99]` the document never defines is a real state — a note deleted
11977        // out from under its reference — and the label is what lets a frontend
11978        // say so. `None` here would be indistinguishable from "not on a
11979        // reference", which is the wrong thing to tell a reader.
11980        let mut d = doc_with("fn_undefined", "A claim[^99] and more.\n");
11981        d.caret = 9;
11982        let f = d
11983            .footnote_at_caret()
11984            .expect("the reference is still a reference");
11985        assert_eq!(f.label, "99");
11986        assert_eq!(f.text, None);
11987        assert_eq!(f.offset, None);
11988    }
11989
11990    #[test]
11991    fn footnote_at_caret_reads_a_word_label_and_a_multiline_note() {
11992        // Labels are not always numbers, and a note's body runs past its first
11993        // line — the indented continuation belongs to the note, so it comes back
11994        // with it (source bytes, verbatim, as documented).
11995        let src = "see[^note] here\n\n[^note]: first line\n    second line\n";
11996        let mut d = doc_with("fn_word_label", src);
11997        d.caret = 6;
11998        let f = d
11999            .footnote_at_caret()
12000            .expect("the caret stands in a reference");
12001        assert_eq!(f.label, "note");
12002        assert_eq!(f.text.as_deref(), Some("first line\n    second line"));
12003    }
12004
12005    #[test]
12006    fn footnote_at_answers_for_an_offset_the_caret_is_nowhere_near() {
12007        // The point of the offset form: a pointer hovering a reference asks what
12008        // note it names, and must not drag the caret along to ask.
12009        let mut d = doc_with("fn_at_off", "A claim[^1] and more.\n\n[^1]: the note\n");
12010        d.caret = 0;
12011        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
12012        assert_eq!(f.label, "1");
12013        assert_eq!(f.text.as_deref(), Some("the note"));
12014        assert_eq!(d.caret, 0, "asking must not move the caret");
12015        assert_eq!(d.footnote_at(2), None, "offset 2 is prose");
12016    }
12017
12018    #[test]
12019    fn footnote_definition_at_caret_points_back_at_the_reference() {
12020        // The return leg. `[^1]` spans 7..11, so its label — the only byte of it
12021        // the caret can rest on — is at 9.
12022        let mut d = doc_with("fn_def", "A claim[^1] and more.\n\n[^1]: the note\n");
12023        d.caret = 30; // inside the note's body
12024        let f = d
12025            .footnote_definition_at_caret()
12026            .expect("the caret stands in a definition");
12027        assert_eq!(f.label, "1");
12028        assert_eq!(f.offset, Some(9));
12029        assert_eq!(&d.source[7..11], "[^1]");
12030    }
12031
12032    #[test]
12033    fn footnote_definition_at_covers_where_a_go_to_note_actually_lands() {
12034        // The two legs have to meet: wherever `footnote_at` sends the caret, the
12035        // definition query must answer for — otherwise arriving at a note leaves
12036        // the reader somewhere the way back isn't offered.
12037        let src = "A claim[^1] and more.\n\n[^1]: the note\n";
12038        let mut d = doc_with("fn_def_marker", src);
12039        let landed = d.footnote_at(9).unwrap().offset.unwrap();
12040        assert_eq!(
12041            d.footnote_definition_at(landed).and_then(|f| f.offset),
12042            Some(9),
12043            "the note a reference sends you to offers the way back"
12044        );
12045    }
12046
12047    #[test]
12048    fn footnote_definition_at_caret_ignores_prose_and_the_reference_itself() {
12049        // The two queries answer for disjoint places, which is what lets one
12050        // gesture mean "down to the note" in one and "back up" in the other
12051        // without either having to remember which way the reader is going.
12052        let mut d = doc_with("fn_def_none", "A claim[^1] and more.\n\n[^1]: the note\n");
12053        d.caret = 2; // prose
12054        assert_eq!(d.footnote_definition_at_caret(), None);
12055        d.caret = 9; // the reference
12056        assert_eq!(d.footnote_definition_at_caret(), None);
12057        assert!(
12058            d.footnote_at_caret().is_some(),
12059            "which is the reference's own query"
12060        );
12061    }
12062
12063    #[test]
12064    fn footnote_definition_at_caret_reports_an_orphan_note_rather_than_nothing() {
12065        // Nothing cites `[^2]`. Answering `None` would say "you are not in a
12066        // note", which is false and leaves a frontend unable to explain why the
12067        // way back is missing.
12068        let src = "A claim[^1].\n\n[^1]: cited\n\n[^2]: orphan\n";
12069        let mut d = doc_with("fn_def_orphan", src);
12070        d.caret = src.find("orphan").unwrap();
12071        let f = d
12072            .footnote_definition_at_caret()
12073            .expect("an orphan is still a definition");
12074        assert_eq!(f.label, "2");
12075        assert_eq!(f.offset, None);
12076    }
12077
12078    #[test]
12079    fn footnote_definition_at_caret_returns_to_the_first_of_repeated_references() {
12080        // One label, cited twice. The first is where the reader most likely came
12081        // from, and the only answer that doesn't depend on how they got here.
12082        let src = "One[^a] and two[^a].\n\n[^a]: the note\n";
12083        let mut d = doc_with("fn_def_repeat", src);
12084        d.caret = src.find("the note").unwrap();
12085        let f = d.footnote_definition_at_caret().expect("a definition");
12086        assert_eq!(
12087            f.offset,
12088            Some(5),
12089            "the first `[^a]`'s label, not the second's"
12090        );
12091        assert_eq!(&src[3..7], "[^a]");
12092    }
12093
12094    #[test]
12095    fn footnote_navigation_is_a_round_trip_through_placed_carets() {
12096        // Down and back up, each leg found from the document rather than from a
12097        // memory of the other — so it still works for a reader who scrolled to
12098        // the notes instead of jumping there.
12099        //
12100        // `place_caret` rather than assigning `caret`, because that is what a
12101        // frontend calls: it snaps to a real caret stop, and a jump that lands
12102        // on a byte the caret can't rest on would arrive somewhere the return
12103        // leg no longer answers for. `build_map` first, since snapping is a
12104        // no-op until the map exists — which is exactly how this went unnoticed
12105        // when the offsets pointed at the `[^` markers.
12106        let mut d = doc_with("fn_round", "A claim[^1] and more.\n\n[^1]: the note\n");
12107        d.build_map(None);
12108        d.place_caret(9, false);
12109        let down = d
12110            .footnote_at_caret()
12111            .expect("a reference")
12112            .offset
12113            .expect("a note");
12114        d.place_caret(down, false);
12115        let up = d
12116            .footnote_definition_at_caret()
12117            .expect("a definition")
12118            .offset
12119            .expect("a reference");
12120        d.place_caret(up, false);
12121        assert_eq!(d.caret, up, "the way back is a stop the caret can occupy");
12122        assert_eq!(
12123            d.footnote_at_caret().expect("back on the reference").label,
12124            "1"
12125        );
12126    }
12127
12128    #[test]
12129    fn insert_link_hands_the_destination_to_twig_raw() {
12130        // Escaping is twig's, and format-specific: Markdown ends a destination
12131        // at the first space and needs the `<…>` form, where djot would read
12132        // those angle brackets as part of the URL.
12133        let mut d = doc_with("link_space", "word\n");
12134        d.anchor = Some(0);
12135        d.caret = 4;
12136        d.insert_link("a b");
12137        assert_eq!(d.source, "[word](<a b>)\n");
12138    }
12139
12140    #[test]
12141    fn insert_link_reports_a_destination_no_format_can_carry() {
12142        let mut d = doc_with("link_bad", "word\n");
12143        d.anchor = Some(0);
12144        d.caret = 4;
12145        d.insert_link("a\nb");
12146        assert_eq!(d.source, "word\n"); // untouched, not quietly rewritten
12147        assert!(
12148            d.status.is_some(),
12149            "InvalidArgument should reach the status line"
12150        );
12151        assert!(!d.dirty);
12152    }
12153
12154    #[test]
12155    fn insert_link_works_in_wysiwyg_view() {
12156        let mut d = wysiwyg_doc("link_wys", "word here\n");
12157        d.anchor = Some(0);
12158        d.caret = 4;
12159        d.insert_link("http://x.dev");
12160        assert_eq!(d.source, "[word](http://x.dev) here\n");
12161        assert_eq!(d.selected_text(), Some("word"));
12162        // The map the caret has to keep riding is rebuilt each frame; motion
12163        // over the fresh one must still land on a real stop (the debug_assert).
12164        d.build_visual(80);
12165        d.move_right(false);
12166        d.move_left(false);
12167    }
12168
12169    #[test]
12170    fn click_maps_a_row_col_to_a_byte_offset() {
12171        let mut d = doc_with("click", "ab\ncd\n");
12172        d.click(1, 1, false); // row 1 ("cd"), col 1 -> the 'd'
12173        assert_eq!(d.caret, 4);
12174    }
12175
12176    // A pixel-hit-test placement (the GUI's `place_caret`) must land on a caret
12177    // stop just as the `(row, col)` click path does, so the caret can never come
12178    // to rest in the blank gap between two paragraphs — where it would draw in one
12179    // place and type in another.
12180    #[test]
12181    fn place_caret_snaps_out_of_the_blank_gap_between_paragraphs() {
12182        // "A\n\nB": offset 2 is the gap the paragraph break is drawn with, not a
12183        // caret stop (stops are 0,1,3,4).
12184        let mut d = wysiwyg_doc("place_gap", "A\n\nB");
12185        assert!(!d.vmap.is_stop(2), "offset 2 should be an unreachable gap");
12186        d.place_caret(2, false);
12187        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
12188        assert_eq!(d.caret, 1, "should snap to the end of the paragraph above");
12189    }
12190
12191    #[test]
12192    fn place_caret_dragging_through_the_gap_keeps_selection_on_stops() {
12193        let mut d = wysiwyg_doc("place_gap_drag", "A\n\nB");
12194        d.place_caret(0, false); // anchor at the start of "A"
12195        d.place_caret(2, true); // drag into the gap
12196        assert!(d.vmap.is_stop(d.caret), "caret {} is not a stop", d.caret);
12197        let (s, e) = d.selection().expect("a selection");
12198        assert!(
12199            d.vmap.is_stop(s) && d.vmap.is_stop(e),
12200            "selection {s}..{e} off a stop"
12201        );
12202    }
12203
12204    #[test]
12205    fn place_caret_on_a_real_stop_is_left_untouched() {
12206        let mut d = wysiwyg_doc("place_stop", "A\n\nB");
12207        d.place_caret(3, false); // the start of "B" — a genuine stop
12208        assert_eq!(d.caret, 3);
12209    }
12210
12211    // An *empty paragraph* (two blank lines, an intentional blank line the user
12212    // opened) is a real caret stop, unlike the gap — a click into it must stay.
12213    #[test]
12214    fn place_caret_rests_in_an_empty_paragraph() {
12215        let mut d = wysiwyg_doc("place_empty_para", "A\n\n\n\nB");
12216        let empty = 3; // the navigable empty row's offset (stops: 0,1,3,5,6)
12217        assert!(d.vmap.is_stop(empty));
12218        d.place_caret(empty, false);
12219        assert_eq!(d.caret, empty);
12220    }
12221
12222    // The content end of a hidden mark is a home too (`VisualMap::mark_ends`):
12223    // a drag over the word `bold` ends there, and a caret placed there stays.
12224    #[test]
12225    fn place_caret_rests_at_the_end_of_a_hidden_marks_content() {
12226        let src = "| A | B |\n| --- | --- |\n| **bold** | other |\n";
12227        let mut d = wysiwyg_doc("place_mark_end", src);
12228        let start = src.find("bold").unwrap();
12229        d.place_caret(start, false);
12230        d.place_caret(start + 4, true);
12231        assert_eq!(d.selection(), Some((start, start + 4)), "the whole word");
12232        d.toggle(InlineKind::Strong);
12233        assert_eq!(d.source, src.replace("**bold**", "bold"));
12234    }
12235
12236    #[test]
12237    fn right_steps_onto_the_end_of_a_mark_and_then_past_its_delimiter() {
12238        let mut d = wysiwyg_doc("right_mark_end", "a **bold** b");
12239        d.caret = 7; // before the `d`
12240        d.move_right(false);
12241        assert_eq!(d.caret, 8, "onto the end of the bold");
12242        assert!(d.active_inline_marks().contains(InlineKind::Strong));
12243        d.move_right(false);
12244        assert_eq!(d.caret, 10, "past the closing `**`");
12245        assert!(!d.active_inline_marks().contains(InlineKind::Strong));
12246        d.move_left(false);
12247        assert_eq!(d.caret, 8);
12248        d.move_left(false);
12249        assert_eq!(d.caret, 7);
12250        // Typing at the inner home extends the bold.
12251        d.caret = 8;
12252        d.insert("!");
12253        assert_eq!(d.source, "a **bold!** b");
12254    }
12255
12256    #[test]
12257    fn a_marks_end_home_follows_an_edit_through_the_incremental_map() {
12258        // The splice path shifts the home with the block it is in, and the
12259        // re-rendered block finds its own again.
12260        let mut d = wysiwyg_doc("mark_end_splice", "x\n\na **bold** b\n\ny\n");
12261        d.build_visual_unwrapped();
12262        d.edit(0, 0, "zz");
12263        d.build_visual_unwrapped();
12264        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a shift");
12265        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
12266        let at = d.source.find("bold").unwrap();
12267        d.edit(at, at, "very ");
12268        d.build_visual_unwrapped();
12269        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after a re-render");
12270        assert!(d.vmap.is_stop(d.source.find("bold").unwrap() + 4));
12271    }
12272
12273    fn wysiwyg_doc(name: &str, body: &str) -> Doc {
12274        doc_in(View::Wysiwyg, name, body)
12275    }
12276
12277    /// How many list items the source actually parses into — the check that a
12278    /// marker Leaf wrote is a marker the format agrees is one.
12279    fn list_items(doc: &mut Doc) -> usize {
12280        doc.editor
12281            .nodes()
12282            .unwrap()
12283            .iter()
12284            .filter(|n| n.kind == Kind::ListItem || n.kind == Kind::TaskListItem)
12285            .count()
12286    }
12287
12288    /// A from-scratch, cache-free WYSIWYG map for `source` — the ground truth the
12289    /// incremental (`build_spliced` / `build_cached`) path must always match.
12290    fn reference_map(source: &str) -> crate::wysiwyg::VisualMap {
12291        reference_map_revealing(source, None)
12292    }
12293
12294    /// [`reference_map`] with a reveal line — the ground truth for the
12295    /// `MarkupMode::Full` builds, where the map is a function of the caret's
12296    /// line as well as the text.
12297    fn reference_map_revealing(source: &str, reveal: Option<Reveal>) -> crate::wysiwyg::VisualMap {
12298        // The same parse `Doc` uses. With twig's plain defaults instead, the two
12299        // sides disagree on what the *document* is before the renderer is even
12300        // reached — a bare `:word` is a text directive to one and prose to the
12301        // other — and the mismatch reads as a splice bug that isn't one.
12302        let mut ed =
12303            twig::Editor::new_ext(source.as_bytes(), Format::Markdown, parse_extensions()).unwrap();
12304        let nodes = ed.nodes().unwrap();
12305        crate::wysiwyg::build(
12306            &nodes,
12307            source,
12308            None,
12309            false,
12310            &wysiwyg::Surface::default(),
12311            reveal,
12312        )
12313    }
12314
12315    fn maps_differ(a: &crate::wysiwyg::VisualMap, b: &crate::wysiwyg::VisualMap) -> bool {
12316        if a.rows.len() != b.rows.len() {
12317            return true;
12318        }
12319        for (ra, rb) in a.rows.iter().zip(&b.rows) {
12320            if ra.end_src != rb.end_src || ra.glyphs.len() != rb.glyphs.len() {
12321                return true;
12322            }
12323            for (ga, gb) in ra.glyphs.iter().zip(&rb.glyphs) {
12324                if ga.ch != gb.ch || ga.src != gb.src {
12325                    return true;
12326                }
12327            }
12328        }
12329        false
12330    }
12331
12332    #[test]
12333    fn incremental_build_matches_a_fresh_build_across_edits() {
12334        // Every `Doc` edit rebuilds through `build_spliced` (the single-block
12335        // fast path, gated on twig's `dirty_range`) or falls back to
12336        // `build_cached`. After each edit the map must be byte-identical to a
12337        // from-scratch build — this is the correctness net under the splice.
12338        let docs = [
12339            "# Title\n\nThe quick brown fox jumps.\n\nAnother paragraph here.\n\n- a\n- b\n",
12340            "para one\n\n> quote **bold** text\n> continued line\n\ntail paragraph\n",
12341            "alpha\n\nbeta\n\ngamma\n\ndelta\n\nepsilon\n\nzeta\n",
12342            // A footnote definition is a root beside `doc`, merged back into the
12343            // top-level list by `wysiwyg::top_blocks`. The random edits below
12344            // make and unmake definitions as they go (a deleted `:` turns one
12345            // back into a paragraph, and vice versa), which is exactly the
12346            // structural churn the splice path has to notice and bail out of.
12347            "text[^1] here\n\n[^1]: the note\n\nmore text[^b]\n\n[^b]: second\n",
12348            // A comment is a top-level block that draws no rows — a layout entry
12349            // at zero rows either side of blocks that do. The edits below type
12350            // into the blocks around it (a splice past a hidden block), and
12351            // break the comment open into prose and back (a structural change).
12352            "intro\n\n<!-- exec -->\n```\ncode\n```\n\nafter the comment\n\n<!-- trail -->\n",
12353            // Link reference definitions: a hidden block that an edit can turn
12354            // into a paragraph (a deleted `:`) and back, and whose own bytes an
12355            // edit can land in.
12356            "see [a] and [b]\n\n[a]: /a\n\nmid text\n\n[b]: /b\n",
12357        ];
12358        // A deterministic mix: mostly single characters (which stay inside one
12359        // block → splice), plus edits that reshape structure (a paragraph break,
12360        // a heading marker, a code fence → fallback), so both paths are exercised.
12361        let inserts = ["x", "y", "\n\n", "#", "`", " ", "z"];
12362        for src in docs {
12363            let mut d = wysiwyg_doc("diff", src);
12364            d.build_visual_unwrapped();
12365            wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "initial");
12366
12367            for step in 0..60usize {
12368                let len = d.source.len();
12369                let raw = (step * 13 + 5) % (len + 1);
12370                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
12371                let pre = d.source.clone();
12372                let action;
12373                if step % 3 == 0 && pos < len {
12374                    let end = (pos + 1..=len)
12375                        .find(|&i| d.source.is_char_boundary(i))
12376                        .unwrap();
12377                    action = format!("delete [{pos},{end})");
12378                    d.edit(pos, end, "");
12379                } else {
12380                    let ins = inserts[step % inserts.len()];
12381                    action = format!("insert {ins:?} @ {pos}");
12382                    d.edit(pos, pos, ins);
12383                }
12384                d.build_visual_unwrapped();
12385                if maps_differ(&d.vmap, &reference_map(&d.source)) {
12386                    panic!(
12387                        "FIRST MISMATCH at step {step}: {action}\n  pre  = {pre:?}\n  post = {:?}",
12388                        d.source
12389                    );
12390                }
12391            }
12392        }
12393    }
12394
12395    /// A frontend is handed [`Doc::vmap`] and may present it differently:
12396    /// leaf-ratatui splices blank filler rows under an oversized heading so the
12397    /// raster it paints there has somewhere to stand, and leaves them in the map
12398    /// because the caret and the mouse both read it between frames. The splice
12399    /// path addresses that map by *row index*, against the block layout the last
12400    /// build recorded — so handed a map with rows in it that no block owns, it
12401    /// laid the re-rendered block over one of the fillers and carried the rows
12402    /// the block really occupied into the suffix. One stranded copy of the
12403    /// edited line, and everything below it a row further down, per keystroke.
12404    ///
12405    /// A map that isn't the one the layout describes is a map this path can't
12406    /// patch, whoever changed it and for whatever reason. It rebuilds instead.
12407    #[test]
12408    fn an_edit_over_a_map_a_frontend_reshaped_rebuilds_it_whole() {
12409        let mut d = wysiwyg_doc("reshaped", "# Title\n\nThe quick brown fox jumps.\n");
12410        d.build_visual_unwrapped();
12411
12412        // Stand in for the heading filler rows: two blank rows past the heading
12413        // that no block accounts for. Cloning a real row keeps every field
12414        // plausible — it is the row *count* the splice can't survive.
12415        let filler = d.vmap.rows[0].clone();
12416        d.vmap.rows.insert(1, filler.clone());
12417        d.vmap.rows.insert(1, filler);
12418
12419        // An edit inside the last block: the single-block case the splice path
12420        // is for, and the one the frontend hits on every keystroke.
12421        let at = d.source.len() - 1;
12422        d.edit(at, at, "!");
12423        d.build_visual_unwrapped();
12424
12425        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the edit");
12426    }
12427
12428    /// A glyph's [`FaceId`] has to mean the same thing however its row was
12429    /// built. A row comes three ways — a fresh walk, a [`BlockCache`] hit
12430    /// cloned at a shifted offset, and a previous map's rows a splice kept
12431    /// untouched — and only the first of those walks a `data-font` at all. An
12432    /// index into a per-build table would have had the same glyph naming two
12433    /// families the moment a second one appeared; the id is the name's own
12434    /// hash, so nothing is remapped and the table is merged rather than rebuilt.
12435    ///
12436    /// Two families, because one cannot tell a wrong id from a right one.
12437    ///
12438    /// [`FaceId`]: crate::style::FaceId
12439    /// [`BlockCache`]: crate::wysiwyg::BlockCache
12440    #[test]
12441    fn a_spliced_rebuild_still_says_which_family_each_glyph_is_set_in() {
12442        use crate::style::{FaceId, FaceRef};
12443        let garamond = FaceId::of("Garamond");
12444        let futura = FaceId::of("Futura");
12445        let mut d = wysiwyg_doc(
12446            "two_faces",
12447            "x <span data-font=\"Garamond\">alpha</span>\n\ny <span data-font=\"Futura\">beta</span>\n",
12448        );
12449        d.build_visual_unwrapped();
12450
12451        // What the map has to keep saying, whichever path built it.
12452        let check = |d: &Doc, ctx: &str| {
12453            let face_of = |ch: char| {
12454                d.vmap
12455                    .rows
12456                    .iter()
12457                    .flat_map(|r| r.glyphs.iter())
12458                    .find(|g| g.ch == ch)
12459                    .map(|g| g.style.font)
12460            };
12461            assert_eq!(face_of('a'), Some(Some(FaceRef::Named(garamond))), "{ctx}");
12462            assert_eq!(face_of('b'), Some(Some(FaceRef::Named(futura))), "{ctx}");
12463            assert_eq!(d.vmap.face_name(garamond), Some("Garamond"), "{ctx}");
12464            assert_eq!(d.vmap.face_name(futura), Some("Futura"), "{ctx}");
12465            assert_eq!(d.vmap.face_name(FaceId::of("Bodoni")), None, "{ctx}");
12466        };
12467        check(&d, "fresh");
12468
12469        // An edit inside the second block: the single-block case the splice
12470        // path is for. The first block's rows are carried over untouched, so
12471        // its glyphs' ids are the previous build's and the table has to be too.
12472        let at = d.source.find("beta").unwrap();
12473        d.edit(at, at, "z");
12474        d.build_visual_unwrapped();
12475        check(&d, "after an edit in the second block");
12476        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced");
12477
12478        // And the other way round, so the block that was kept is the one that
12479        // is now re-rendered.
12480        let at = d.source.find("alpha").unwrap();
12481        d.edit(at, at, "z");
12482        d.build_visual_unwrapped();
12483        check(&d, "after an edit in the first block");
12484        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "spliced again");
12485
12486        // A structural edit is one the splice bails out of, so the map is
12487        // reassembled by `build_cached` — where an untouched block is a *cache
12488        // hit* and its rows are cloned without a `data-font` being walked
12489        // again. The names the entry stored are what keeps the table honest
12490        // there.
12491        let at = d.source.find("\n\ny ").unwrap();
12492        d.edit(at, at, "\n\nmiddle");
12493        d.build_visual_unwrapped();
12494        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached");
12495        // Twice, because that first `build_cached` is what stores the entries:
12496        // this one is the build where the Garamond block is a *hit*, its rows
12497        // cloned with their ids and no attribute walked to explain them.
12498        let at = d.source.len() - 1;
12499        d.edit(at, at, "\n\ntail");
12500        d.build_visual_unwrapped();
12501        check(&d, "after a structural edit, through the block cache");
12502        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "cached again");
12503
12504        // A family the edit took the last glyph of leaves the glyphs with no
12505        // face and the table with a name nothing asks for — harmless, and the
12506        // price of not walking the rows the splice exists to avoid walking.
12507        let span = d.source.find("<span data-font=\"Futura\">").unwrap();
12508        let end = d.source.rfind("</span>").unwrap() + "</span>".len();
12509        d.edit(span, end, "beta");
12510        d.build_visual_unwrapped();
12511        assert!(!d.source.contains("Futura"), "{:?}", d.source);
12512        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "the face removed");
12513    }
12514
12515    #[test]
12516    fn incremental_build_matches_a_fresh_build_under_full_reveal() {
12517        // The same correctness net as `incremental_build_matches_a_fresh_build_
12518        // across_edits`, under `MarkupMode::Full` — where the map depends on
12519        // the caret's *line* as well as the text, so the two caches have a new
12520        // way to be wrong. Both are exercised: the block cache can hand back
12521        // rows built for a line that is no longer the revealed one, and the
12522        // splice path can reuse a suffix that still has yesterday's line raw.
12523        //
12524        // Caret motion is interleaved with the edits deliberately, because a
12525        // caret that only ever moved with the edit would never cross a line
12526        // without also dirtying it — the case where a stale reveal survives.
12527        let docs = [
12528            "# Title\n\n*one* and **two**\n\n[lk](http://x) and `code`\n\n- a *b*\n",
12529            "para *em* one\n\n> quote **bold** text\n\ntail ~~del~~ paragraph\n",
12530        ];
12531        let inserts = ["x", "*", "\n\n", "#", "`", " ", "_"];
12532        for src in docs {
12533            let mut d = wysiwyg_doc("reveal_diff", src);
12534            d.set_markup_mode(MarkupMode::Full);
12535
12536            for step in 0..60usize {
12537                let len = d.source.len();
12538                let raw = (step * 13 + 5) % (len + 1);
12539                let pos = (raw..=len).find(|&i| d.source.is_char_boundary(i)).unwrap();
12540                let pre = d.source.clone();
12541                let action;
12542                if step % 3 == 0 && pos < len {
12543                    let end = (pos + 1..=len)
12544                        .find(|&i| d.source.is_char_boundary(i))
12545                        .unwrap();
12546                    action = format!("delete [{pos},{end})");
12547                    d.edit(pos, end, "");
12548                } else {
12549                    let ins = inserts[step % inserts.len()];
12550                    action = format!("insert {ins:?} @ {pos}");
12551                    d.edit(pos, pos, ins);
12552                }
12553                // Walk the caret somewhere else in the document, independently
12554                // of where the edit landed.
12555                let want = (step * 29 + 11) % (d.source.len() + 1);
12556                d.caret = (want..=d.source.len())
12557                    .find(|&i| d.source.is_char_boundary(i))
12558                    .unwrap();
12559                d.build_visual_unwrapped();
12560
12561                let want = reference_map_revealing(&d.source, d.reveal_line());
12562                if maps_differ(&d.vmap, &want) {
12563                    panic!(
12564                        "FIRST MISMATCH at step {step}: {action}, caret {}\n  pre  = {pre:?}\n  post = {:?}",
12565                        d.caret, d.source
12566                    );
12567                }
12568            }
12569        }
12570    }
12571
12572    #[test]
12573    fn caret_motion_across_lines_rebuilds_only_under_full() {
12574        // The cache-key change has to earn its keep in both directions: `Full`
12575        // must rebuild when the caret changes line (or the reveal would never
12576        // move), and the hidden modes must *not* (or every arrow key would pay
12577        // for a feature they don't use). The existing `cache_motion` test pins
12578        // the second for the default mode; this pins the pair against a mode
12579        // change alone.
12580        let body = "*one* here\n\n*two* there\n";
12581
12582        let mut full = doc_in(View::Wysiwyg, "motion_full", body);
12583        full.set_markup_mode(MarkupMode::Full);
12584        caret_at(&mut full, "one");
12585        let before = full.revision();
12586        caret_at(&mut full, "two");
12587        assert_eq!(full.revision(), before, "motion is not an edit");
12588        assert!(
12589            drawn_rows(&full).iter().any(|r| r == "*two* there"),
12590            "the map followed the caret: {:?}",
12591            drawn_rows(&full)
12592        );
12593
12594        let mut hidden = doc_in(View::Wysiwyg, "motion_hidden", body);
12595        caret_at(&mut hidden, "one");
12596        let key = hidden.vmap_key.clone();
12597        caret_at(&mut hidden, "two");
12598        assert_eq!(
12599            hidden.vmap_key, key,
12600            "a hidden mode rebuilds nothing on motion"
12601        );
12602    }
12603
12604    #[test]
12605    fn wysiwyg_down_crosses_a_paragraph_boundary() {
12606        // Regression: the blank separator row used to share the previous
12607        // paragraph's end offset, so Down got pinned at the boundary (while Up
12608        // still crossed). Both directions must step through it symmetrically.
12609        //
12610        // It's now stepped *over* rather than onto: the blank line between two
12611        // paragraphs is the boundary being drawn, not a line of the document, so
12612        // one press of Down crosses it. The goal column survives the crossing —
12613        // col 3 at the end of "abc" is col 3 at the end of "def".
12614        let mut d = wysiwyg_doc("wys_down", "abc\n\ndef\n");
12615        d.caret = 3; // end of "abc" (row 0)
12616        d.move_down(false);
12617        assert_eq!(d.caret_pos().0, 2, "Down should reach the second paragraph");
12618        assert_eq!(d.caret, 8); // end of "def", col 3 kept
12619        d.move_up(false);
12620        assert_eq!(d.caret_pos().0, 0, "Up should come back symmetrically");
12621        assert_eq!(d.caret, 3);
12622    }
12623
12624    #[test]
12625    fn wysiwyg_up_and_down_are_inverse_across_paragraphs() {
12626        // The second Up and the second Down here run off the ends of the
12627        // document, which is no longer a place a press is swallowed: they carry
12628        // the caret to the start and the end of the text. The claim in the
12629        // middle — that a Down retraces the Up that crossed the paragraph gap —
12630        // is the one this test is for, and it is asserted where it is made.
12631        let mut d = wysiwyg_doc("wys_updown", "abc\n\ndef\n");
12632        d.caret = 5; // start of "def"
12633        let start = d.caret_pos();
12634        d.move_up(false);
12635        assert_eq!(d.caret_pos().0, 0, "Up reaches the first paragraph");
12636        d.move_up(false);
12637        assert_eq!(d.caret, 0, "a second Up runs on to the document's start");
12638        d.move_down(false);
12639        assert_eq!(d.caret_pos(), start, "Down retraces Up exactly");
12640        d.move_down(false);
12641        assert_eq!(d.caret, 8, "a second Down runs on to the document's end");
12642    }
12643
12644    #[test]
12645    fn wysiwyg_new_paragraph_shows_before_typing() {
12646        // Regression: two Enters at the end of a paragraph produced trailing
12647        // newlines with no AST node, so the caret appeared stuck on the old line
12648        // until a character was typed. It must ride down onto the new line now.
12649        let mut d = doc_with("wys_newpara", "abc\n");
12650        d.view = View::Wysiwyg;
12651        d.caret = 3;
12652        d.insert("\n");
12653        d.insert("\n"); // source is now "abc\n\n\n", caret at 5
12654        assert_eq!(d.source, "abc\n\n\n");
12655        d.build_visual(80);
12656        let (row, _) = d.caret_pos();
12657        assert!(
12658            row >= 2,
12659            "caret should have moved down to the new line, got row {row}"
12660        );
12661        assert!(
12662            d.vmap.num_rows() >= 3,
12663            "the blank lines should render as rows"
12664        );
12665    }
12666
12667    #[test]
12668    fn wysiwyg_enter_between_paragraphs_lands_on_an_empty_line() {
12669        // The reported bug: Enter at the end of a paragraph that has another
12670        // paragraph below put the caret at the *start of the next paragraph* —
12671        // the empty paragraph it opened had no row, so the caret snapped onto
12672        // "World". It must now sit on its own empty line, with a blank spacer
12673        // above it (the paragraph gap).
12674        let mut d = wysiwyg_doc("wys_gap_mid", "Hello\n\nWorld\n");
12675        d.caret = 5; // end of "Hello"
12676        d.newline();
12677        d.build_visual(80);
12678        let (row, col) = d.caret_pos();
12679        assert_eq!(col, 0, "caret should start an empty line, not sit in text");
12680        assert_eq!(
12681            d.vmap.row_width(row),
12682            0,
12683            "caret's row must be empty, not 'World'"
12684        );
12685        assert!(
12686            row >= 2,
12687            "a blank spacer row should sit above the caret, got row {row}"
12688        );
12689        // The row above the caret is a real (empty) gap, and "Hello" stays put.
12690        assert_eq!(
12691            d.vmap.row_width(row - 1),
12692            0,
12693            "the row above the caret is a gap"
12694        );
12695        let row0: String = d.vmap.rows[0].glyphs.iter().map(|g| g.ch).collect();
12696        assert_eq!(row0, "Hello", "the paragraph above the caret must not move");
12697    }
12698
12699    #[test]
12700    fn wysiwyg_enter_at_eof_shows_a_gap_before_typing() {
12701        // At the document end a single Enter must also show the paragraph gap —
12702        // a blank spacer row above the caret — so the layout already matches how
12703        // it will look once the new paragraph has text.
12704        let mut d = wysiwyg_doc("wys_gap_eof", "Hello");
12705        d.caret = 5; // end of "Hello", no trailing newline
12706        d.newline(); // source becomes "Hello\n\n"
12707        d.build_visual(80);
12708        let (row, col) = d.caret_pos();
12709        assert_eq!(col, 0);
12710        assert!(
12711            row >= 2,
12712            "caret should sit below a blank spacer, got row {row}"
12713        );
12714        assert_eq!(
12715            d.vmap.row_width(row - 1),
12716            0,
12717            "the row above the caret is a gap"
12718        );
12719    }
12720
12721    #[test]
12722    fn wysiwyg_typing_after_enter_does_not_shift_the_caret_row() {
12723        // The spacer is view-only: typing the new paragraph must not reflow the
12724        // caret onto a different row — the transient view already matched the
12725        // settled one.
12726        let mut d = wysiwyg_doc("wys_no_reflow", "Hello\n\nWorld\n");
12727        d.caret = 5;
12728        d.newline();
12729        d.build_visual(80);
12730        let before = d.caret_pos();
12731        d.insert("New");
12732        d.build_visual(80);
12733        let after = d.caret_pos();
12734        assert_eq!(
12735            after.0, before.0,
12736            "typing must not move the caret to another row ({before:?} -> {after:?})"
12737        );
12738    }
12739
12740    #[test]
12741    fn wysiwyg_return_on_the_last_code_line_keeps_the_caret_in_the_block() {
12742        // Return at the end of the block's last line writes an empty line the
12743        // map used to drop, so the caret landed on `after` and the next
12744        // keystroke went into the paragraph below instead of into the code.
12745        let mut d = wysiwyg_doc("code_return", "prose\n\n```\nalpha\nbeta\n```\n\nafter\n");
12746        d.caret = d.source.find("beta").unwrap() + "beta".len();
12747        d.build_visual(80);
12748        let before = d.caret_pos().0;
12749
12750        d.newline();
12751        d.build_visual(80);
12752        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\n\n```\n\nafter\n");
12753
12754        let (row, col) = d.caret_pos();
12755        assert_eq!(row, before + 1, "the caret moves down one row");
12756        assert_eq!(col, 0, "onto the head of the empty line");
12757        let span = d.vmap.code_blocks[0].rows_span.clone();
12758        assert!(
12759            span.contains(&row),
12760            "caret row {row} is outside the block's rows {span:?}"
12761        );
12762
12763        // The whole point: what is typed next is code.
12764        d.insert("gamma");
12765        assert_eq!(d.source, "prose\n\n```\nalpha\nbeta\ngamma\n```\n\nafter\n");
12766    }
12767
12768    #[test]
12769    fn wysiwyg_hides_frontmatter_from_the_caret_and_copy() {
12770        let fm = "---\ntitle: hi\n---\n";
12771        let body = format!("{fm}# leaf\n\nbody\n");
12772        let mut d = wysiwyg_doc("wys_fm", &body);
12773        // Opening lifts the caret out of the now-hidden frontmatter.
12774        assert_eq!(
12775            d.caret,
12776            fm.len(),
12777            "caret should start at the first real block"
12778        );
12779        // Left at the content start can't step back into frontmatter.
12780        d.move_left(false);
12781        assert_eq!(d.caret, fm.len(), "left must not enter frontmatter");
12782        // Doc-start lands on the content floor, not offset 0.
12783        d.move_doc_start(false);
12784        assert_eq!(d.caret, fm.len());
12785        // Select-all + copy never include the frontmatter bytes.
12786        d.select_all();
12787        let sel = d.selected_text().unwrap().to_string();
12788        assert!(!sel.contains("title"), "copy leaked frontmatter: {sel:?}");
12789        assert!(
12790            sel.starts_with("# leaf"),
12791            "selection should begin at content: {sel:?}"
12792        );
12793    }
12794
12795    #[test]
12796    fn typing_in_a_frontmatter_only_document_lands_after_the_frontmatter() {
12797        // A fresh note is frontmatter and nothing else. With no rendered block
12798        // to floor the caret it opened at offset 0 — before the opening `---` —
12799        // so the first keystroke wrote itself in front of the metadata and the
12800        // file came out as `This---\ntitle: …`.
12801        let fm = "---\ntitle: 2026-08-29\nid: f8s32cd\n---\n";
12802        let mut d = wysiwyg_doc("wys_fm_only", fm);
12803        assert_eq!(d.caret, fm.len(), "caret must open past the frontmatter");
12804        // Nothing is rendered, so the caret draws at the origin of an empty view
12805        // — the same place an empty document puts it.
12806        assert_eq!(d.caret_pos(), (0, 0));
12807        d.insert("This");
12808        assert_eq!(d.source, format!("{fm}This"));
12809    }
12810
12811    /// `select_range` is the verb for a range a host already knows the bytes of,
12812    /// so it must not snap — and must still hold every invariant `place_caret`
12813    /// holds, the frontmatter floor above all.
12814    #[test]
12815    fn select_range_takes_the_range_as_given_but_still_floors_it() {
12816        let fm = "---\ntitle: foo\n---\n\n";
12817        let body = format!("{fm}body foo here\n");
12818        let mut d = wysiwyg_doc("wys_select_range", &body);
12819
12820        // The `foo` in the body: taken exactly, not snapped to a caret stop.
12821        let at = body.rfind("foo").unwrap();
12822        d.select_range(at, at + 3);
12823        assert_eq!(d.selection(), Some((at, at + 3)));
12824        assert_eq!(d.selected_text(), Some("foo"));
12825
12826        // The `foo` in the hidden frontmatter: below the floor, so both ends
12827        // come up to it rather than parking the caret in the metadata, where a
12828        // later keystroke would rewrite `title:`.
12829        let hidden = body.find("foo").unwrap();
12830        assert!(hidden < d.vmap.content_start);
12831        d.select_range(hidden, hidden + 3);
12832        assert!(
12833            d.caret >= d.vmap.content_start && d.anchor.unwrap() >= d.vmap.content_start,
12834            "a range under the floor must not leave the caret in the frontmatter"
12835        );
12836
12837        // Past the end, and mid-character, are both brought back to something
12838        // sliceable rather than panicking the next reader of the range.
12839        let multi = wysiwyg_doc("wys_select_range_utf8", "héllo\n");
12840        let mut d = multi;
12841        d.select_range(2, 9_999);
12842        assert_eq!(d.caret, d.source.len());
12843        assert!(d.source.is_char_boundary(d.anchor.unwrap()));
12844        assert!(d.source.is_char_boundary(d.caret));
12845    }
12846
12847    /// The bug `select_range` exists for: a match butting up against a hidden
12848    /// delimiter. `place_caret` snaps to the nearest *visible* stop, which is
12849    /// the one before the `**`.
12850    #[test]
12851    fn select_range_does_not_snap_off_a_hidden_delimiter() {
12852        let mut d = wysiwyg_doc("wys_select_range_bold", "a **needle** in it\n");
12853        let at = d.source.find("needle").unwrap();
12854        d.select_range(at, at + 6);
12855        assert_eq!(d.selected_text(), Some("needle"), "not \"needl\"");
12856    }
12857
12858    #[test]
12859    fn wysiwyg_backspace_at_content_start_leaves_frontmatter_intact() {
12860        // Backspace deletes `prev_boundary..caret` directly; at the first real
12861        // block that boundary is inside the hidden frontmatter, so it must be a
12862        // no-op rather than eating the closing `---`.
12863        let fm = "---\ntitle: hi\n---\n";
12864        let body = format!("{fm}leaf\n");
12865        let mut d = wysiwyg_doc("wys_fm_bs", &body);
12866        assert_eq!(d.caret, fm.len());
12867        d.backspace();
12868        assert_eq!(d.source, body, "backspace must not touch frontmatter");
12869        d.delete_word_back();
12870        assert_eq!(
12871            d.source, body,
12872            "word-delete must not touch frontmatter either"
12873        );
12874    }
12875
12876    #[test]
12877    fn wysiwyg_edits_inside_a_vis_directive_block_without_disturbing_its_fences() {
12878        // diaryx's `:::vis{.audience}` visibility block — any `:::name{.class}`
12879        // fenced div, really, since core parses these on for every document
12880        // now (`parse_extensions`). The container is a `directive` node, an
12881        // `is_block_container` kind like `block_quote`, so the caret works
12882        // inside its child paragraph exactly as it would inside a quote: typing
12883        // edits the paragraph, and the `:::vis{...}` / `:::` fences round-trip
12884        // untouched.
12885        let body = ":::vis{.public .family}\nhello\n:::\nafter\n";
12886        let mut d = wysiwyg_doc("wys_vis", body);
12887        d.caret = body.find("hello").unwrap() + "hello".len();
12888        d.insert("!");
12889        assert_eq!(
12890            d.source, ":::vis{.public .family}\nhello!\n:::\nafter\n",
12891            "typing inside the block edits its content in place"
12892        );
12893        assert!(
12894            d.source.contains(":::vis{.public .family}"),
12895            "opening fence survives"
12896        );
12897        assert!(d.source.contains(":::\nafter"), "closing fence survives");
12898    }
12899
12900    #[test]
12901    fn source_view_still_reaches_frontmatter() {
12902        // The metadata is only *hidden*, never lost: the source view edits and
12903        // selects it in full, and it's always preserved on save.
12904        let fm = "---\ntitle: hi\n---\n";
12905        let body = format!("{fm}# leaf\n");
12906        let mut d = doc_with("src_fm", &body);
12907        d.select_all();
12908        let sel = d.selected_text().unwrap();
12909        assert!(
12910            sel.contains("title"),
12911            "source view should select everything"
12912        );
12913        d.move_doc_start(false);
12914        assert_eq!(d.caret, 0, "source view can reach offset 0");
12915    }
12916
12917    const TABLE: &str = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n| Fig | 12 |\n";
12918
12919    #[test]
12920    fn wysiwyg_right_crosses_a_cell_border_without_stalling() {
12921        // The border and padding between two cells all share one source offset,
12922        // so a column-stepping caret would sit on `│` and then stall there
12923        // forever. Right must step: end of "Name" -> start of "Qty".
12924        let mut d = wysiwyg_doc("tbl_right", TABLE);
12925        d.caret = TABLE.find("Name").unwrap() + 4; // just after "Name"
12926        d.move_right(false);
12927        assert_eq!(
12928            d.caret,
12929            TABLE.find("Qty").unwrap(),
12930            "should land in the next cell"
12931        );
12932        let (r, c) = d.caret_pos();
12933        assert_eq!(d.vmap.rows[r].glyphs[c].ch, 'Q');
12934    }
12935
12936    #[test]
12937    fn wysiwyg_left_crosses_back_to_the_previous_cell() {
12938        let mut d = wysiwyg_doc("tbl_left", TABLE);
12939        d.caret = TABLE.find("Qty").unwrap();
12940        d.move_left(false);
12941        assert_eq!(
12942            d.caret,
12943            TABLE.find("Name").unwrap() + 4,
12944            "end of the previous cell"
12945        );
12946    }
12947
12948    #[test]
12949    fn wysiwyg_down_steps_over_a_table_rule() {
12950        // Between the header and the first body row sits a `├───┼───┤` rule.
12951        // It's drawn but holds no caret, so one Down must reach "Pear".
12952        let mut d = wysiwyg_doc("tbl_down", TABLE);
12953        d.caret = TABLE.find("Name").unwrap();
12954        d.move_down(false);
12955        assert_eq!(
12956            d.caret,
12957            TABLE.find("Pear").unwrap(),
12958            "one Down reaches the body row"
12959        );
12960        d.move_down(false);
12961        assert_eq!(d.caret, TABLE.find("Fig").unwrap());
12962    }
12963
12964    #[test]
12965    fn wysiwyg_tab_walks_the_cells_and_shift_tab_walks_back() {
12966        let mut d = wysiwyg_doc("tbl_tab", TABLE);
12967        d.caret = TABLE.find("Name").unwrap();
12968        // A hop lands with the destination cell's whole content selected, the
12969        // caret at its end — so typing replaces the cell like a form field.
12970        assert!(d.cell_hop(true));
12971        assert_eq!(
12972            d.selected_text(),
12973            Some("Qty"),
12974            "the target cell comes up selected"
12975        );
12976        assert_eq!(d.caret, TABLE.find("Qty").unwrap() + "Qty".len());
12977        assert!(d.cell_hop(true), "Tab wraps onto the next row's first cell");
12978        assert_eq!(d.selected_text(), Some("Pear"));
12979        assert!(d.cell_hop(false));
12980        assert_eq!(d.selected_text(), Some("Qty"));
12981    }
12982
12983    #[test]
12984    fn tab_outside_a_table_is_not_a_cell_hop() {
12985        // `cell_hop` reports false so the frontend can indent as usual.
12986        let mut d = wysiwyg_doc("tbl_none", "just a paragraph\n");
12987        d.caret = 4;
12988        assert!(!d.cell_hop(true));
12989        assert_eq!(d.caret, 4, "a refused hop leaves the caret alone");
12990    }
12991
12992    #[test]
12993    fn tab_at_the_last_cell_declines_rather_than_leaving_the_table() {
12994        let mut d = wysiwyg_doc("tbl_edge", TABLE);
12995        d.caret = TABLE.rfind("12").unwrap(); // the final cell
12996        assert!(!d.cell_hop(true), "no cell after the last one");
12997        d.caret = TABLE.find("Name").unwrap();
12998        assert!(!d.cell_hop(false), "no cell before the first one");
12999    }
13000
13001    #[test]
13002    fn wysiwyg_vertical_cell_motion_holds_the_column() {
13003        // Down/Up step to the cell above/below in the *same column*, not back to
13004        // the top-left the way a naive row/col motion over the picture would.
13005        let mut d = wysiwyg_doc("tbl_vert", TABLE);
13006        d.caret = TABLE.find("Qty").unwrap();
13007        // Each vertical hop selects the destination cell, holding the column.
13008        assert!(d.cell_move_vertical(true));
13009        assert_eq!(d.selected_text(), Some("3"), "Down holds column 1");
13010        assert!(d.cell_move_vertical(true));
13011        assert_eq!(d.selected_text(), Some("12"), "Down again, still column 1");
13012        assert!(!d.cell_move_vertical(true), "no row below the last");
13013        assert!(d.cell_move_vertical(false));
13014        assert_eq!(d.selected_text(), Some("3"), "Up holds column 1");
13015        assert!(d.cell_move_vertical(false));
13016        assert_eq!(d.selected_text(), Some("Qty"), "Up onto the header");
13017        assert!(!d.cell_move_vertical(false), "no row above the header");
13018    }
13019
13020    #[test]
13021    fn tab_off_the_last_cell_grows_a_row_and_enters_it() {
13022        let mut d = wysiwyg_doc("tbl_grow", TABLE);
13023        d.caret = TABLE.rfind("12").unwrap();
13024        let rows_before = d.source.matches('\n').count();
13025        assert!(d.cell_tab(true), "acts as a table key");
13026        assert_eq!(
13027            d.source.matches('\n').count(),
13028            rows_before + 1,
13029            "a fresh row was appended"
13030        );
13031        assert!(d.caret_in_table(), "the caret entered the new row");
13032        // The caret sits in the new row's first cell — past the old last cell.
13033        assert!(d.caret > TABLE.rfind("12").unwrap());
13034    }
13035
13036    #[test]
13037    fn return_in_a_table_drops_a_cell_and_grows_a_row_at_the_bottom() {
13038        let mut d = wysiwyg_doc("tbl_ret", TABLE);
13039        d.caret = TABLE.find("Name").unwrap();
13040        assert!(d.cell_return(), "acts as a table key");
13041        assert_eq!(
13042            d.selected_text(),
13043            Some("Pear"),
13044            "Return drops one cell, selecting it"
13045        );
13046        // From the last row, Return appends a row and enters it.
13047        d.caret = TABLE.rfind("Fig").unwrap();
13048        let rows_before = d.source.matches('\n').count();
13049        assert!(d.cell_return());
13050        assert_eq!(d.source.matches('\n').count(), rows_before + 1);
13051        assert!(d.caret_in_table());
13052    }
13053
13054    #[test]
13055    fn return_and_tab_outside_a_table_decline() {
13056        let mut d = wysiwyg_doc("tbl_decline", "just a paragraph\n");
13057        d.caret = 4;
13058        assert!(!d.cell_return(), "no table: the frontend inserts a newline");
13059        assert!(!d.cell_tab(true), "no table: the frontend indents");
13060        assert!(
13061            !d.cell_line_break(),
13062            "no table: the frontend breaks the line"
13063        );
13064    }
13065
13066    #[test]
13067    fn a_click_under_a_trailing_table_lands_past_it_and_enter_opens_a_line() {
13068        // A document that ends in a table used to end *inside* it: nothing
13069        // past the last cell was a caret stop, so a click in the blank space
13070        // under the grid snapped back into the table and there was no way to
13071        // write a line after it. The bottom border's end is that stop now.
13072        let mut d = wysiwyg_doc("tbl_trail", TABLE);
13073        let rows = d.vmap.num_rows();
13074        d.click(rows + 3, 0, false);
13075        let end = TABLE.trim_end_matches('\n').len();
13076        assert_eq!(d.caret, end, "the caret stands just past the table");
13077        assert!(!d.caret_in_table(), "past the table is outside it");
13078        assert!(!d.cell_return(), "Return there is the frontend's newline");
13079        d.newline();
13080        d.insert("after");
13081        assert_eq!(
13082            d.source,
13083            format!("{TABLE}\nafter\n"),
13084            "Enter opens a paragraph under the table"
13085        );
13086    }
13087
13088    #[test]
13089    fn typing_at_a_table_s_trailing_stop_opens_a_paragraph_first() {
13090        // The stop sits at the end of the table's last source line, and a
13091        // line glued under a table is a row of it — `| Fig | 12 |x` would be a
13092        // three-cell row. So the text gets a paragraph of its own, as it does
13093        // beside a block picture.
13094        let mut d = wysiwyg_doc("tbl_type", TABLE);
13095        d.caret = TABLE.trim_end_matches('\n').len();
13096        d.insert("x");
13097        assert_eq!(d.source, format!("{TABLE}\nx\n"));
13098        assert_eq!(d.caret, TABLE.len() + 2, "the caret follows the text");
13099        // And a paste, which joins the block exactly as typing would.
13100        let mut d = wysiwyg_doc("tbl_paste", TABLE);
13101        d.caret = TABLE.trim_end_matches('\n').len();
13102        d.paste("pasted");
13103        assert_eq!(d.source, format!("{TABLE}\npasted\n"));
13104    }
13105
13106    #[test]
13107    fn right_leaves_a_table_by_its_trailing_stop_and_backspace_steps_back_in() {
13108        let mut d = wysiwyg_doc("tbl_edge", TABLE);
13109        let last_cell_end = TABLE.rfind("12").unwrap() + 2;
13110        let end = TABLE.trim_end_matches('\n').len();
13111        d.caret = last_cell_end;
13112        d.move_right(false);
13113        assert_eq!(d.caret, end, "Right from the last cell leaves the table");
13114        // Backspace there takes no byte: the one behind the caret is the row's
13115        // closing `|`, which the rich view never drew. It steps back instead.
13116        d.backspace();
13117        assert_eq!(d.source, TABLE, "nothing deleted");
13118        assert_eq!(d.caret, last_cell_end, "back into the last cell");
13119        // Down from the last row lands on the same stop, and Up returns.
13120        d.move_down(false);
13121        assert_eq!(d.caret, end, "Down from the last row leaves the table");
13122        d.move_up(false);
13123        assert_eq!(d.caret, last_cell_end);
13124    }
13125
13126    #[test]
13127    fn a_table_s_trailing_stop_sits_between_it_and_the_text_below() {
13128        // With prose under the table, the stop is one hop between the last
13129        // cell and the paragraph — the shape a block picture's second stop has.
13130        let src = format!("{TABLE}\nafter\n");
13131        let mut d = wysiwyg_doc("tbl_mid", &src);
13132        d.caret = TABLE.rfind("12").unwrap() + 2;
13133        d.move_right(false);
13134        assert_eq!(d.caret, TABLE.trim_end_matches('\n').len());
13135        d.move_right(false);
13136        assert_eq!(d.caret, src.find("after").unwrap());
13137        // Typing at the stop still opens a paragraph, and the text below keeps
13138        // its own.
13139        d.move_left(false);
13140        d.insert("x");
13141        assert_eq!(d.source, format!("{TABLE}\nx\n\nafter\n"));
13142    }
13143
13144    #[test]
13145    fn shift_return_inserts_an_in_cell_break_the_renderer_reads_as_a_line() {
13146        let mut d = wysiwyg_doc("tbl_break", TABLE);
13147        d.caret = TABLE.find("Pear").unwrap() + 4; // just after "Pear"
13148        assert!(d.cell_line_break(), "acts as a table key");
13149        assert!(
13150            d.source.contains("Pear<br>"),
13151            "spelled as an inline <br>: {}",
13152            d.source
13153        );
13154        assert!(d.caret_in_table(), "still in the cell, past the break");
13155        // The break renders as a real line: the "Pear" cell now draws two lines,
13156        // so the table's picture is one row taller than a single-line table.
13157        d.build_visual(80);
13158        let table = &d.vmap.tables[0];
13159        let cell = &table.grid[1].cells[0]; // first body row, first column
13160        assert!(
13161            cell.glyphs.iter().any(|g| g.ch == '\n'),
13162            "the cell carries the break as a newline glyph for the frontend to split"
13163        );
13164    }
13165
13166    #[test]
13167    fn shift_return_in_a_markdown_cell_leaves_a_semantic_hard_break_not_raw_html() {
13168        // twig promotes the in-cell `<br>` to a `hard_break`, so the break reads
13169        // back as structure — the whole point of routing through insert_line_break
13170        // instead of splicing raw `<br>` bytes.
13171        let mut d = wysiwyg_doc("tbl_break_semantic", TABLE);
13172        d.caret = TABLE.find("Pear").unwrap() + 4;
13173        assert!(d.cell_line_break());
13174        let kinds: Vec<Kind> = d
13175            .editor
13176            .nodes()
13177            .unwrap()
13178            .iter()
13179            .map(|n| n.kind.clone())
13180            .collect();
13181        assert!(kinds.contains(&Kind::HardBreak), "got {kinds:?}");
13182        assert!(
13183            !kinds.contains(&Kind::RawInline),
13184            "still raw HTML: {kinds:?}"
13185        );
13186    }
13187
13188    #[test]
13189    fn backspace_over_an_in_cell_break_deletes_the_whole_br_not_a_byte() {
13190        // The `<br>` draws as one newline glyph, so Backspace over it must take
13191        // all four bytes — a one-byte delete would strand a visible `<br` in the
13192        // cell (the reported bug).
13193        let mut d = wysiwyg_doc("tbl_break_bs", TABLE);
13194        d.caret = TABLE.find("Pear").unwrap() + 4;
13195        assert!(d.cell_line_break());
13196        assert!(d.source.contains("Pear<br>"), "precondition: {}", d.source);
13197        d.backspace(); // caret sits just past the break
13198        assert!(
13199            !d.source.contains("<br"),
13200            "no half-deleted <br left: {}",
13201            d.source
13202        );
13203        assert!(
13204            d.source.contains("| Pear |"),
13205            "the cell is back to one line: {}",
13206            d.source
13207        );
13208    }
13209
13210    #[test]
13211    fn delete_forward_over_an_in_cell_break_deletes_the_whole_br() {
13212        let mut d = wysiwyg_doc("tbl_break_del", TABLE);
13213        d.caret = TABLE.find("Pear").unwrap() + 4;
13214        assert!(d.cell_line_break());
13215        d.caret = TABLE.find("Pear").unwrap() + 4; // back onto the break's start
13216        d.delete_forward();
13217        assert!(
13218            !d.source.contains("<br"),
13219            "no half-deleted <br: {}",
13220            d.source
13221        );
13222        assert!(
13223            d.source.contains("| Pear |"),
13224            "cell back to one line: {}",
13225            d.source
13226        );
13227    }
13228
13229    #[test]
13230    fn shift_return_in_a_djot_cell_is_swallowed_and_leaves_the_row_intact() {
13231        // Djot has no idiomatic in-cell break, so twig refuses it. The gesture is
13232        // still consumed (a real newline would split the one-line row), but the
13233        // cell must be left exactly as it was — no non-idiomatic `<br>` spliced in.
13234        let src = "| Name | Qty |\n|:-----|----:|\n| Pear | 3 |\n";
13235        let mut d = Doc::from_source(src.to_string(), Format::Djot).unwrap();
13236        d.caret = src.find("Pear").unwrap() + 4;
13237        assert!(d.caret_in_table(), "caret should be inside the djot table");
13238        assert!(
13239            d.cell_line_break(),
13240            "the key is consumed, not passed to the frontend"
13241        );
13242        assert_eq!(d.source, src, "the djot cell is left untouched");
13243        assert!(
13244            !d.source.contains("<br>"),
13245            "no non-idiomatic <br> spliced into djot"
13246        );
13247        assert!(
13248            d.status.is_some(),
13249            "the refusal is surfaced on the status line"
13250        );
13251    }
13252
13253    #[test]
13254    fn typing_in_a_cell_edits_that_cell() {
13255        // Editing comes free once offsets map correctly: the caret is a source
13256        // offset, so a normal splice lands inside the pipe table.
13257        let mut d = wysiwyg_doc("tbl_type", TABLE);
13258        d.caret = TABLE.find("Pear").unwrap() + 4;
13259        d.insert("s");
13260        assert!(d.source.contains("| Pears | 3 |"), "got {:?}", d.source);
13261    }
13262
13263    #[test]
13264    fn motion_and_delete_treat_an_emoji_as_one_character() {
13265        // 👨‍👩‍👧 is a single grapheme built from three emoji joined by ZWJ — 18
13266        // bytes, several codepoints. Right-arrow must clear it in one step, and
13267        // backspace must remove the whole cluster, not a stray joiner.
13268        let family = "👨‍👩‍👧";
13269        let mut d = doc_with("emoji", &format!("a{family}b\n"));
13270        d.caret = 1; // just after 'a', before the emoji
13271        d.move_right(false);
13272        assert_eq!(
13273            d.caret,
13274            1 + family.len(),
13275            "one step clears the whole cluster"
13276        );
13277        assert_eq!(&d.source[d.caret..d.caret + 1], "b");
13278
13279        d.backspace(); // delete the emoji as a unit
13280        assert_eq!(d.source, "ab\n");
13281        assert_eq!(d.caret, 1);
13282    }
13283
13284    #[test]
13285    fn motion_handles_a_combining_accent_as_one_character() {
13286        // "e" + U+0301 (combining acute) renders as one é.
13287        let mut d = doc_with("combining", "e\u{0301}x\n");
13288        d.caret = 0;
13289        d.move_right(false);
13290        assert_eq!(
13291            d.caret,
13292            "e\u{0301}".len(),
13293            "steps past base + combining mark"
13294        );
13295    }
13296
13297    #[test]
13298    fn undo_then_redo_round_trips_an_edit() {
13299        let mut d = doc_with("undo", "hello\n");
13300        d.caret = 5;
13301        d.insert("!");
13302        assert_eq!(d.source, "hello!\n");
13303        d.undo();
13304        assert_eq!(d.source, "hello\n");
13305        assert_eq!(d.caret, 5, "undo restores the caret");
13306        d.redo();
13307        assert_eq!(d.source, "hello!\n");
13308    }
13309
13310    #[test]
13311    fn a_run_of_typing_undoes_as_one_step() {
13312        let mut d = doc_with("coalesce", "\n");
13313        d.caret = 0;
13314        d.insert("a");
13315        d.insert("b");
13316        d.insert("c");
13317        assert_eq!(d.source, "abc\n");
13318        d.undo(); // the whole typed run, not just "c"
13319        assert_eq!(d.source, "\n");
13320        d.undo(); // nothing left — the run was one step
13321        assert_eq!(d.source, "\n");
13322        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
13323    }
13324
13325    // ── IME composition ──────────────────────────────────────────────────────
13326
13327    #[test]
13328    fn a_composition_run_undoes_as_one_step() {
13329        let mut d = doc_with("compose", "\n");
13330        d.caret = 0;
13331        // What an IME does: each step replaces the last one's provisional bytes.
13332        d.edit_composing(0, 0, "k");
13333        d.edit_composing(0, 1, "か");
13334        d.edit_composing(0, 3, "かん");
13335        d.edit_composing(0, 6, "感"); // the commit
13336        d.end_composition();
13337        assert_eq!(d.source, "感\n");
13338        d.undo(); // the whole composition, not its last keystroke
13339        assert_eq!(d.source, "\n");
13340        assert_eq!(d.status.as_deref(), None, "the run was a single step");
13341    }
13342
13343    #[test]
13344    fn two_compositions_are_two_undo_steps() {
13345        let mut d = doc_with("compose_two", "\n");
13346        d.caret = 0;
13347        d.edit_composing(0, 0, "か");
13348        d.edit_composing(0, 3, "蚊");
13349        d.end_composition();
13350        d.edit_composing(3, 3, "き");
13351        d.edit_composing(3, 6, "木");
13352        d.end_composition();
13353        assert_eq!(d.source, "蚊木\n");
13354        d.undo();
13355        assert_eq!(d.source, "蚊\n", "only the second composition");
13356        d.undo();
13357        assert_eq!(d.source, "\n");
13358    }
13359
13360    #[test]
13361    fn a_composition_does_not_fold_into_the_typing_around_it() {
13362        let mut d = doc_with("compose_typing", "\n");
13363        d.caret = 0;
13364        d.insert("a");
13365        d.insert("b");
13366        d.edit_composing(2, 2, "か");
13367        d.edit_composing(2, 5, "蚊");
13368        d.end_composition();
13369        d.insert("c");
13370        assert_eq!(d.source, "ab蚊c\n");
13371        d.undo();
13372        assert_eq!(d.source, "ab蚊\n");
13373        d.undo();
13374        assert_eq!(d.source, "ab\n");
13375        d.undo();
13376        assert_eq!(d.source, "\n");
13377    }
13378
13379    #[test]
13380    fn ending_a_composition_that_never_began_leaves_a_typing_run_alone() {
13381        let mut d = doc_with("compose_spurious", "\n");
13382        d.caret = 0;
13383        d.insert("a");
13384        d.end_composition(); // an IME unmarking unprompted
13385        d.insert("b");
13386        assert_eq!(d.source, "ab\n");
13387        d.undo();
13388        assert_eq!(d.source, "\n", "still one typed run");
13389    }
13390
13391    // ── the clipboard's rich flavor ──────────────────────────────────────────
13392
13393    #[test]
13394    fn an_inline_selection_publishes_html_without_a_paragraph_wrapper() {
13395        let mut d = doc_with("sel_inline", "a **bold** c\n");
13396        d.anchor = Some(2);
13397        d.caret = 10; // `**bold**`, inside the paragraph
13398        assert_eq!(d.selection_html().as_deref(), Some("<strong>bold</strong>"));
13399    }
13400
13401    #[test]
13402    fn a_whole_block_selection_keeps_its_paragraph() {
13403        let mut d = doc_with("sel_block", "a **bold** c\n");
13404        d.anchor = Some(0);
13405        d.caret = 12; // the entire paragraph
13406        assert_eq!(
13407            d.selection_html().as_deref(),
13408            Some("<p>a <strong>bold</strong> c</p>")
13409        );
13410    }
13411
13412    #[test]
13413    fn a_multi_block_selection_keeps_its_structure() {
13414        let mut d = doc_with("sel_multi", "para\n\n- one\n- two\n");
13415        d.select_all();
13416        let html = d.selection_html().expect("renders");
13417        assert!(html.contains("<p>para</p>"), "{html:?}");
13418        assert!(html.contains("<li>one</li>"), "{html:?}");
13419    }
13420
13421    #[test]
13422    fn a_word_inside_a_heading_publishes_as_text_not_a_heading() {
13423        // The fragment `Head` is a paragraph standalone; the *document* says it
13424        // sits inside one block, so the wrapper is an artifact either way.
13425        let mut d = doc_with("sel_heading", "# Head line\n");
13426        d.anchor = Some(2);
13427        d.caret = 6;
13428        assert_eq!(d.selection_html().as_deref(), Some("Head"));
13429    }
13430
13431    #[test]
13432    fn no_selection_publishes_no_html() {
13433        let mut d = doc_with("sel_none", "a b\n");
13434        d.caret = 1;
13435        assert_eq!(d.selection_html(), None);
13436    }
13437
13438    #[test]
13439    fn pasting_html_converts_it_and_is_one_undo_step() {
13440        let mut d = doc_with("paste_html", "x\n");
13441        d.caret = 1;
13442        assert!(d.paste_html("<p>a <strong>b</strong> c</p>"));
13443        assert_eq!(d.source, "xa **b** c\n");
13444        d.undo();
13445        assert_eq!(d.source, "x\n", "the whole paste, in one step");
13446    }
13447
13448    #[test]
13449    fn pasting_html_replaces_the_selection() {
13450        let mut d = doc_with("paste_html_sel", "keep drop\n");
13451        d.anchor = Some(5);
13452        d.caret = 9;
13453        assert!(d.paste_html("<em>new</em>"));
13454        assert_eq!(d.source, "keep *new*\n");
13455    }
13456
13457    #[test]
13458    fn html_that_would_paste_garbage_declines_so_the_caller_falls_back() {
13459        let mut d = doc_with("paste_html_bad", "x\n");
13460        d.caret = 1;
13461        // twig builds no table from HTML; raw `<table>` in prose is worse than
13462        // the plain flavor the caller still holds.
13463        assert!(!d.paste_html("<table><tr><td>a</td></tr></table>"));
13464        assert_eq!(d.source, "x\n", "declined edits nothing");
13465    }
13466
13467    #[test]
13468    fn copy_then_paste_round_trips_through_the_html_flavor() {
13469        let mut d = doc_with("clip_round", "a **b** and [l](https://x.dev)\n");
13470        d.select_all();
13471        let html = d.selection_html().expect("renders");
13472        let mut into = doc_with("clip_round_dst", "\n");
13473        into.caret = 0;
13474        assert!(into.paste_html(&html));
13475        assert_eq!(into.source, "a **b** and [l](https://x.dev)\n");
13476    }
13477
13478    #[test]
13479    fn moving_the_caret_starts_a_new_undo_group() {
13480        let mut d = doc_with("break", "\n");
13481        d.caret = 0;
13482        d.insert("a");
13483        d.insert("b"); // "ab\n", caret at 2
13484        d.move_left(false); // breaks the run
13485        d.insert("X"); // "aXb\n"
13486        assert_eq!(d.source, "aXb\n");
13487        d.undo();
13488        assert_eq!(
13489            d.source, "ab\n",
13490            "first undo removes only the post-move insert"
13491        );
13492        d.undo();
13493        assert_eq!(d.source, "\n", "second undo removes the earlier run");
13494    }
13495
13496    #[test]
13497    fn undo_reverses_a_format_toggle() {
13498        let mut d = doc_with("fmt_undo", "a word b\n");
13499        d.anchor = Some(2);
13500        d.caret = 6;
13501        d.toggle(InlineKind::Strong);
13502        assert_eq!(d.source, "a **word** b\n");
13503        d.undo();
13504        assert_eq!(d.source, "a word b\n");
13505    }
13506
13507    #[test]
13508    fn undo_back_to_the_saved_state_clears_dirty() {
13509        let mut d = doc_with("dirty_undo", "hello\n");
13510        assert!(!d.dirty);
13511        d.caret = 5;
13512        d.insert("!");
13513        assert!(d.dirty);
13514        d.undo();
13515        assert!(
13516            !d.dirty,
13517            "undoing to the saved source is not a modification"
13518        );
13519    }
13520
13521    #[test]
13522    fn a_new_edit_invalidates_redo() {
13523        let mut d = doc_with("redo_inv", "\n");
13524        d.caret = 0;
13525        d.insert("a");
13526        d.undo();
13527        d.insert("b"); // diverges — the redo of "a" is now gone
13528        d.redo();
13529        assert_eq!(d.source, "b\n");
13530    }
13531
13532    #[test]
13533    fn can_undo_and_can_redo_follow_the_history_a_menu_would_enable_by() {
13534        let mut d = doc_with("can_undo", "hello\n");
13535        assert!(
13536            !d.can_undo() && !d.can_redo(),
13537            "a fresh document has no history"
13538        );
13539        d.caret = 5;
13540        d.insert("!");
13541        assert!(
13542            d.can_undo() && !d.can_redo(),
13543            "an edit is a step to take back"
13544        );
13545        d.undo();
13546        assert!(!d.can_undo() && d.can_redo(), "undone: only redo remains");
13547        d.redo();
13548        assert!(d.can_undo() && !d.can_redo(), "redone: back to undoable");
13549        d.undo();
13550        d.insert("?");
13551        assert!(
13552            d.can_undo() && !d.can_redo(),
13553            "a fresh edit ends the redo chain"
13554        );
13555        // A coalesced run over-counts steps — the bound is what a menu needs,
13556        // and it reconciles the moment twig reports the history empty.
13557        d.insert("a");
13558        d.insert("b");
13559        while d.can_undo() {
13560            d.undo();
13561        }
13562        assert_eq!(d.source, "hello\n");
13563        assert!(!d.can_undo());
13564        // A reading surface has nothing to undo, whatever the history holds.
13565        d.redo();
13566        d.set_read_only(true);
13567        assert!(!d.can_undo() && !d.can_redo());
13568    }
13569
13570    #[test]
13571    fn undo_on_empty_history_is_a_no_op() {
13572        let mut d = doc_with("undo_empty", "hi\n");
13573        d.undo();
13574        assert_eq!(d.source, "hi\n");
13575        assert_eq!(d.status.as_deref(), Some("nothing to undo"));
13576    }
13577
13578    #[test]
13579    fn a_one_character_paste_is_its_own_undo_step() {
13580        for view in [View::Source, View::Wysiwyg] {
13581            let mut d = doc_in(view, "paste_step", "ab\n");
13582            d.caret = 0;
13583            d.insert("x");
13584            d.insert("y"); // a run of typing
13585            d.paste("z"); // one character, but pasted — not part of that run
13586            assert_eq!(d.source, "xyzab\n");
13587            d.undo();
13588            assert_eq!(d.source, "xyab\n", "the paste undoes on its own");
13589            assert_eq!(d.caret, 2, "and hands back the caret it found");
13590            d.undo();
13591            assert_eq!(d.source, "ab\n", "the typed run is still one step under it");
13592        }
13593    }
13594
13595    #[test]
13596    fn the_same_character_typed_still_joins_the_run() {
13597        // The other half of the pair: `z` is a keystroke here and a paste above,
13598        // and the two undo differently. Nothing about the *string* says which —
13599        // which is why provenance has to come from the door the caller uses.
13600        for view in [View::Source, View::Wysiwyg] {
13601            let mut d = doc_in(view, "typed_run", "ab\n");
13602            d.caret = 0;
13603            d.insert("x");
13604            d.insert("y");
13605            d.insert("z");
13606            d.undo();
13607            assert_eq!(d.source, "ab\n", "one run, one step");
13608        }
13609    }
13610
13611    #[test]
13612    fn undo_restores_the_caret_to_where_it_was_not_to_the_edit_site() {
13613        for view in [View::Source, View::Wysiwyg] {
13614            let mut d = doc_in(view, "undo_caret", "hello world\n");
13615            d.caret = 11; // standing at the end of "world", away from the edit
13616            d.edit(0, 5, "goodbye");
13617            assert_eq!(d.source, "goodbye world\n");
13618            d.undo();
13619            assert_eq!(d.source, "hello world\n");
13620            // The undone edit ends at offset 5; the user was at 11.
13621            assert_eq!(d.caret, 11, "the caret comes back with the bytes");
13622        }
13623    }
13624
13625    #[test]
13626    fn undo_restores_the_selection_the_edit_replaced() {
13627        for view in [View::Source, View::Wysiwyg] {
13628            let mut d = doc_in(view, "undo_sel", "a word b\n");
13629            d.anchor = Some(2);
13630            d.caret = 6; // "word" selected
13631            d.insert("X");
13632            assert_eq!(d.source, "a X b\n");
13633            d.undo();
13634            assert_eq!(d.source, "a word b\n");
13635            assert_eq!(d.selection(), Some((2, 6)), "the selection comes back too");
13636        }
13637    }
13638
13639    #[test]
13640    fn redo_restores_the_caret_the_edit_left_behind() {
13641        for view in [View::Source, View::Wysiwyg] {
13642            let mut d = doc_in(view, "redo_caret", "hello world\n");
13643            d.caret = 11;
13644            d.edit(0, 5, "goodbye");
13645            assert_eq!(d.caret, 7, "the edit left the caret after its new text");
13646            d.undo();
13647            d.redo();
13648            assert_eq!(d.source, "goodbye world\n");
13649            assert_eq!(d.caret, 7, "redo puts it back where the edit had it");
13650        }
13651    }
13652
13653    #[test]
13654    fn undoing_a_typed_run_restores_the_caret_from_before_the_whole_run() {
13655        for view in [View::Source, View::Wysiwyg] {
13656            let mut d = doc_in(view, "run_caret", "hi\n");
13657            d.caret = 2;
13658            d.insert("a");
13659            d.insert("b");
13660            d.insert("c");
13661            assert_eq!(d.source, "hiabc\n");
13662            d.undo();
13663            assert_eq!(d.source, "hi\n");
13664            assert_eq!(d.caret, 2, "before the run, not before its last keystroke");
13665            d.redo();
13666            assert_eq!(d.caret, 5, "and redo restores the end of the whole run");
13667        }
13668    }
13669
13670    #[test]
13671    fn undo_restores_the_caret_across_a_format_toggle() {
13672        // A toggle reaches twig without going through `splice`, so it has to
13673        // record its own step — miss it and every stack depth below it is off by
13674        // one, and undo starts handing back another edit's caret.
13675        for view in [View::Source, View::Wysiwyg] {
13676            let mut d = doc_in(view, "fmt_caret", "a word b\n");
13677            d.caret = 8;
13678            d.anchor = Some(2);
13679            d.caret = 6;
13680            d.toggle(InlineKind::Strong);
13681            assert_eq!(d.source, "a **word** b\n");
13682            d.undo();
13683            assert_eq!(d.source, "a word b\n");
13684            assert_eq!(
13685                d.selection(),
13686                Some((2, 6)),
13687                "the toggled selection comes back"
13688            );
13689        }
13690    }
13691
13692    #[test]
13693    fn an_edit_after_an_undo_truncates_the_caret_history_with_twigs() {
13694        // The drift that would never announce itself: twig drops its redo stack
13695        // on any fresh edit, so a leaf redo entry that outlives it would restore
13696        // a caret from the timeline that edit abandoned.
13697        for view in [View::Source, View::Wysiwyg] {
13698            let mut d = doc_in(view, "redo_trunc", "hello world\n");
13699            d.caret = 11;
13700            d.edit(0, 5, "goodbye"); // step A, caret 11 → 7
13701            d.undo();
13702            assert_eq!(d.caret, 11);
13703            d.caret = 0;
13704            d.insert("X"); // diverges: A's redo is gone from twig
13705            assert_eq!(d.source, "Xhello world\n");
13706
13707            d.redo();
13708            assert_eq!(d.source, "Xhello world\n", "nothing to redo onto");
13709            assert_eq!(d.status.as_deref(), Some("nothing to redo"));
13710            d.undo();
13711            assert_eq!(d.source, "hello world\n");
13712            assert_eq!(
13713                d.caret, 0,
13714                "the surviving step's caret, not the dropped one"
13715            );
13716        }
13717    }
13718
13719    #[test]
13720    fn indent_and_outdent_move_the_caret_line_with_its_text() {
13721        for view in [View::Source, View::Wysiwyg] {
13722            let g = |m, f: fn(&mut Doc)| golden_in(view, "indent_line", m, f);
13723            assert_eq!(g("he|llo\n", |d| d.indent()), "  he|llo\n");
13724            assert_eq!(g("  he|llo\n", |d| d.outdent()), "he|llo\n");
13725            // Indentation the caret is standing *in* collapses to the line start
13726            // rather than dragging the caret into the text.
13727            assert_eq!(g("| hello\n", |d| d.outdent()), "|hello\n");
13728            // A line with none to give back is left exactly as it was.
13729            assert_eq!(g("he|llo\n", |d| d.outdent()), "he|llo\n");
13730            // Less than a full level gives back what it has.
13731            assert_eq!(g(" he|llo\n", |d| d.outdent()), "he|llo\n");
13732            // A tab is one level however many spaces it isn't.
13733            assert_eq!(g("\the|llo\n", |d| d.outdent()), "he|llo\n");
13734        }
13735    }
13736
13737    #[test]
13738    fn one_indent_level_leaves_a_paragraph_a_paragraph() {
13739        // Why the level is two spaces and not the four both frontends type
13740        // today. Four is markdown's indented-code-block marker, so a Tab on a
13741        // paragraph would silently restyle it as code — a width that changes
13742        // what the document *means* isn't an indent. Pinned because the number
13743        // is the kind of thing a later list-aware pass would reach for.
13744        let mut d = doc_with("indent_kind", "hello\n");
13745        d.caret = 2;
13746        d.indent();
13747        assert_eq!(d.source, "  hello\n");
13748        assert!(
13749            d.nodes().iter().any(|n| n.kind == Kind::Para),
13750            "still prose after a Tab"
13751        );
13752        assert!(!d.nodes().iter().any(|n| n.kind == Kind::CodeBlock));
13753
13754        // The four-space level this replaces, for contrast: same text, and twig
13755        // reparses the paragraph into a code block.
13756        let mut wide = doc_with("indent_kind_4", "    hello\n");
13757        wide.build_visual(80);
13758        assert!(
13759            wide.nodes().iter().any(|n| n.kind == Kind::CodeBlock),
13760            "four spaces is a code block, not an indented paragraph"
13761        );
13762    }
13763
13764    #[test]
13765    fn indent_nests_a_list_item_under_its_parent() {
13766        // Tab indents a list item by its own marker width, landing its marker at
13767        // the parent's content column so twig reparses it as a nested list.
13768        for view in [View::Source, View::Wysiwyg] {
13769            let mut d = doc_in(view, "indent_nest", "- a\n- b\n");
13770            d.caret = 6; // on the second item
13771            d.indent();
13772            assert_eq!(d.source, "- a\n  - b\n");
13773            let lists = d
13774                .nodes()
13775                .iter()
13776                .filter(|n| n.kind == Kind::BulletList)
13777                .count();
13778            assert_eq!(lists, 2, "the indented item is a nested list");
13779        }
13780    }
13781
13782    #[test]
13783    fn indent_nests_an_ordered_item_at_its_marker_width() {
13784        // An ordered marker `1. ` is three columns wide, so a two-space step
13785        // (which nests a bullet) leaves it flat. Regression: Tab must use the
13786        // marker width, three, so the item actually nests — and the source
13787        // renumbers so the sub-list restarts at 1 and the outer list resumes.
13788        for view in [View::Source, View::Wysiwyg] {
13789            let mut d = doc_in(view, "indent_ord", "1. a\n2. b\n3. c\n");
13790            d.caret = d.source.find('b').unwrap();
13791            d.indent();
13792            assert_eq!(d.source, "1. a\n   1. b\n2. c\n");
13793            let lists = d
13794                .nodes()
13795                .iter()
13796                .filter(|n| n.kind == Kind::OrderedList)
13797                .count();
13798            assert_eq!(lists, 2, "the indented item is a nested ordered list");
13799        }
13800    }
13801
13802    #[test]
13803    fn indent_leaves_a_lists_first_item_put() {
13804        // The first item of a list has no sibling above it to nest under, so Tab
13805        // is a no-op there — the marker stays at column zero rather than being
13806        // shoved into indentation twig can't read as a sub-list.
13807        for view in [View::Source, View::Wysiwyg] {
13808            let mut d = doc_in(view, "indent_first", "- a\n- b\n");
13809            d.caret = 1; // on the FIRST item
13810            d.indent();
13811            assert_eq!(d.source, "- a\n- b\n", "the first item doesn't nest");
13812            // The sibling below still nests, proving the guard is per-item.
13813            d.caret = d.source.find('b').unwrap();
13814            d.indent();
13815            assert_eq!(d.source, "- a\n  - b\n");
13816        }
13817    }
13818
13819    #[test]
13820    fn hidden_mode_keeps_typed_markup_literal() {
13821        // The Diaryx default: typing `*hi*` gives the characters, not emphasis —
13822        // twig escapes what would open markup, so the source is `\*hi\*` and the
13823        // AST is a plain string. Formatting is the commands' job in this mode.
13824        let mut d = doc_in(View::Wysiwyg, "hidden_literal", "");
13825        d.insert("*hi*");
13826        assert_eq!(d.source, "\\*hi\\*");
13827        assert!(
13828            d.nodes()
13829                .iter()
13830                .all(|n| n.kind != Kind::Emph && n.kind != Kind::Strong)
13831        );
13832    }
13833
13834    #[test]
13835    fn hidden_mode_escapes_a_line_start_block_marker() {
13836        // A `#`/`-`/`>` at a line start would open a block, so Hidden mode keeps
13837        // it literal too — a Diaryx user's "# 1 idea" stays prose, not a heading.
13838        let mut d = doc_in(View::Wysiwyg, "hidden_block", "");
13839        d.insert("# hi");
13840        assert_eq!(d.source, "\\# hi");
13841        assert!(d.nodes().iter().all(|n| n.kind != Kind::Heading));
13842    }
13843
13844    #[test]
13845    fn authoring_modes_keep_typed_markup_live() {
13846        // Both authoring rungs of the ladder: typing `*hi*` really is emphasis
13847        // (no escape), the same as source view — escaping is `None`'s alone, and
13848        // it's the axis, not the reveal, that decides.
13849        for (view, mode) in [
13850            (View::Wysiwyg, MarkupMode::Shortcuts),
13851            (View::Wysiwyg, MarkupMode::Full),
13852            (View::Source, MarkupMode::None),
13853        ] {
13854            let mut d = doc_in(view, "live_markup", "");
13855            d.set_markup_mode(mode);
13856            d.insert("*hi*");
13857            assert_eq!(d.source, "*hi*", "{mode:?} in {view:?} types raw markup");
13858        }
13859    }
13860
13861    #[test]
13862    fn hidden_mode_overwrite_undoes_in_one_step() {
13863        // Typing over a selection escapes the replacement *and* stays a single
13864        // undo — the selection-delete and the literal insert fold together, so
13865        // one undo brings the whole selection back, like a plain overwrite.
13866        let mut d = doc_in(View::Wysiwyg, "hidden_overwrite", "a word b\n");
13867        d.anchor = Some(2);
13868        d.caret = 6; // "word"
13869        d.insert("*");
13870        assert_eq!(d.source, "a \\* b\n", "the replacement is escaped");
13871        d.undo();
13872        assert_eq!(d.source, "a word b\n");
13873        assert_eq!(d.selection(), Some((2, 6)), "one undo, selection restored");
13874    }
13875
13876    #[test]
13877    fn backspace_over_an_escaped_char_takes_the_hidden_backslash_too() {
13878        // Type `*` in Hidden mode → `\*` (drawn as one `*`); one Backspace clears
13879        // the whole visual character, never stranding the hidden `\`.
13880        let mut d = doc_in(View::Wysiwyg, "bsp_escape", "");
13881        d.insert("*");
13882        assert_eq!(d.source, "\\*");
13883        d.backspace();
13884        assert_eq!(d.source, "", "the escape backslash went with the *");
13885        // A *literal* backslash (source view, no escape) is an ordinary char.
13886        let mut s = doc_in(View::Source, "bsp_lit", "a\\b\n");
13887        s.caret = 3; // after `b`
13888        s.backspace();
13889        assert_eq!(s.source, "a\\\n", "only the b is deleted, the \\ stays");
13890    }
13891
13892    #[test]
13893    fn hidden_mode_leaves_structural_markup_alone() {
13894        // Enter continues a bullet list by writing a real `- ` marker (an
13895        // `insert_raw`, not the typing path), so Hidden mode's escaping never
13896        // touches it — the list keeps working.
13897        let mut d = doc_in(View::Wysiwyg, "hidden_struct", "- item\n");
13898        d.caret = 6;
13899        d.newline();
13900        d.insert("two");
13901        assert_eq!(d.source, "- item\n- two\n");
13902    }
13903
13904    #[test]
13905    fn markup_mode_defaults_to_none_and_round_trips() {
13906        // Diaryx's default is the clean `None` surface; a markup-fluent
13907        // frontend can climb the ladder, and the choice sticks.
13908        let mut d = doc_in(View::Wysiwyg, "markup_mode", "hi\n");
13909        assert_eq!(d.markup_mode(), MarkupMode::None, "None by default");
13910        for mode in [MarkupMode::Shortcuts, MarkupMode::Full, MarkupMode::None] {
13911            d.set_markup_mode(mode);
13912            assert_eq!(d.markup_mode(), mode);
13913        }
13914    }
13915
13916    #[test]
13917    fn full_mode_reveals_only_the_caret_line() {
13918        // The mode's whole claim: the caret's line shows its raw delimiters and
13919        // every other line stays resolved. Two paragraphs with identical markup
13920        // so the only difference between the rows is where the caret is.
13921        let mut d = doc_in(
13922            View::Wysiwyg,
13923            "reveal_caret_line",
13924            "*one* here\n\n*two* there\n",
13925        );
13926        d.set_markup_mode(MarkupMode::Full);
13927
13928        caret_at(&mut d, "one");
13929        let rows = drawn_rows(&d);
13930        assert!(
13931            rows.iter().any(|r| r == "*one* here"),
13932            "caret's line raw: {rows:?}"
13933        );
13934        assert!(
13935            rows.iter().any(|r| r == "two there"),
13936            "other line resolved: {rows:?}"
13937        );
13938
13939        // Move to the other paragraph: the reveal follows, and the line just
13940        // left goes back to being resolved.
13941        caret_at(&mut d, "two");
13942        let rows = drawn_rows(&d);
13943        assert!(
13944            rows.iter().any(|r| r == "*two* there"),
13945            "caret's line raw: {rows:?}"
13946        );
13947        assert!(
13948            rows.iter().any(|r| r == "one here"),
13949            "left line resolved: {rows:?}"
13950        );
13951    }
13952
13953    #[test]
13954    fn revealing_a_coloured_highlight_shows_the_emoji_that_spelled_it() {
13955        // The emoji is a delimiter, not content — so `MarkupMode::Full` owes it
13956        // the same treatment as an emphasis's `*`: hidden while the caret is
13957        // elsewhere, shown in full where the caret lands. That falls out of
13958        // `delims` reading the bytes between the mark's span and its content
13959        // span, which is exactly `==🔴 ` and `==`, rather than from a table
13960        // of spellings — so the no-space form `==🟢green==` reveals right too.
13961        let mut d = doc_in(
13962            View::Wysiwyg,
13963            "reveal_coloured_mark",
13964            "a ==🔴 red== one\n\nb ==plain== two\n",
13965        );
13966        d.set_markup_mode(MarkupMode::Full);
13967
13968        caret_at(&mut d, "red");
13969        let rows = drawn_rows(&d);
13970        assert!(
13971            rows.iter().any(|r| r == "a ==🔴 red== one"),
13972            "the caret's line shows the colour it was written with: {rows:?}"
13973        );
13974        assert!(
13975            rows.iter().any(|r| r == "b plain two"),
13976            "and every other line stays resolved: {rows:?}"
13977        );
13978
13979        // Away from it, the emoji goes back to being markup — the reader sees
13980        // the words and the wash.
13981        caret_at(&mut d, "two");
13982        let rows = drawn_rows(&d);
13983        assert!(
13984            rows.iter().any(|r| r == "a red one"),
13985            "resolved again: {rows:?}"
13986        );
13987    }
13988
13989    #[test]
13990    fn hidden_modes_never_reveal_wherever_the_caret_is() {
13991        // The two rungs below `Full` share a rendering: delimiters stay hidden
13992        // even under the caret. `Shortcuts` differing from `None` only in what
13993        // typing does is exactly the point of splitting the axes.
13994        for mode in [MarkupMode::None, MarkupMode::Shortcuts] {
13995            let mut d = doc_in(View::Wysiwyg, "reveal_hidden", "*one* here\n");
13996            d.set_markup_mode(mode);
13997            caret_at(&mut d, "one");
13998            let rows = drawn_rows(&d);
13999            assert!(
14000                rows.iter().any(|r| r == "one here"),
14001                "{mode:?} hides: {rows:?}"
14002            );
14003            assert!(
14004                !rows.iter().any(|r| r.contains('*')),
14005                "{mode:?} shows no `*`: {rows:?}"
14006            );
14007        }
14008    }
14009
14010    #[test]
14011    fn revealed_delimiters_are_the_authors_own_spelling() {
14012        // Delimiters are re-read from the source rather than synthesized per
14013        // kind, so a line comes back spelled the way it was written: `_em_` does
14014        // not turn into `*em*`, and a two-backtick fence keeps both backticks.
14015        let body = "_em_ and __st__ and ``lit ` tick`` and [lk](http://x) and ~~del~~\n";
14016        let mut d = doc_in(View::Wysiwyg, "reveal_spelling", body);
14017        d.set_markup_mode(MarkupMode::Full);
14018        caret_at(&mut d, "em");
14019        let rows = drawn_rows(&d);
14020        assert!(
14021            rows.iter().any(|r| r == body.trim_end()),
14022            "the revealed line is its own source: {rows:?}"
14023        );
14024    }
14025
14026    #[test]
14027    fn revealed_heading_shows_its_hashes() {
14028        // The `# ` marker is a block-level prefix, not an inline delimiter, so
14029        // it takes its own path — but it reveals on the same rule.
14030        let mut d = doc_in(View::Wysiwyg, "reveal_heading", "# Title\n\nbody\n");
14031        d.set_markup_mode(MarkupMode::Full);
14032
14033        caret_at(&mut d, "Title");
14034        assert!(
14035            drawn_rows(&d).iter().any(|r| r == "# Title"),
14036            "{:?}",
14037            drawn_rows(&d)
14038        );
14039
14040        caret_at(&mut d, "body");
14041        let rows = drawn_rows(&d);
14042        assert!(
14043            rows.iter().any(|r| r == "Title"),
14044            "hashes hidden again: {rows:?}"
14045        );
14046    }
14047
14048    #[test]
14049    fn revealed_delimiters_are_caret_stops() {
14050        // A delimiter that is drawn but can't be reached is worse than one
14051        // that's hidden: the mode exists so the markup can be *edited*. Every
14052        // revealed byte must be somewhere the caret can stand.
14053        let mut d = doc_in(View::Wysiwyg, "reveal_stops", "*em* x\n");
14054        d.set_markup_mode(MarkupMode::Full);
14055        caret_at(&mut d, "em");
14056        let opener = d.source.find('*').unwrap();
14057        assert!(d.vmap.is_stop(opener), "the opening `*` is a caret stop");
14058        assert!(
14059            d.vmap.is_stop(opener + 3),
14060            "the closing `*` is a caret stop"
14061        );
14062    }
14063
14064    #[test]
14065    fn setext_heading_reveals_nothing_across_its_newline() {
14066        // A setext heading's underline is on another line, so it is not the
14067        // caret line's to reveal — and emitting it would inject a `\n` glyph
14068        // that splits the row where the author wrote no break.
14069        let mut d = doc_in(View::Wysiwyg, "reveal_setext", "Title\n=====\n\nbody\n");
14070        d.set_markup_mode(MarkupMode::Full);
14071        caret_at(&mut d, "Title");
14072        let rows = drawn_rows(&d);
14073        assert!(
14074            rows.iter().any(|r| r == "Title"),
14075            "title renders alone: {rows:?}"
14076        );
14077        assert!(
14078            !rows.iter().any(|r| r.contains('=')),
14079            "no underline leaks in: {rows:?}"
14080        );
14081    }
14082
14083    #[test]
14084    fn markup_mode_axes_split_the_ladder() {
14085        // The two behaviours the ladder spells: `Shortcuts` is the middle rung
14086        // that authors markup but still hides it, and it's the only rung where
14087        // the two axes disagree.
14088        assert!(!MarkupMode::None.authors());
14089        assert!(!MarkupMode::None.reveals_caret_line());
14090        assert!(MarkupMode::Shortcuts.authors());
14091        assert!(!MarkupMode::Shortcuts.reveals_caret_line());
14092        assert!(MarkupMode::Full.authors());
14093        assert!(MarkupMode::Full.reveals_caret_line());
14094    }
14095
14096    #[test]
14097    fn indenting_an_empty_dash_item_under_text_dodges_the_setext_collapse() {
14098        // Tabbing an empty `- ` under a text line would spell `- hello\n  - `,
14099        // which twig (correctly, per CommonMark — pandoc agrees) reparses as a
14100        // setext H2. leaf swaps the dash for a `*` so the item stays an empty
14101        // nested bullet and `hello` stays prose: the file round-trips instead of
14102        // hiding a heading the user never asked for.
14103        for view in [View::Source, View::Wysiwyg] {
14104            let mut d = doc_in(view, "setext_guard", "- hello\n- \n");
14105            d.caret = d.source.find("- \n").unwrap() + 2; // after the empty marker
14106            d.indent();
14107            assert_eq!(d.source, "- hello\n  * \n");
14108            assert!(
14109                d.nodes().iter().all(|n| n.kind != Kind::Heading),
14110                "no heading"
14111            );
14112            // And it's genuinely a nested list, not a flat one.
14113            assert_eq!(
14114                d.nodes()
14115                    .iter()
14116                    .filter(|n| n.kind == Kind::BulletList)
14117                    .count(),
14118                2
14119            );
14120        }
14121    }
14122
14123    #[test]
14124    fn indenting_a_dash_item_with_content_keeps_its_dash() {
14125        // With content, `- x` can't be a setext underline, so there's nothing to
14126        // dodge: the marker stays a dash and nests as an ordinary sub-bullet.
14127        let mut d = doc_in(View::Wysiwyg, "setext_ok", "- hello\n- x\n");
14128        d.caret = d.source.find('x').unwrap();
14129        d.indent();
14130        assert_eq!(d.source, "- hello\n  - x\n");
14131    }
14132
14133    #[test]
14134    fn the_setext_swap_undoes_as_one_step_with_the_indent() {
14135        // The dash→`*` repair coalesces into the Tab, so a single undo restores
14136        // the whole pre-Tab state rather than stranding a half-collapsed doc.
14137        let mut d = doc_in(View::Wysiwyg, "setext_undo", "- hello\n- \n");
14138        d.caret = d.source.find("- \n").unwrap() + 2;
14139        d.indent();
14140        assert_eq!(d.source, "- hello\n  * \n");
14141        d.undo();
14142        assert_eq!(d.source, "- hello\n- \n", "one undo, not two");
14143    }
14144
14145    #[test]
14146    fn indent_leaves_a_nested_lists_first_item_put_too() {
14147        // The guard is about siblings, not depth: the first item of an *inner*
14148        // list (already nested under `a`) still has nothing before it at its own
14149        // level, so Tab can't take it deeper.
14150        let mut d = doc_in(View::Wysiwyg, "indent_first_nested", "- a\n  - b\n  - c\n");
14151        d.caret = d.source.find('b').unwrap();
14152        d.indent();
14153        assert_eq!(d.source, "- a\n  - b\n  - c\n", "inner first item holds");
14154        // But `c` (a sibling of `b`) nests under `b`.
14155        d.caret = d.source.find('c').unwrap();
14156        d.indent();
14157        assert_eq!(d.source, "- a\n  - b\n    - c\n");
14158    }
14159
14160    #[test]
14161    fn backspace_at_a_nested_item_start_outdents_it() {
14162        // Backspace with the caret right after a nested item's marker gives back
14163        // one level of nesting, the mirror of Tab — and renumbers the flattened
14164        // ordered list back to a clean run.
14165        let mut d = doc_in(View::Wysiwyg, "bsp_outdent", "1. a\n   1. b\n2. c\n");
14166        d.caret = d.source.find('b').unwrap(); // start of the nested item's content
14167        d.backspace();
14168        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
14169    }
14170
14171    #[test]
14172    fn backspace_at_a_top_level_item_start_strips_the_marker() {
14173        // At the outermost level there's no nesting left to give back, so the same
14174        // keystroke drops the bullet and leaves a plain paragraph.
14175        let mut d = doc_in(View::Wysiwyg, "bsp_strip", "- a\n- b\n");
14176        d.caret = d.source.find('b').unwrap(); // right after `- `
14177        d.backspace();
14178        assert_eq!(d.source, "- a\nb\n", "the marker is gone, the text stays");
14179    }
14180
14181    #[test]
14182    fn backspace_mid_item_still_deletes_a_character() {
14183        // The list behaviour is armed only at the item's content start; anywhere
14184        // else Backspace is the ordinary character delete.
14185        let mut d = doc_in(View::Wysiwyg, "bsp_mid", "- ab\n");
14186        d.caret = d.source.find('b').unwrap(); // between `a` and `b`
14187        d.backspace();
14188        assert_eq!(d.source, "- b\n");
14189    }
14190
14191    #[test]
14192    fn backspace_at_a_heading_start_strips_the_marker() {
14193        // The `# ` is markup the rich view hides, so Backspace over it takes the
14194        // whole marker and leaves a paragraph. Deleting a byte of it instead left
14195        // `#Title` — no longer a heading, with the hash now literal text the user
14196        // never typed and has to delete again.
14197        let mut d = doc_in(View::Wysiwyg, "bsp_head", "## Title\n");
14198        d.caret = d.source.find('T').unwrap(); // right after `## `
14199        d.backspace();
14200        assert_eq!(d.source, "Title\n");
14201        assert_eq!(
14202            d.caret, 0,
14203            "the caret stays with the text it was in front of"
14204        );
14205    }
14206
14207    #[test]
14208    fn backspace_at_a_heading_start_keeps_the_block_around_it() {
14209        // Only the heading's own marker goes — the quote (or list) it sits in is
14210        // untouched, exactly as un-heading it should be.
14211        let mut d = doc_in(View::Wysiwyg, "bsp_head_quote", "> # Title\n");
14212        d.caret = d.source.find('T').unwrap();
14213        d.backspace();
14214        assert_eq!(d.source, "> Title\n");
14215    }
14216
14217    #[test]
14218    fn backspace_at_a_heading_start_takes_its_closing_sequence_too() {
14219        // `# Title #`'s trailing hashes are hidden at the other end; leaving them
14220        // behind would surface the same stray hash the marker delete just avoided.
14221        let mut d = doc_in(View::Wysiwyg, "bsp_head_closed", "# Title #\n");
14222        d.caret = d.source.find('T').unwrap();
14223        d.backspace();
14224        assert_eq!(d.source, "Title\n");
14225        // And it's one edit: a single undo puts the whole heading back.
14226        d.undo();
14227        assert_eq!(d.source, "# Title #\n");
14228    }
14229
14230    #[test]
14231    fn backspace_mid_heading_still_deletes_a_character() {
14232        // The heading behaviour is armed only at the content's start; anywhere
14233        // else Backspace is the ordinary character delete.
14234        let mut d = doc_in(View::Wysiwyg, "bsp_head_mid", "# ab\n");
14235        d.caret = d.source.find('b').unwrap();
14236        d.backspace();
14237        assert_eq!(d.source, "# b\n");
14238    }
14239
14240    #[test]
14241    fn source_view_backspace_still_edits_the_heading_marker_literally() {
14242        // In source view the `# ` is text on the screen the user is deleting a
14243        // byte of, so it keeps its literal meaning — the same split the list
14244        // ladder and Enter draw between the two views.
14245        let mut d = doc_with("bsp_head_src", "# Title\n");
14246        d.caret = d.source.find('T').unwrap();
14247        d.backspace();
14248        assert_eq!(d.source, "#Title\n");
14249    }
14250
14251    #[test]
14252    fn outdent_unnests_an_ordered_item_in_one_press() {
14253        // Shift+Tab gives back exactly the marker width the indent added, so a
14254        // nested ordered item unnests in a single press, and the flattened list
14255        // renumbers back to a clean 1, 2, 3.
14256        let mut d = doc_with("outdent_ord", "1. a\n   2. b\n3. c\n");
14257        d.caret = d.source.find('b').unwrap();
14258        d.outdent();
14259        assert_eq!(d.source, "1. a\n2. b\n3. c\n");
14260        let lists = d
14261            .nodes()
14262            .iter()
14263            .filter(|n| n.kind == Kind::OrderedList)
14264            .count();
14265        assert_eq!(lists, 1, "back to one flat list");
14266    }
14267
14268    #[test]
14269    fn table_insert_row_adds_a_row_below_the_caret() {
14270        let mut d = doc_with("tbl_ins_row", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14271        d.caret = d.source.find('1').unwrap(); // in the body row
14272        d.table_insert_row(true);
14273        assert_eq!(d.source, "| a | b |\n| --- | --- |\n| 1 | 2 |\n|  |  |\n");
14274    }
14275
14276    #[test]
14277    fn table_insert_and_delete_column_at_the_caret() {
14278        let mut d = doc_with("tbl_col", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14279        d.caret = d.source.find('a').unwrap(); // column 0
14280        d.table_insert_column(true); // add a column to the right of `a`
14281        assert_eq!(
14282            d.source,
14283            "| a |  | b |\n| --- | --- | --- |\n| 1 |  | 2 |\n"
14284        );
14285        d.caret = d.source.find('b').unwrap(); // now the third column
14286        d.table_delete_column();
14287        assert_eq!(d.source, "| a |  |\n| --- | --- |\n| 1 |  |\n");
14288    }
14289
14290    // ── ragged formats ───────────────────────────────────────────────────────
14291    // No format spells every gesture. HTML writes the inline marks as a tag pair
14292    // and no heading, list, quote or link; Markdown spells five of the eight
14293    // marks — the highlight only because leaf parses with `highlight`, which is
14294    // why the question is asked with the extensions; djot spells all eight and
14295    // no in-cell break. leaf asks twig per
14296    // gesture (`Doc::supports`) and refuses at the door, rather than letting each
14297    // op discover the fact on its own — one of them didn't.
14298
14299    /// An HTML document in the rich view, ready for a gesture.
14300    fn html_doc(body: &str) -> Doc {
14301        let mut d = Doc::from_source(body.to_string(), Format::Html).unwrap();
14302        d.view = View::Wysiwyg;
14303        d.build_visual(80);
14304        d
14305    }
14306
14307    #[test]
14308    fn a_table_gesture_leaves_an_html_table_alone() {
14309        // The regression this guard exists for. twig's table editor consults no
14310        // `Syntax` table — it spells a grid, not a delimiter — so it rebuilt an
14311        // HTML `<table>` as a *pipe table* and reported success: the whole
14312        // element replaced by `| a | b |`, silently, on one press of a toolbar
14313        // button. Every grid op went the same way.
14314        let src = "<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>\n";
14315        // A table of named operations, which is what it looks like.
14316        #[allow(clippy::type_complexity)]
14317        let ops: [(&str, &dyn Fn(&mut Doc)); 7] = [
14318            ("insert row", &|d: &mut Doc| d.table_insert_row(true)),
14319            ("delete row", &|d: &mut Doc| d.table_delete_row()),
14320            ("insert column", &|d: &mut Doc| d.table_insert_column(true)),
14321            ("delete column", &|d: &mut Doc| d.table_delete_column()),
14322            ("align", &|d: &mut Doc| {
14323                d.table_set_alignment(Alignment::Right)
14324            }),
14325            ("move row", &|d: &mut Doc| d.table_move_row(true)),
14326            ("move column", &|d: &mut Doc| d.table_move_column(true)),
14327        ];
14328        for (name, op) in ops {
14329            let mut d = html_doc(src);
14330            d.caret = d.source.find('a').unwrap();
14331            assert!(d.caret_in_table(), "{name}: the caret really is in a table");
14332            op(&mut d);
14333            assert_eq!(d.source, src, "{name} rewrote an HTML table");
14334            assert!(
14335                !d.dirty,
14336                "{name} marked the document dirty without editing it"
14337            );
14338            assert!(d.status.is_some(), "{name} refused without saying why");
14339        }
14340    }
14341
14342    #[test]
14343    fn the_block_gestures_html_cannot_spell_are_refused_with_a_reason() {
14344        // A task box is a form control in HTML and a footnote has no native
14345        // spelling at all — the two gestures twig 3.5 still spells nothing
14346        // for, now that a quote, a list, a link and an image print through
14347        // its renderer (see the test below).
14348        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
14349        // A table of named operations, which is what it looks like.
14350        #[allow(clippy::type_complexity)]
14351        let ops: [(&str, &dyn Fn(&mut Doc)); 3] = [
14352            ("task item", &|d: &mut Doc| d.toggle_task_item()),
14353            ("task tick", &|d: &mut Doc| d.toggle_task_checked()),
14354            ("footnote", &|d: &mut Doc| d.insert_footnote()),
14355        ];
14356        for (name, op) in ops {
14357            let mut d = html_doc(src);
14358            let at = d.source.find("Hello").unwrap();
14359            d.caret = at;
14360            d.anchor = Some(at + 5); // a selection, for the ops that want one
14361            op(&mut d);
14362            assert_eq!(d.source, src, "{name} edited an HTML document");
14363            assert!(
14364                !d.dirty,
14365                "{name} marked the document dirty without editing it"
14366            );
14367            let status = d.status.as_deref().unwrap_or("");
14368            assert!(
14369                status.contains("html"),
14370                "{name}: the refusal should name the format, got {status:?}"
14371            );
14372        }
14373    }
14374
14375    #[test]
14376    fn html_spells_a_quote_a_list_a_link_and_an_image_through_the_renderer() {
14377        // twig 3.5: where HTML has no marker alphabet it prints the fresh
14378        // node — a `<blockquote>` around the paragraph, a `<ul>`/`<ol>` with
14379        // the paragraph as its item, an `<a>` or `<img>` over the selection.
14380        // Until then every one of these was a refusal; now each is a real
14381        // edit, which is what the toolbar's capability flags say too.
14382        let src = "<h1>Title</h1>\n<p>Hello world</p>\n<ul><li>one</li></ul>\n";
14383        #[allow(clippy::type_complexity)]
14384        let ops: [(&str, &dyn Fn(&mut Doc), &str); 5] = [
14385            (
14386                "quote",
14387                &|d: &mut Doc| d.toggle_blockquote(),
14388                "<blockquote>",
14389            ),
14390            ("list", &|d: &mut Doc| d.toggle_list(false), "<ul>\n<li>"),
14391            (
14392                "ordered list",
14393                &|d: &mut Doc| d.toggle_list(true),
14394                "<ol>\n<li>",
14395            ),
14396            (
14397                "link",
14398                &|d: &mut Doc| d.insert_link("https://example.dev"),
14399                "<a href=\"https://example.dev\">Hello</a>",
14400            ),
14401            (
14402                "image",
14403                &|d: &mut Doc| d.insert_image("pic.png", "alt"),
14404                "<img alt=\"Hello\" src=\"pic.png\">",
14405            ),
14406        ];
14407        for (name, op, expect) in ops {
14408            let mut d = html_doc(src);
14409            let at = d.source.find("Hello").unwrap();
14410            d.caret = at;
14411            d.anchor = Some(at + 5);
14412            op(&mut d);
14413            assert!(d.source.contains(expect), "{name}: got {:?}", d.source);
14414            assert!(d.dirty, "{name}: a real edit");
14415            assert_eq!(
14416                d.status, None,
14417                "{name}: a supported gesture reports nothing"
14418            );
14419        }
14420    }
14421
14422    #[test]
14423    fn html_spells_a_heading_as_its_tag_pair() {
14424        // twig 3.4 rebuilds a heading or paragraph as its tag pair, attributes
14425        // along — the one block gesture whose HTML shape it can write. So ⌘2
14426        // in an HTML document is a real edit, and ⌘0 takes it back.
14427        let src = "<h1>Title</h1>\n<p>Hello world</p>\n";
14428        let mut d = html_doc(src);
14429        d.caret = d.source.find("Hello").unwrap();
14430        d.toggle_heading(2);
14431        assert_eq!(d.source, "<h1>Title</h1>\n<h2>Hello world</h2>\n");
14432        assert!(d.dirty);
14433        assert_eq!(d.status, None, "a supported gesture reports nothing");
14434        d.toggle_heading(2);
14435        assert_eq!(d.source, src, "the same level again is back to a paragraph");
14436    }
14437
14438    #[test]
14439    fn html_spells_the_inline_marks_and_the_rule() {
14440        // The other half, and why one per-document flag stopped being enough:
14441        // ⌘B in an HTML document writes `<strong>` — the tag the serializer
14442        // already emits and the parser reads straight back as the same mark —
14443        // and the rule button writes an `<hr>`. Refusing these on the old
14444        // "HTML is parse-only" reading would now be leaf's own limitation.
14445        let mut d = html_doc("<p>Hello world</p>\n");
14446        let at = d.source.find("world").unwrap();
14447        d.caret = at;
14448        d.anchor = Some(at + 5);
14449        d.toggle(InlineKind::Strong);
14450        assert_eq!(d.source, "<p>Hello <strong>world</strong></p>\n");
14451        assert!(d.dirty);
14452        assert_eq!(d.status, None, "a supported gesture reports nothing");
14453
14454        // And off again — the toggle reverses, which is the property that makes
14455        // authoring in HTML worth offering rather than a one-way trip.
14456        d.toggle(InlineKind::Strong);
14457        assert_eq!(d.source, "<p>Hello world</p>\n");
14458
14459        let mut d = html_doc("<p>Hello world</p>\n");
14460        d.caret = d.source.find("world").unwrap();
14461        d.insert_thematic_break();
14462        assert!(d.source.contains("<hr>"), "got {:?}", d.source);
14463    }
14464
14465    #[test]
14466    fn a_mark_the_format_cannot_spell_arms_nothing() {
14467        // `toggle` with a collapsed caret doesn't reach twig at all — it arms a
14468        // sticky mark for the next text typed. Guarding only the twig call
14469        // leaves that path live, promising a mark the gesture will not write and
14470        // then swallowing the error inside `insert`.
14471        //
14472        // Markdown carries this, on the superscript now rather than on the
14473        // highlight: `^x^` is text there in any configuration, whereas twig
14474        // 3.3.1 authors `==x==` for an editor holding the `highlight` extension,
14475        // which every leaf document does.
14476        let mut d = doc_with("mark", "Hello world\n");
14477        d.view = View::Wysiwyg;
14478        d.build_visual(80);
14479        d.caret = d.source.find("world").unwrap();
14480        d.toggle(InlineKind::Superscript);
14481        assert!(d.pending_marks.is_empty(), "no mark should be armed");
14482        assert!(d.status.as_deref().unwrap_or("").contains("markdown"));
14483        d.insert("X");
14484        assert_eq!(d.source, "Hello Xworld\n");
14485    }
14486
14487    #[test]
14488    fn markdown_authors_a_highlight_and_a_strikethrough() {
14489        // twig 3.3.1: the two marks Markdown reads and, until it, refused to
14490        // write. `==x==` is authorable because leaf's own `parse_extensions`
14491        // turns `highlight` on — twig will only mint bytes this editor's reparse
14492        // reads back — and `~~x~~` because GFM strikethrough is parsed by
14493        // default, so the refusal there was never right for any leaf document.
14494        for (kind, marked) in [
14495            (InlineKind::Mark, "a ==word== b\n"),
14496            (InlineKind::Delete, "a ~~word~~ b\n"),
14497        ] {
14498            let mut d = doc_with("author_mark", "a word b\n");
14499            d.anchor = Some(2);
14500            d.caret = 6;
14501            d.toggle(kind);
14502            assert_eq!(d.source, marked, "{kind:?}");
14503            assert_eq!(d.status, None, "{kind:?}: a supported gesture is silent");
14504            assert!(d.dirty, "{kind:?}");
14505            // The region stays selected, so the second press reverses it — the
14506            // property that separates authoring from a one-way trip.
14507            d.toggle(kind);
14508            assert_eq!(d.source, "a word b\n", "{kind:?}");
14509        }
14510    }
14511
14512    #[test]
14513    fn an_authored_highlight_reads_back_as_a_mark() {
14514        // The round trip the extension gate exists to protect: what the toggle
14515        // writes, the reparse must read back as a `mark` rather than as two
14516        // literal `=` pairs. A `Role::Mark` glyph is that answer, taken from the
14517        // rebuilt map rather than from the source text.
14518        let mut d = doc_with("mark_roundtrip", "a word b\n");
14519        d.view = View::Wysiwyg;
14520        d.build_visual(80);
14521        d.anchor = Some(2);
14522        d.caret = 6;
14523        d.toggle(InlineKind::Mark);
14524        assert_eq!(d.source, "a ==word== b\n");
14525        d.build_visual(80);
14526        let w = d
14527            .vmap
14528            .rows
14529            .iter()
14530            .flat_map(|r| r.glyphs.iter())
14531            .find(|g| g.ch == 'w')
14532            .expect("the highlighted word");
14533        assert_eq!(w.style.role, crate::Role::Mark(None));
14534    }
14535
14536    #[test]
14537    fn a_highlight_takes_a_colour_changes_it_and_gives_it_back() {
14538        // The three states of one gesture, in the order a palette is pressed:
14539        // an uncoloured highlight takes the prefix, a coloured one has it
14540        // replaced, and `None` takes it away with the space that was part of the
14541        // spelling.
14542        let mut d = doc_with("mark_colour", "a ==word== b\n");
14543        d.caret = d.source.find("word").unwrap();
14544        d.set_mark_color(Some(MarkColor::Red));
14545        assert_eq!(d.source, "a ==🔴 word== b\n");
14546        assert_eq!(d.status, None);
14547        assert!(d.dirty);
14548
14549        d.set_mark_color(Some(MarkColor::Blue));
14550        assert_eq!(d.source, "a ==🔵 word== b\n");
14551
14552        d.set_mark_color(None);
14553        assert_eq!(d.source, "a ==word== b\n");
14554    }
14555
14556    #[test]
14557    fn the_caret_keeps_its_place_in_the_text_across_a_colour() {
14558        // The prefix is written *before* the word, so an offset in the word has
14559        // to ride its width — a caret that stayed put would be a caret that
14560        // walked backwards through the text it was standing in.
14561        let mut d = doc_with("mark_colour_caret", "a ==word== b\n");
14562        let word = d.source.find("word").unwrap();
14563        d.caret = word + 2; // between `wo` and `rd`
14564        d.set_mark_color(Some(MarkColor::Red));
14565        assert_eq!(&d.source[d.caret..d.caret + 2], "rd", "still before `rd`");
14566
14567        // And back the other way when the prefix goes.
14568        d.set_mark_color(None);
14569        assert_eq!(&d.source[d.caret..d.caret + 2], "rd");
14570    }
14571
14572    #[test]
14573    fn the_colour_at_the_caret_is_what_the_palette_lights() {
14574        let mut d = doc_with("mark_colour_read", "a ==🔴 red== and ==plain== b\n");
14575        d.caret = d.source.find("red").unwrap();
14576        assert!(d.caret_in_mark());
14577        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Red));
14578
14579        d.caret = d.source.find("plain").unwrap();
14580        assert!(d.caret_in_mark(), "a highlight with no colour is still one");
14581        assert_eq!(d.mark_color_at_caret(), None);
14582
14583        d.caret = d.source.find(" and ").unwrap() + 2;
14584        assert!(!d.caret_in_mark());
14585        assert_eq!(d.mark_color_at_caret(), None);
14586    }
14587
14588    #[test]
14589    fn a_colour_without_a_highlight_says_so_and_writes_nothing() {
14590        // The gesture colours a highlight that exists; it does not make one.
14591        // Two presses is the price of a coloured highlight from bare text, and
14592        // the reason is undo — one press that spliced twice would take two
14593        // presses to take back.
14594        let mut d = doc_with("mark_colour_none", "a word b\n");
14595        d.caret = d.source.find("word").unwrap();
14596        d.set_mark_color(Some(MarkColor::Red));
14597        assert_eq!(d.source, "a word b\n");
14598        assert!(d.status.is_some(), "it should say why");
14599        assert!(!d.dirty);
14600
14601        // Clearing where there is nothing to clear is the same refusal, not a
14602        // quiet success — the caret is in no highlight either way.
14603        d.status = None;
14604        d.set_mark_color(None);
14605        assert_eq!(d.source, "a word b\n");
14606        assert!(d.status.is_some());
14607    }
14608
14609    #[test]
14610    fn clearing_an_uncoloured_highlight_is_a_quiet_no_op() {
14611        // twig answers this one *successfully* with a `Change` describing some
14612        // earlier edit, so a caller that trusted the change would jump the caret
14613        // to wherever that was. Core answers it before asking.
14614        let mut d = doc_with("mark_colour_noop", "a ==word== b\n");
14615        d.toggle(InlineKind::Strong); // an earlier edit for a stale change to name
14616        d.caret = d.source.find("word").unwrap();
14617        let (source, caret) = (d.source.clone(), d.caret);
14618        d.set_mark_color(None);
14619        assert_eq!(d.source, source);
14620        assert_eq!(
14621            d.caret, caret,
14622            "the caret must not ride a change that isn't one"
14623        );
14624        assert_eq!(d.status, None, "and it is not an error either");
14625    }
14626
14627    #[test]
14628    fn djot_spells_the_highlight_and_not_its_colour() {
14629        // The reason the palette is its own capability rather than the Highlight
14630        // button's: `{=word=}` is a highlight djot writes happily, and there is
14631        // no djot spelling for a colour on it.
14632        assert!(Capabilities::of(Format::Djot).mark);
14633        assert!(!Capabilities::of(Format::Djot).mark_color);
14634        assert!(Capabilities::of(Format::Markdown).mark_color);
14635
14636        let mut d = Doc::from_source("a {=word=} b\n".into(), Format::Djot).unwrap();
14637        d.caret = d.source.find("word").unwrap();
14638        assert!(
14639            d.caret_in_mark(),
14640            "the caret is in a highlight all the same"
14641        );
14642        d.set_mark_color(Some(MarkColor::Red));
14643        assert_eq!(d.source, "a {=word=} b\n");
14644        assert!(
14645            d.status.as_deref().unwrap_or("").contains("djot"),
14646            "and the refusal names the document's format: {:?}",
14647            d.status
14648        );
14649    }
14650
14651    #[test]
14652    fn a_coloured_highlight_is_one_undo_step_and_reads_back_as_its_colour() {
14653        // The round trip that matters for a palette: the bytes twig writes are
14654        // bytes its own reparse reads back as a colour, so the swatch that was
14655        // pressed is the swatch that lights afterwards.
14656        let mut d = doc_with("mark_colour_undo", "a word b\n");
14657        d.anchor = Some(2);
14658        d.caret = 6;
14659        d.toggle(InlineKind::Mark);
14660        d.caret = d.source.find("word").unwrap();
14661        d.set_mark_color(Some(MarkColor::Green));
14662        assert_eq!(d.source, "a ==🟢 word== b\n");
14663        assert_eq!(d.mark_color_at_caret(), Some(MarkColor::Green));
14664
14665        // One splice, one step: the colour comes off and the highlight stays.
14666        d.undo();
14667        assert_eq!(d.source, "a ==word== b\n");
14668        d.undo();
14669        assert_eq!(d.source, "a word b\n");
14670    }
14671
14672    #[test]
14673    fn every_colour_leaf_names_is_one_twig_writes() {
14674        // The two enums are one vocabulary, and this is what says so: each of
14675        // leaf's colours writes an emoji twig's reparse reads back as *that*
14676        // colour, so `twig_mark_color`'s table cannot quietly pair red with
14677        // orange.
14678        for color in MarkColor::ALL {
14679            let mut d = doc_with("mark_colour_all", "a ==word== b\n");
14680            d.caret = d.source.find("word").unwrap();
14681            d.set_mark_color(Some(color));
14682            assert_eq!(d.status, None, "{color:?}");
14683            assert_eq!(d.mark_color_at_caret(), Some(color), "{color:?}");
14684        }
14685    }
14686
14687    #[test]
14688    fn a_fresh_highlight_takes_a_colour_without_moving_the_caret_first() {
14689        // The two presses a coloured highlight is made of, in the state the
14690        // first one leaves: `toggle` selects the whole `==word==` and puts the
14691        // caret one past the closing `==`, which is *not* in the mark. Asking at
14692        // the caret alone would refuse to colour the highlight just written —
14693        // the selection's start is what answers.
14694        let mut d = doc_with("mark_colour_fresh", "a word b\n");
14695        d.anchor = Some(2);
14696        d.caret = 6;
14697        d.toggle(InlineKind::Mark);
14698        assert_eq!(d.source, "a ==word== b\n");
14699        assert_eq!(d.caret, 10, "the caret twig leaves, past the closing `==`");
14700
14701        assert!(d.caret_in_mark(), "the selected highlight is the one meant");
14702        d.set_mark_color(Some(MarkColor::Yellow));
14703        assert_eq!(d.source, "a ==🟡 word== b\n");
14704        assert_eq!(d.status, None);
14705    }
14706
14707    #[test]
14708    fn one_press_highlights_a_selection_and_colours_it() {
14709        // What a toolbar swatch means over a plain selection, and the undo it
14710        // has to have: one press, one step. Two steps would leave an uncoloured
14711        // highlight behind on the way back, which is a state the author never
14712        // asked for and never saw.
14713        let mut d = doc_with("highlight_one", "a word b\n");
14714        d.anchor = Some(2);
14715        d.caret = 6;
14716        d.highlight(Some(MarkColor::Purple));
14717        assert_eq!(d.source, "a ==\u{1F7E3} word== b\n");
14718        assert_eq!(d.status, None);
14719
14720        d.undo();
14721        assert_eq!(d.source, "a word b\n", "one press, one undo");
14722    }
14723
14724    #[test]
14725    fn one_press_on_an_existing_highlight_only_recolours_it() {
14726        // The other half: inside a highlight there is nothing to make, so the
14727        // compound is the plain gesture and the text is untouched.
14728        let mut d = doc_with("highlight_recolour", "a ==\u{1F534} word== b\n");
14729        d.caret = d.source.find("word").unwrap();
14730        d.highlight(Some(MarkColor::Blue));
14731        assert_eq!(d.source, "a ==\u{1F535} word== b\n");
14732        d.undo();
14733        assert_eq!(d.source, "a ==\u{1F534} word== b\n", "the highlight stays");
14734    }
14735
14736    #[test]
14737    fn one_press_with_no_colour_over_a_selection_just_highlights_it() {
14738        // `None` means "no colour", and over bare text that is the Highlight
14739        // button's own job. The fold must not happen here — there is no second
14740        // splice, and folding would take the *previous* edit into this one.
14741        let mut d = doc_with("highlight_none", "a word b and more\n");
14742        d.caret = d.source.find("more").unwrap() + 4; // after "more"
14743        d.insert("!"); // an earlier edit for a wrong fold to swallow
14744        d.anchor = Some(2);
14745        d.caret = 6;
14746        d.highlight(None);
14747        assert_eq!(d.source, "a ==word== b and more!\n");
14748
14749        d.undo();
14750        assert_eq!(
14751            d.source, "a word b and more!\n",
14752            "only the highlight came off"
14753        );
14754        d.undo();
14755        assert_eq!(
14756            d.source, "a word b and more\n",
14757            "and the edit before it survived"
14758        );
14759    }
14760
14761    #[test]
14762    fn one_press_at_a_bare_caret_in_no_highlight_writes_nothing() {
14763        // `toggle` at a collapsed caret arms a mark for text not yet typed, and
14764        // a colour cannot be armed with it — so the compound declines rather
14765        // than leaving half a promise.
14766        let mut d = doc_with("highlight_bare", "a word b\n");
14767        d.caret = 4;
14768        d.highlight(Some(MarkColor::Red));
14769        assert_eq!(d.source, "a word b\n");
14770        assert!(d.pending_marks.is_empty(), "and nothing armed");
14771        assert!(d.status.is_some());
14772    }
14773
14774    #[test]
14775    fn a_read_only_document_takes_no_colour() {
14776        let mut d = doc_with("mark_colour_ro", "a ==word== b\n");
14777        d.caret = d.source.find("word").unwrap();
14778        d.set_read_only(true);
14779        d.set_mark_color(Some(MarkColor::Red));
14780        assert_eq!(d.source, "a ==word== b\n");
14781    }
14782
14783    #[test]
14784    fn a_sticky_highlight_wraps_the_next_typed_text_in_markdown() {
14785        // The other door into `toggle`: no selection, so nothing reaches twig
14786        // until `insert` realises the armed mark. It is armed now — the guard
14787        // above asks `Doc::supports`, which asks with the extensions — and what
14788        // it writes is the same `==…==`.
14789        let mut d = doc_with("sticky_mark", "xy\n");
14790        d.caret = 1;
14791        d.toggle(InlineKind::Mark);
14792        assert!(d.pending_marks.contains(InlineKind::Mark));
14793        d.insert("Z");
14794        assert_eq!(d.source, "x==Z==y\n");
14795    }
14796
14797    #[test]
14798    fn html_documents_still_take_typed_text() {
14799        // The guard covers *markup* gestures and must not touch plain editing:
14800        // twig's splicer is language-neutral, and typing into an HTML document
14801        // is the thing that does work today.
14802        let mut d = html_doc("<p>Hello world</p>\n");
14803        d.caret = d.source.find("world").unwrap();
14804        d.insert("big ");
14805        assert_eq!(d.source, "<p>Hello big world</p>\n");
14806        assert!(d.dirty);
14807        d.backspace();
14808        assert_eq!(d.source, "<p>Hello bigworld</p>\n");
14809        d.undo();
14810        d.undo();
14811        assert_eq!(d.source, "<p>Hello world</p>\n");
14812    }
14813
14814    #[test]
14815    fn authorable_is_the_coarse_question_and_capabilities_the_useful_one() {
14816        // `authorable` only separates "there is a door in" from "there is not",
14817        // and HTML is on the near side of that line — which is exactly why a
14818        // toolbar must not be built from it.
14819        let html = Doc::from_source("<p>x</p>\n".into(), Format::Html).unwrap();
14820        assert!(html.authorable());
14821        assert!(
14822            !Doc::from_source("<r>x</r>".into(), Format::Xml)
14823                .unwrap()
14824                .authorable()
14825        );
14826
14827        let caps = html.capabilities();
14828        assert!(caps.bold && caps.italic && caps.code && caps.mark);
14829        assert!(caps.thematic_break && caps.cell_line_break);
14830        // A heading is a tag pair twig rebuilds (3.4), and since 3.5 so are a
14831        // quote, a list, a code block's language, a link and an image — each
14832        // printed as a fresh node where HTML has no marker to rewrite. A task
14833        // box is a form control and a footnote has no spelling, so those two
14834        // are what keeps the record ragged.
14835        assert!(caps.heading && caps.blockquote && caps.bullet_list);
14836        assert!(caps.link && caps.image && caps.code_language);
14837        assert!(!caps.task && !caps.footnote);
14838        // The one flag that isn't twig's answer: an HTML `<table>` is a grid
14839        // twig's table editor would happily re-emit as `| a | b |`.
14840        assert!(!caps.table);
14841
14842        // The two lightweight formats spell everything leaf offers — and still
14843        // differ from each other, which is the other half of why one boolean
14844        // can't serve.
14845        for fmt in [Format::Markdown, Format::Djot] {
14846            let caps = Capabilities::of(fmt);
14847            assert!(
14848                caps.heading && caps.blockquote && caps.ordered_list,
14849                "{fmt:?}"
14850            );
14851            assert!(
14852                caps.task && caps.link && caps.image && caps.table,
14853                "{fmt:?}"
14854            );
14855        }
14856        // Both spell the highlight and the strikethrough: djot natively, and
14857        // Markdown because `Capabilities` asks with `parse_extensions` rather
14858        // than with twig's defaults — `==x==` is text under those, and a mark
14859        // under the `highlight` leaf always parses with.
14860        for fmt in [Format::Markdown, Format::Djot] {
14861            let caps = Capabilities::of(fmt);
14862            assert!(caps.mark && caps.strike, "{fmt:?}");
14863        }
14864        // What still separates them, now that the highlight doesn't: djot has
14865        // no in-cell break, and Markdown spells neither of the scripts.
14866        assert!(Capabilities::of(Format::Djot).superscript);
14867        assert!(!Capabilities::of(Format::Markdown).superscript);
14868        assert!(Capabilities::of(Format::Markdown).cell_line_break);
14869        assert!(!Capabilities::of(Format::Djot).cell_line_break);
14870
14871        // A parse-only format answers no to every one of them, so the coarse
14872        // predicate and the record agree there.
14873        let caps = Capabilities::of(Format::Xml);
14874        assert!(!caps.bold && !caps.heading && !caps.table && !caps.thematic_break);
14875    }
14876
14877    #[test]
14878    fn a_refused_gesture_says_so_where_twig_would_have_said_it() {
14879        // The guard exists to name the *document's* format rather than twig's
14880        // internals, so the message has to survive being one leaf writes itself.
14881        // Checked against a gesture twig also refuses, since that is the pair
14882        // most at risk of drifting apart — the task box, once the code
14883        // language stopped being one (twig 3.5).
14884        let mut d = html_doc("<p>Hello</p>\n");
14885        d.caret = d.source.find("Hello").unwrap();
14886        d.toggle_task_item();
14887        assert_eq!(d.status.as_deref(), Some("task: not supported in html"));
14888        assert!(!d.dirty);
14889    }
14890
14891    #[test]
14892    fn table_set_alignment_respells_the_delimiter() {
14893        let mut d = doc_with("tbl_align", "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
14894        d.caret = d.source.find('b').unwrap();
14895        d.table_set_alignment(Alignment::Right);
14896        assert_eq!(d.source, "| a | b |\n| --- | ---: |\n| 1 | 2 |\n");
14897    }
14898
14899    #[test]
14900    fn each_empty_table_cell_has_its_own_editable_home() {
14901        // Regression: an empty cell has no twig content_span, so both cells of a
14902        // `|  |  |` row collapsed onto the row's start (before the first `│`).
14903        // Typing there inserted *before* the table (`hello|  |  |`); nav couldn't
14904        // tell the cells apart. Each empty cell must now have a distinct home
14905        // inside it.
14906        let mut d = wysiwyg_doc("tbl_empty", "| a | b |\n| --- | --- |\n|  |  |\n");
14907        let (c0, c1) = {
14908            let cells = &d.vmap.tables[0].grid[1].cells;
14909            (cells[0].start, cells[1].start)
14910        };
14911        assert!(
14912            c0 < c1,
14913            "the two empty cells have distinct homes: {c0} < {c1}"
14914        );
14915        d.caret = c0;
14916        d.insert("x");
14917        assert_eq!(
14918            d.source, "| a | b |\n| --- | --- |\n| x |  |\n",
14919            "typed inside the cell"
14920        );
14921    }
14922
14923    #[test]
14924    fn arrows_step_into_each_empty_table_cell() {
14925        let mut d = wysiwyg_doc("tbl_empty_nav", "| a | b |\n| --- | --- |\n|  |  |\n");
14926        let (c0, c1) = {
14927            let cells = &d.vmap.tables[0].grid[1].cells;
14928            (cells[0].start, cells[1].start)
14929        };
14930        d.caret = d.source.find('b').unwrap(); // in the header's second cell
14931        let mut seen = std::collections::HashSet::new();
14932        for _ in 0..6 {
14933            d.move_right(false);
14934            seen.insert(d.caret);
14935        }
14936        assert!(
14937            seen.contains(&c0),
14938            "right arrow reaches the first empty cell"
14939        );
14940        assert!(
14941            seen.contains(&c1),
14942            "right arrow reaches the second empty cell"
14943        );
14944    }
14945
14946    #[test]
14947    fn table_op_off_a_table_is_a_no_op_with_a_status() {
14948        let mut d = doc_with("tbl_none", "just text\n");
14949        d.caret = 3;
14950        d.table_insert_row(true);
14951        assert_eq!(d.source, "just text\n", "nothing changed");
14952        assert!(d.status.is_some(), "a status explains why");
14953        assert!(!d.caret_in_table());
14954    }
14955
14956    #[test]
14957    fn enter_in_an_ordered_list_renumbers_the_following_items() {
14958        // Inserting an item mid-list left the source markers stale (`1. 2. 2. 3.`);
14959        // the renumber pass keeps them sequential, matching what the view draws.
14960        let mut d = wysiwyg_doc("enter_renumber", "1. a\n2. b\n3. c\n");
14961        d.caret = d.source.find('a').unwrap() + 1; // end of item a
14962        d.newline();
14963        d.insert("x");
14964        assert_eq!(d.source, "1. a\n2. x\n3. b\n4. c\n");
14965    }
14966
14967    #[test]
14968    fn outdent_with_nothing_to_give_back_records_no_undo_step() {
14969        for view in [View::Source, View::Wysiwyg] {
14970            let mut d = doc_in(view, "outdent_noop", "hello\n");
14971            d.caret = 2;
14972            d.outdent();
14973            assert_eq!(d.source, "hello\n");
14974            assert!(!d.dirty, "a no-op is not a modification");
14975            d.undo();
14976            assert_eq!(
14977                d.status.as_deref(),
14978                Some("nothing to undo"),
14979                "spends no undo step"
14980            );
14981            assert_eq!(d.source, "hello\n");
14982        }
14983    }
14984
14985    #[test]
14986    fn indent_shifts_every_selected_line_and_keeps_them_selected() {
14987        for view in [View::Source, View::Wysiwyg] {
14988            let mut d = doc_in(view, "indent_sel", "one\n\ntwo\n");
14989            d.anchor = Some(0);
14990            d.caret = 7; // through "two"
14991            d.indent();
14992            assert_eq!(
14993                d.source, "  one\n\n  two\n",
14994                "the blank line keeps no trailing pad"
14995            );
14996            // Selected, so a second Tab lands on the same lines rather than on
14997            // whatever the shifted offsets now cover.
14998            assert_eq!(d.selection(), Some((0, 12)));
14999            d.indent();
15000            assert_eq!(d.source, "    one\n\n    two\n");
15001        }
15002    }
15003
15004    #[test]
15005    fn outdent_takes_what_each_line_has_and_leaves_the_rest_alone() {
15006        for view in [View::Source, View::Wysiwyg] {
15007            let mut d = doc_in(view, "outdent_sel", "  two\n one\nnone\n");
15008            d.anchor = Some(0);
15009            d.caret = 15;
15010            d.outdent();
15011            assert_eq!(d.source, "two\none\nnone\n");
15012        }
15013    }
15014
15015    #[test]
15016    fn a_tab_undoes_as_one_step_however_many_lines_it_moved() {
15017        for view in [View::Source, View::Wysiwyg] {
15018            let mut d = doc_in(view, "indent_undo", "one\n\ntwo\n");
15019            d.anchor = Some(0);
15020            d.caret = 7;
15021            d.indent();
15022            assert_eq!(d.source, "  one\n\n  two\n");
15023            d.undo();
15024            assert_eq!(d.source, "one\n\ntwo\n", "one step, not one per line");
15025            assert_eq!(
15026                d.selection(),
15027                Some((0, 7)),
15028                "with the selection it was aimed at"
15029            );
15030            d.redo();
15031            assert_eq!(d.source, "  one\n\n  two\n");
15032            assert_eq!(
15033                d.selection(),
15034                Some((0, 12)),
15035                "redo replays the caret the indent placed, not the one splice left"
15036            );
15037        }
15038    }
15039
15040    #[test]
15041    fn vertical_motion_keeps_the_column() {
15042        let mut d = doc_with("move", "abcd\nef\n");
15043        d.caret = 3; // "abc|d" on row 0, col 3
15044        d.move_down(false); // row 1 "ef" only has cols 0..2 -> clamps to end
15045        assert_eq!(d.caret, 7); // just after "ef"
15046    }
15047
15048    // ── goal column ──────────────────────────────────────────────────────────
15049
15050    #[test]
15051    fn vertical_motion_goal_column_survives_a_short_line() {
15052        // Regression: re-deriving the column from the clamped position on
15053        // every step permanently forgets it once a short line clamps it.
15054        // Down through "xy" (2 cols) and into "ghijkl" must return to col 4.
15055        let g = |m, f: fn(&mut Doc)| golden("goalcol", m, f);
15056        assert_eq!(
15057            g("abcd|ef\nxy\nghijkl\n", |d| {
15058                d.move_down(false); // clamps to end of "xy"
15059                d.move_down(false); // restores col 4 on the long line
15060            }),
15061            "abcdef\nxy\nghij|kl\n"
15062        );
15063    }
15064
15065    #[test]
15066    fn goal_column_state_is_set_by_vertical_motion_and_cleared_by_horizontal() {
15067        let mut d = doc_with("goalcol_state", "abcdef\nxy\nghijkl\n");
15068        assert_eq!(d.goal_col, None);
15069        d.caret = 4; // row 0, col 4
15070        d.move_down(false); // clamps into "xy"; goal stays the original col
15071        assert_eq!(d.goal_col, Some(4));
15072        assert_eq!(d.caret_pos(), (1, 2));
15073
15074        // A horizontal motion drops the goal column...
15075        d.move_left(false);
15076        assert_eq!(d.goal_col, None);
15077
15078        // ...so the next vertical motion picks up the *new* column (1), not
15079        // the stale one (4).
15080        d.move_down(false);
15081        assert_eq!(d.goal_col, Some(1));
15082        assert_eq!(d.caret_pos(), (2, 1));
15083    }
15084
15085    #[test]
15086    fn editing_clears_the_goal_column() {
15087        let mut d = doc_with("goalcol_edit", "abcdef\nxy\nghijkl\n");
15088        d.caret = 4;
15089        d.move_down(false);
15090        assert_eq!(d.goal_col, Some(4));
15091        d.insert("Z");
15092        assert_eq!(d.goal_col, None);
15093    }
15094
15095    #[test]
15096    fn vertical_motion_on_an_empty_document_is_a_no_op() {
15097        let mut d = doc_with("empty_vert", "");
15098        d.move_down(false);
15099        assert_eq!(d.caret, 0);
15100        d.move_up(false);
15101        assert_eq!(d.caret, 0);
15102    }
15103
15104    // ── the document's edges ─────────────────────────────────────────────────
15105
15106    #[test]
15107    fn vertical_motion_at_the_document_edges_runs_to_them_in_both_views() {
15108        // The reproduction, and the disagreement: Down on the last line ran to
15109        // the end of the document in the source view — by accident, an
15110        // out-of-range row clamping to the end of the string — and did nothing
15111        // whatever in the view leaf opens in. One rule now, in both.
15112        for (view, tag) in VIEWS {
15113            let mut d = doc_in(view, &format!("edge_{tag}"), "abc");
15114            d.caret = 1;
15115            d.move_down(false);
15116            assert_eq!(d.caret, 3, "{tag}: Down on the last line runs to the end");
15117            d.move_up(false);
15118            assert_eq!(d.caret, 0, "{tag}: Up on the first line runs to the start");
15119        }
15120    }
15121
15122    #[test]
15123    fn vertical_motion_at_the_edges_carries_the_column_across_the_lines_between() {
15124        // Down off the bottom is a motion like any other, so it latches a goal
15125        // column — and Up comes back to the column the caret left, not to the
15126        // one the document's end happened to be in.
15127        for (view, tag) in VIEWS {
15128            let gap = if view == View::Source { "\n" } else { "\n\n" };
15129            let src = format!("abcdef{gap}ghijkl");
15130            let mut d = doc_in(view, &format!("edge_goal_{tag}"), &src);
15131            d.caret = 2; // row 0, col 2
15132            d.move_down(false);
15133            assert_eq!(d.caret_pos().1, 2, "{tag}: Down keeps the column");
15134            d.move_down(false);
15135            assert_eq!(
15136                d.caret,
15137                src.len(),
15138                "{tag}: Down off the bottom reaches the end"
15139            );
15140            d.move_up(false);
15141            assert_eq!(
15142                d.caret_pos().1,
15143                2,
15144                "{tag}: Up returns to the column Down left"
15145            );
15146        }
15147    }
15148
15149    #[test]
15150    fn vertical_motion_with_nowhere_to_go_latches_no_goal_column() {
15151        // `goal_col.get_or_insert` ran *before* the early return at row 0, so an
15152        // Up that did nothing still armed a goal column, and the next Down aimed
15153        // at a column the caret had never been in.
15154        for (view, tag) in VIEWS {
15155            let mut d = doc_in(view, &format!("noop_goal_{tag}"), "abc\n\ndef");
15156            d.caret = 0;
15157            d.move_up(false);
15158            assert_eq!(d.caret, 0, "{tag}: already at the start");
15159            assert_eq!(d.goal_col, None, "{tag}: a no-op Up latched a goal column");
15160
15161            d.caret = d.source.len();
15162            d.move_down(false);
15163            assert_eq!(d.caret, d.source.len(), "{tag}: already at the end");
15164            assert_eq!(
15165                d.goal_col, None,
15166                "{tag}: a no-op Down latched a goal column"
15167            );
15168        }
15169    }
15170
15171    // ── soft wrap ────────────────────────────────────────────────────────────
15172    // Every other test here builds the map at 80 columns, where no fixture is
15173    // long enough to fold. A wrap is where one offset belongs to two rows at
15174    // once, and it broke everything that asks the caret what row it is on.
15175
15176    /// The wrapped fixture these cases share, folded at 12 columns into
15177    /// `one two ` / `three four ` / `five six ` / `seven eight`.
15178    fn wrapped_doc(name: &str) -> Doc {
15179        let mut d = wysiwyg_doc(name, "one two three four five six seven eight");
15180        d.build_visual(12);
15181        d
15182    }
15183
15184    #[test]
15185    fn home_and_end_work_from_a_wrapped_row() {
15186        // The reproduction: offset 19 is the `f` of "five", the first character
15187        // of the third row — and also the offset the second row ends at. It
15188        // resolved to the *second* row, so End aimed at a place the caret was
15189        // already in and did nothing, while Home walked backwards onto a row the
15190        // caret had left.
15191        let mut d = wrapped_doc("wrap_home_end");
15192        d.caret = 19;
15193        assert_eq!(
15194            d.caret_pos(),
15195            (2, 0),
15196            "the wrap boundary opens the third row"
15197        );
15198        d.move_end(false);
15199        assert_eq!(d.caret, 27, "End stalled at the wrap boundary");
15200        d.move_home(false);
15201        assert_eq!(d.caret, 19, "Home left the row the caret was on");
15202    }
15203
15204    #[test]
15205    fn end_of_a_wrapped_row_stays_put_when_pressed_again() {
15206        // The row's end is the last offset that is only ever its own: the offset
15207        // past it opens the row below, and aiming there would send a second
15208        // press on to *that* row's end, and a third to the next — End walking
15209        // down the paragraph rather than sitting where it landed.
15210        let mut d = wrapped_doc("wrap_end_twice");
15211        d.caret = 12; // inside "three", on the second row
15212        d.move_end(false);
15213        assert_eq!(
15214            d.caret, 18,
15215            "the end of `three four`, before the space the wrap ate"
15216        );
15217        assert_eq!(d.caret_pos(), (1, 10), "drawn on the row it is the end of");
15218        d.move_end(false);
15219        assert_eq!(d.caret, 18, "a second End moved the caret");
15220        d.move_home(false);
15221        assert_eq!(d.caret, 8, "Home takes the row's own start");
15222    }
15223
15224    #[test]
15225    fn vertical_motion_crosses_a_soft_wrap() {
15226        // Down aimed at the row below's column 0, an offset that resolved *up*
15227        // to the row above's end — so it landed on the offset it already had and
15228        // the caret could never leave a paragraph's first row.
15229        let mut d = wrapped_doc("wrap_down");
15230        d.caret = 0;
15231        for (want, row) in [(8, 1), (19, 2), (28, 3), (39, 3)] {
15232            d.move_down(false);
15233            assert_eq!(d.caret, want, "Down stalled");
15234            assert_eq!(d.caret_pos().0, row, "Down landed on the wrong row");
15235        }
15236        d.move_down(false);
15237        assert_eq!(d.caret, 39, "the last row's Down runs to the end and stops");
15238
15239        // ...and back up, one row per press. The goal column is the end of the
15240        // last row, past every other row's width, so each press clamps to the
15241        // row's own last offset rather than to the one that opens the next.
15242        let mut d = wrapped_doc("wrap_up");
15243        d.caret = 39;
15244        for (want, pos) in [(27, (2, 8)), (18, (1, 10)), (7, (0, 7)), (0, (0, 0))] {
15245            d.move_up(false);
15246            assert_eq!(d.caret, want, "Up stalled");
15247            assert_eq!(d.caret_pos(), pos, "Up landed on the wrong row");
15248        }
15249    }
15250
15251    #[test]
15252    fn a_kill_on_a_wrapped_row_stops_at_the_row() {
15253        // The kills take the same line Home and End do, so in WYSIWYG they take
15254        // the visual row — and a soft wrap has no newline in it to delete, so
15255        // nothing is joined by reaching the end of one.
15256        let mut d = wrapped_doc("wrap_kill");
15257        d.caret = 19; // the `f` of "five", opening the third row
15258        d.delete_to_line_end();
15259        // The space the wrap ate goes with the row it was drawn on: sparing it
15260        // would leave "four  seven", two spaces where the row had been.
15261        assert_eq!(d.source, "one two three four seven eight");
15262
15263        // Backwards from the row's last caret position — which is *before* that
15264        // space, so this one survives, being on the far side of the caret.
15265        let mut d = wrapped_doc("wrap_kill_back");
15266        d.caret = 27;
15267        d.delete_to_line_start();
15268        assert_eq!(d.source, "one two three four  seven eight");
15269    }
15270
15271    // ── document start / end ────────────────────────────────────────────────
15272
15273    #[test]
15274    fn move_doc_start_and_end_jump_to_the_edges() {
15275        let g = |m, f: fn(&mut Doc)| golden("doc_edges", m, f);
15276        assert_eq!(
15277            g("hello\nwor|ld\n", |d| d.move_doc_start(false)),
15278            "|hello\nworld\n"
15279        );
15280        assert_eq!(
15281            g("hel|lo\nworld\n", |d| d.move_doc_end(false)),
15282            "hello\nworld\n|"
15283        );
15284        // Already at the edge: a no-op.
15285        assert_eq!(g("|hello\n", |d| d.move_doc_start(false)), "|hello\n");
15286        assert_eq!(g("hello|\n", |d| d.move_doc_end(false)), "hello\n|");
15287    }
15288
15289    #[test]
15290    fn move_doc_start_and_end_extend_the_selection() {
15291        assert_eq!(
15292            golden("doc_edges_ext_end", "hello wor|ld\n", |d| d
15293                .move_doc_end(true)),
15294            "hello wor[ld\n|]"
15295        );
15296        assert_eq!(
15297            golden("doc_edges_ext_start", "hello wor|ld\n", |d| d
15298                .move_doc_start(true)),
15299            "[|hello wor]ld\n"
15300        );
15301    }
15302
15303    #[test]
15304    fn move_doc_start_and_end_on_an_empty_document_are_a_no_op() {
15305        let mut d = doc_with("empty_edges", "");
15306        d.move_doc_end(false);
15307        assert_eq!(d.caret, 0);
15308        d.move_doc_start(false);
15309        assert_eq!(d.caret, 0);
15310    }
15311
15312    // ── arrow collapses an active selection ─────────────────────────────────
15313
15314    #[test]
15315    fn arrow_collapses_selection_to_its_near_edge() {
15316        let mut d = doc_with("collapse", "hello world\n");
15317
15318        // Forward selection (anchor before caret): Right -> end, Left -> start.
15319        d.anchor = Some(2);
15320        d.caret = 7;
15321        d.move_right(false);
15322        assert_eq!((d.caret, d.anchor), (7, None));
15323
15324        d.anchor = Some(2);
15325        d.caret = 7;
15326        d.move_left(false);
15327        assert_eq!((d.caret, d.anchor), (2, None));
15328
15329        // Backward selection (anchor after caret): edges are the same
15330        // regardless of which end the caret started on.
15331        d.anchor = Some(7);
15332        d.caret = 2;
15333        d.move_right(false);
15334        assert_eq!((d.caret, d.anchor), (7, None));
15335
15336        d.anchor = Some(7);
15337        d.caret = 2;
15338        d.move_left(false);
15339        assert_eq!((d.caret, d.anchor), (2, None));
15340    }
15341
15342    #[test]
15343    fn arrow_with_extend_keeps_growing_the_selection() {
15344        let mut d = doc_with("collapse_extend", "hello world\n");
15345        d.anchor = Some(2);
15346        d.caret = 7;
15347        d.move_right(true); // extend: no collapse, caret steps one further
15348        assert_eq!((d.caret, d.anchor), (8, Some(2)));
15349    }
15350
15351    #[test]
15352    fn arrow_without_a_selection_moves_one_character_as_before() {
15353        let mut d = doc_with("no_collapse", "hello\n");
15354        d.caret = 2;
15355        d.move_right(false);
15356        assert_eq!(d.caret, 3);
15357        d.move_left(false);
15358        assert_eq!(d.caret, 2);
15359    }
15360
15361    /// Press Right until it stops, collecting the offsets walked through. Every
15362    /// caret bug in the WYSIWYG view shows up here as a walk that ends early:
15363    /// two stops sharing one source offset can't be moved between, so the caret
15364    /// stalls on the first of them and the walk never reaches the rest.
15365    fn walk_right(d: &mut Doc) -> Vec<usize> {
15366        let mut seen = vec![d.caret];
15367        for _ in 0..2000 {
15368            let before = d.caret;
15369            d.move_right(false);
15370            if d.caret == before {
15371                break;
15372            }
15373            seen.push(d.caret);
15374        }
15375        seen
15376    }
15377
15378    #[test]
15379    fn the_caret_crosses_a_soft_break() {
15380        // A newline inside a paragraph is a `soft_break`, which twig gives no
15381        // span of its own — the space it renders as used to borrow the offset of
15382        // the character before it, and a caret can't move without changing
15383        // offset. Right must walk clean off the end of the first line.
15384        let mut d = wysiwyg_doc("soft_break_walk", "one two\nthree four\n");
15385        d.caret = 0;
15386        let seen = walk_right(&mut d);
15387        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
15388    }
15389
15390    #[test]
15391    fn line_flow_preserve_resplits_the_map_and_defaults_to_fold() {
15392        // The paragraph holds one soft break. Folded (the default) it lays out as
15393        // a single reflowed row; Preserve re-lays it as a row per source line.
15394        // The setter must invalidate the cached map for the change to show, and
15395        // again on the way back — so a round trip returns to the folded layout.
15396        let mut d = wysiwyg_doc("line_flow", "one two\nthree four\n");
15397        assert_eq!(d.line_flow(), LineFlow::Fold, "fold is the default");
15398        d.build_visual(80);
15399        assert_eq!(d.vmap.num_rows(), 1, "fold: one flowing row");
15400
15401        d.set_line_flow(LineFlow::Preserve);
15402        d.build_visual(80);
15403        assert_eq!(d.vmap.num_rows(), 2, "preserve: a row per source line");
15404
15405        d.set_line_flow(LineFlow::Fold);
15406        d.build_visual(80);
15407        assert_eq!(d.vmap.num_rows(), 1, "fold again: back to one row");
15408    }
15409
15410    #[test]
15411    fn the_caret_still_crosses_a_preserved_soft_break() {
15412        // Preserve renders the soft break as a row boundary rather than a space,
15413        // but the caret must still reach every offset — the break's own offset is
15414        // the first row's end stop, so Right walks clean off the end of line one
15415        // onto line two, exactly as it does when the break is folded.
15416        let mut d = wysiwyg_doc("preserve_walk", "one two\nthree four\n");
15417        d.set_line_flow(LineFlow::Preserve);
15418        d.build_visual(80);
15419        d.caret = 0;
15420        let seen = walk_right(&mut d);
15421        assert_eq!(seen, (0..=18).collect::<Vec<_>>(), "walk stalled: {seen:?}");
15422    }
15423
15424    #[test]
15425    fn the_caret_walks_a_code_block() {
15426        // Every glyph of a code block used to map to the block's start, so the
15427        // whole block was a single offset and the caret couldn't move inside it.
15428        let src = "```rust\nlet x = 1;\nfn f() {}\n```\n";
15429        let mut d = wysiwyg_doc("code_walk", src);
15430        d.caret = 0;
15431        let seen = walk_right(&mut d);
15432        // The fences are markup: hidden, and no caret stop. The code between
15433        // them is reached a character at a time.
15434        let code = src.find("let").unwrap()..src.find("\n```").unwrap();
15435        for off in code.clone() {
15436            assert!(seen.contains(&off), "offset {off} unreachable: {seen:?}");
15437        }
15438        assert!(seen.contains(&code.end), "no stop after the last line");
15439    }
15440
15441    #[test]
15442    fn the_caret_walks_an_indented_code_block() {
15443        // An indented block's text has the four-space indent stripped, so it
15444        // isn't a verbatim slice and its lines have to be re-found. The caret
15445        // lands on the code, never in the indent.
15446        let src = "    indented\n    code\n";
15447        let mut d = wysiwyg_doc("indent_code_walk", src);
15448        d.caret = 0;
15449        let seen = walk_right(&mut d);
15450        assert!(seen.contains(&src.find("indented").unwrap()));
15451        assert!(seen.contains(&src.find("code").unwrap()));
15452        assert!(
15453            !seen.contains(&0) || seen[0] == 0,
15454            "the caret starts where it was put"
15455        );
15456        // Nothing in the stripped indent is a stop.
15457        for off in [1, 2, 3] {
15458            assert!(!seen.contains(&off), "landed in the indent at {off}");
15459        }
15460    }
15461
15462    #[test]
15463    fn the_caret_leaves_a_tight_heading() {
15464        // "# H" with text directly under it: the heading row's end and the
15465        // separator row's end are the same offset. Right used to find the
15466        // separator's copy, set the caret to where it already was, and stop.
15467        let mut d = wysiwyg_doc("tight_heading_walk", "# H\ntext\n");
15468        d.caret = 2; // the "H"
15469        let seen = walk_right(&mut d);
15470        assert!(
15471            seen.len() > 2,
15472            "Right stalled at the heading's end: {seen:?}"
15473        );
15474        assert!(
15475            seen.contains(&8),
15476            "never reached the end of \"text\": {seen:?}"
15477        );
15478    }
15479
15480    #[test]
15481    fn the_caret_skips_the_gap_between_two_paragraphs() {
15482        // The blank line between two paragraphs is the boundary itself. The
15483        // caret used to be able to sit on it, and typing there landed in the
15484        // previous paragraph — "A\n\nB" became "A\nx\nB", one paragraph with a
15485        // soft break, so the text visibly snapped back up.
15486        let mut d = wysiwyg_doc("gap_skip", "A\n\nB\n");
15487        d.caret = 1; // the end of "A"
15488        d.move_right(false);
15489        assert_eq!(d.caret, 3, "Right stopped in the gap");
15490        d.insert("x");
15491        assert_eq!(d.source, "A\n\nxB\n", "typing landed outside B");
15492    }
15493
15494    #[test]
15495    fn down_from_a_paragraph_lands_on_the_next_one() {
15496        let mut d = wysiwyg_doc("gap_down", "A\n\nB\n");
15497        d.caret = 0;
15498        d.move_down(false);
15499        assert_eq!(d.caret, 3, "Down stopped in the gap");
15500    }
15501
15502    #[test]
15503    fn clicking_the_gap_lands_on_real_text() {
15504        // A click can still *reach* the gap — it's drawn, so it's clickable.
15505        // It has to resolve to somewhere the caret can be.
15506        let mut d = wysiwyg_doc("gap_click", "A\n\nB\n");
15507        d.click(1, 0, false); // the gap row
15508        assert!(
15509            d.caret == 1 || d.caret == 3,
15510            "click left the caret in the gap at {}",
15511            d.caret
15512        );
15513        d.insert("x");
15514        // Either edge of the boundary is a fair place to land; inside it isn't.
15515        assert!(
15516            d.source == "Ax\n\nB\n" || d.source == "A\n\nxB\n",
15517            "click in the gap typed into the boundary: {:?}",
15518            d.source
15519        );
15520    }
15521
15522    #[test]
15523    fn enter_opens_an_empty_paragraph_the_caret_can_type_into() {
15524        // Enter inserts a paragraph break, which leaves a blank line spare on
15525        // either side of a new one. That middle line is a real empty paragraph:
15526        // the caret lands there, and typing makes a paragraph rather than
15527        // extending a neighbour.
15528        let mut d = wysiwyg_doc("gap_enter", "A\n\nB\n");
15529        d.caret = 1;
15530        d.newline();
15531        assert_eq!(d.source, "A\n\n\n\nB\n");
15532        d.build_visual(80);
15533        let (row, _) = d.caret_pos();
15534        assert!(
15535            d.vmap.row_is_navigable(row),
15536            "the caret landed on a gap row"
15537        );
15538        d.insert("x");
15539        assert_eq!(
15540            d.source, "A\n\nx\n\nB\n",
15541            "the new paragraph merged into a neighbour"
15542        );
15543    }
15544
15545    #[test]
15546    fn enter_at_the_end_of_the_document_opens_a_paragraph_too() {
15547        let mut d = wysiwyg_doc("gap_eof", "A\n");
15548        d.caret = 1;
15549        d.newline();
15550        d.build_visual(80);
15551        let (row, _) = d.caret_pos();
15552        assert!(
15553            d.vmap.row_is_navigable(row),
15554            "the caret landed on a gap row"
15555        );
15556        d.insert("x");
15557        assert!(
15558            d.source.starts_with("A\n\n") && d.source.contains('x'),
15559            "typing at the end merged into A: {:?}",
15560            d.source
15561        );
15562    }
15563
15564    // ── click_past_end ───────────────────────────────────────────────────────
15565
15566    /// [`Doc::click_past_end`] on `body`, and the source it left, with the caret
15567    /// rendered as `|`.
15568    fn past_end(name: &str, body: &str) -> (Doc, String) {
15569        let mut d = wysiwyg_doc(name, body);
15570        d.click_past_end();
15571        let out = render_caret(&d);
15572        (d, out)
15573    }
15574
15575    #[test]
15576    fn click_past_end_opens_an_empty_paragraph_under_the_last_block() {
15577        let (mut d, out) = past_end("pe_para", "A\n");
15578        assert_eq!(out, "A\n\n|");
15579        d.build_visual(80);
15580        let (row, _) = d.caret_pos();
15581        assert!(
15582            d.vmap.row_is_navigable(row),
15583            "the caret landed on a gap row"
15584        );
15585        d.insert("x");
15586        assert_eq!(d.source, "A\n\nx", "typing merged into A");
15587    }
15588
15589    #[test]
15590    fn click_past_end_writes_both_newlines_when_the_file_has_none() {
15591        assert_eq!(past_end("pe_bare", "A").1, "A\n\n|");
15592    }
15593
15594    #[test]
15595    fn click_past_end_is_only_a_caret_move_when_the_paragraph_is_already_there() {
15596        let (d, out) = past_end("pe_there", "A\n\n");
15597        assert_eq!(out, "A\n\n|");
15598        assert!(!d.dirty, "a click wrote to a document it did not need to");
15599        assert!(
15600            !d.can_undo(),
15601            "a click that changed nothing left an undo step"
15602        );
15603    }
15604
15605    #[test]
15606    fn click_past_end_leaves_a_closed_fence() {
15607        let (mut d, out) = past_end("pe_fence", "```\ncode\n```\n");
15608        assert_eq!(out, "```\ncode\n```\n\n|");
15609        d.build_visual(80);
15610        let (row, _) = d.caret_pos();
15611        assert!(!d.vmap.rows[row].code, "the caret is still on a code row");
15612        d.insert("x");
15613        assert_eq!(d.source, "```\ncode\n```\n\nx");
15614    }
15615
15616    #[test]
15617    fn click_past_end_closes_an_unclosed_fence_first() {
15618        assert_eq!(past_end("pe_open", "```\ncode\n").1, "```\ncode\n```\n\n|");
15619        assert_eq!(
15620            past_end("pe_open2", "````\ncode").1,
15621            "````\ncode\n````\n\n|"
15622        );
15623        assert_eq!(past_end("pe_tilde", "~~~\ncode\n").1, "~~~\ncode\n~~~\n\n|");
15624        assert_eq!(
15625            past_end("pe_quoted", "> ```\n> code\n").1,
15626            "> ```\n> code\n> ```\n\n|"
15627        );
15628    }
15629
15630    #[test]
15631    fn click_past_end_does_not_close_an_indented_block() {
15632        assert_eq!(past_end("pe_indent", "    code\n").1, "    code\n\n|");
15633    }
15634
15635    #[test]
15636    fn click_past_end_under_a_list_and_a_table_leaves_them() {
15637        let (mut d, out) = past_end("pe_list", "- a\n- b\n");
15638        assert_eq!(out, "- a\n- b\n\n|");
15639        d.insert("x");
15640        assert_eq!(d.source, "- a\n- b\n\nx", "typed into the list");
15641        assert_eq!(
15642            past_end("pe_table", "| a |\n|---|\n| b |\n").1,
15643            "| a |\n|---|\n| b |\n\n|"
15644        );
15645    }
15646
15647    #[test]
15648    fn click_past_end_on_an_empty_document_writes_nothing() {
15649        let (d, out) = past_end("pe_empty", "");
15650        assert_eq!(out, "|");
15651        assert!(!d.dirty);
15652    }
15653
15654    #[test]
15655    fn click_past_end_is_one_undo_step() {
15656        let (mut d, _) = past_end("pe_undo", "A\n");
15657        d.undo();
15658        assert_eq!(d.source, "A\n");
15659    }
15660
15661    #[test]
15662    fn click_past_end_in_the_source_view_only_moves_the_caret() {
15663        let mut d = doc_with("pe_source", "A\n");
15664        d.click_past_end();
15665        assert_eq!(render_caret(&d), "A\n|");
15666        assert!(!d.dirty);
15667    }
15668
15669    #[test]
15670    fn click_past_end_on_a_read_only_document_only_moves_the_caret() {
15671        let mut d = wysiwyg_doc("pe_ro", "A\n");
15672        d.set_read_only(true);
15673        d.click_past_end();
15674        assert_eq!(render_caret(&d), "A\n|");
15675    }
15676
15677    // ── toggle_code_block ────────────────────────────────────────────────────
15678
15679    #[test]
15680    fn toggle_code_block_fences_the_paragraph_at_the_caret_and_reverses() {
15681        let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
15682        assert_eq!(g("cb_on", "hel|lo\n"), "```\nhel|lo\n```\n");
15683        assert_eq!(g("cb_off", "```\nhel|lo\n```\n"), "hel|lo\n");
15684        assert_eq!(g("cb_end", "hello|\n"), "```\nhello|\n```\n");
15685        assert_eq!(
15686            g("cb_wrap", "aaa\nb|bb\nccc\n"),
15687            "```\naaa\nb|bb\nccc\n```\n"
15688        );
15689        assert_eq!(
15690            g("cb_unwrap", "```\naaa\nb|bb\nccc\n```\n"),
15691            "aaa\nb|bb\nccc\n"
15692        );
15693        // An indented block dedents, and the lines stay one-to-one.
15694        assert_eq!(g("cb_indent", "    co|de\n"), "co|de\n");
15695        // A quote's marker is kept on every line, the fence's included.
15696        assert_eq!(g("cb_quote", "> hel|lo\n"), "> ```\n> hel|lo\n> ```\n");
15697    }
15698
15699    #[test]
15700    fn toggle_code_block_on_a_blank_line_opens_an_empty_fence() {
15701        let g = |n, m| golden_in(View::Wysiwyg, n, m, |d| d.toggle_code_block());
15702        assert_eq!(g("cb_blank", "A\n\n|"), "A\n\n```\n|\n```");
15703        assert_eq!(
15704            g("cb_blank_mid", "A\n\n|\n\nB\n"),
15705            "A\n\n```\n|\n```\n\nB\n"
15706        );
15707        // Tight against a neighbour, a blank line goes in on that side.
15708        assert_eq!(g("cb_blank_tight", "A\n|\nB\n"), "A\n\n```\n|\n```\n\nB\n");
15709        // Inside a quote, on a quoted blank line.
15710        assert_eq!(
15711            g("cb_blank_quote", "> A\n>\n> |\n"),
15712            "> A\n>\n> ```\n> |\n> ```\n"
15713        );
15714    }
15715
15716    #[test]
15717    fn toggle_code_block_from_a_blank_line_is_a_block_the_caret_can_type_into() {
15718        let mut d = wysiwyg_doc("cb_type", "A\n\n");
15719        d.click_past_end();
15720        d.toggle_code_block();
15721        assert!(
15722            d.caret_in_code_block(),
15723            "the caret is not in the block it opened"
15724        );
15725        d.insert("let x = 1;");
15726        d.newline();
15727        d.insert("x");
15728        assert_eq!(d.source, "A\n\n```\nlet x = 1;\nx\n```");
15729        d.build_visual(80);
15730        let (row, _) = d.caret_pos();
15731        assert!(d.vmap.rows[row].code, "typed text is not on a code row");
15732        // And back out: the button reverses what it did, text kept.
15733        d.toggle_code_block();
15734        assert_eq!(d.source, "A\n\nlet x = 1;\nx\n");
15735        assert!(!d.caret_in_code_block());
15736    }
15737
15738    #[test]
15739    fn toggle_code_block_over_a_selection_fences_it_whole_and_keeps_it_selected() {
15740        let mut d = wysiwyg_doc("cb_sel", "one\n\ntwo\n\nthree\n");
15741        d.anchor = Some(1);
15742        d.caret = 7;
15743        d.toggle_code_block();
15744        assert_eq!(d.source, "```\none\n\ntwo\n```\n\nthree\n");
15745        assert!(d.selection().is_some(), "the block came back unselected");
15746        d.toggle_code_block();
15747        assert_eq!(
15748            d.source, "one\n\ntwo\n\nthree\n",
15749            "a second press did not reverse the first"
15750        );
15751    }
15752
15753    #[test]
15754    fn toggle_code_block_inside_a_list_item_is_refused_and_reported() {
15755        let mut d = wysiwyg_doc("cb_list", "- it|em\n".replace('|', "").as_str());
15756        d.caret = 3;
15757        d.toggle_code_block();
15758        assert_eq!(d.source, "- item\n");
15759        assert!(
15760            d.status
15761                .as_deref()
15762                .is_some_and(|s| s.starts_with("code block:"))
15763        );
15764    }
15765
15766    #[test]
15767    fn caret_in_code_block_reads_fenced_and_indented_blocks() {
15768        let mut d = wysiwyg_doc("cb_in", "para\n\n```\ncode\n```\n\n    more\n");
15769        d.caret = 2;
15770        assert!(!d.caret_in_code_block());
15771        d.caret = 11;
15772        assert!(d.caret_in_code_block());
15773        d.caret = 25;
15774        assert!(d.caret_in_code_block(), "an indented block is a code block");
15775    }
15776
15777    #[test]
15778    fn toggle_code_block_is_one_undo_step() {
15779        let mut d = wysiwyg_doc("cb_undo", "hello\n");
15780        d.caret = 2;
15781        d.toggle_code_block();
15782        d.undo();
15783        assert_eq!(render_caret(&d), "he|llo\n");
15784    }
15785
15786    #[test]
15787    fn triple_click_selects_a_paragraph_across_its_soft_breaks() {
15788        // A paragraph broken over two source lines is one paragraph. Selecting
15789        // it must not stop at the newline inside it — that newline is markup the
15790        // rich-text view exists to hide.
15791        let src = "one two\nthree four\n\nnext\n";
15792        let mut d = wysiwyg_doc("triple_para", src);
15793        d.select_block_at(2);
15794        assert_eq!(
15795            d.selected_text(),
15796            Some("one two\nthree four"),
15797            "stopped at the soft break"
15798        );
15799    }
15800
15801    #[test]
15802    fn the_wheel_can_scroll_away_from_a_caret_that_stays_put() {
15803        // The reader scrolls down past the caret's row. Nothing moved the
15804        // caret, so the view must stay where it was put — the old code revealed
15805        // the caret every frame, which dragged the view straight back and made
15806        // the document unscrollable past the caret.
15807        let mut d = wysiwyg_doc("scroll_free", "a\n\nb\n\nc\n\nd\n\ne\n");
15808        d.caret = 0;
15809        d.follow_caret(0, 3, 9); // first frame: the caret is at the top
15810        d.scroll = 4; // the wheel
15811        d.follow_caret(0, 3, 9);
15812        assert_eq!(
15813            d.scroll, 4,
15814            "the wheel was overruled by a caret that never moved"
15815        );
15816    }
15817
15818    #[test]
15819    fn moving_the_caret_brings_the_view_back_to_it() {
15820        let mut d = wysiwyg_doc("scroll_follow", "a\n\nb\n\nc\n\nd\n\ne\n");
15821        d.caret = 0;
15822        d.follow_caret(0, 3, 9);
15823        d.scroll = 6; // scrolled away
15824        d.move_right(false); // ...and now the caret moves
15825        let (row, _) = d.caret_pos();
15826        d.follow_caret(row, 3, 9);
15827        assert!(
15828            d.scroll <= row && row < d.scroll + 3,
15829            "caret row {row} off screen at scroll {}",
15830            d.scroll
15831        );
15832    }
15833
15834    #[test]
15835    fn scrolling_stops_at_the_last_row() {
15836        let mut d = wysiwyg_doc("scroll_clamp", "a\n\nb\n");
15837        d.caret = 0;
15838        d.follow_caret(0, 3, 3); // a first frame, so the caret isn't "new"
15839        d.scroll = 999; // the wheel, spun hard
15840        d.follow_caret(0, 3, 3);
15841        assert_eq!(d.scroll, 2, "scrolled into the void past the document");
15842    }
15843
15844    #[test]
15845    fn every_cell_of_a_wide_table_is_reachable() {
15846        // A table whose cells are far wider than the surface: the columns are
15847        // cut to fit and the text wraps inside them, so no cell hangs off the
15848        // right edge where the caret can never go.
15849        let src = "| Ingredient | Notes |\n|---|---|\n\
15850                   | flour milled coarse | sift it twice before folding it in |\n";
15851        let mut d = wysiwyg_doc("wide_table_walk", src);
15852        d.build_visual(30);
15853        d.caret = 0;
15854        let seen = walk_right(&mut d);
15855        for word in ["Ingredient", "Notes", "coarse", "folding"] {
15856            let at = src.find(word).unwrap();
15857            assert!(seen.contains(&at), "{word:?} at {at} unreachable: {seen:?}");
15858        }
15859    }
15860
15861    // ── view parity ──────────────────────────────────────────────────────────
15862    // `doc_with` pins the source view, so everything above tests a view users
15863    // never start in — `Doc::open` opens in WYSIWYG. These run the motion and
15864    // deletion golden cases through *both*, plus the WYSIWYG cases the two
15865    // can't share: where the source carries markup the rendered text is a
15866    // different string, and the views agreeing would itself be the bug.
15867
15868    const VIEWS: [(View, &str); 2] = [(View::Source, "source"), (View::Wysiwyg, "wysiwyg")];
15869
15870    /// Run `action` in both views on one `|`-marked fixture and assert they
15871    /// agree. Plain prose only: with no markup to hide, WYSIWYG renders the
15872    /// source verbatim, so the two views are looking at the same text and any
15873    /// disagreement is one of them having lost the plot.
15874    fn both_views(name: &str, marked: &str, action: fn(&mut Doc)) -> String {
15875        let (src, caret) = parse_caret(marked);
15876        let run = |view: View, tag: &str| {
15877            let mut d = doc_in(view, &format!("{name}_{tag}"), &src);
15878            d.caret = caret;
15879            action(&mut d);
15880            render_caret(&d)
15881        };
15882        let source = run(VIEWS[0].0, VIEWS[0].1);
15883        let wysiwyg = run(VIEWS[1].0, VIEWS[1].1);
15884        assert_eq!(source, wysiwyg, "the views disagree on {marked:?}");
15885        source
15886    }
15887
15888    #[test]
15889    fn word_motion_agrees_across_the_views_on_plain_prose() {
15890        let g = both_views;
15891        assert_eq!(
15892            g("par_wl", "hello wor|ld", |d| d.move_word_left(false)),
15893            "hello |world"
15894        );
15895        assert_eq!(
15896            g("par_wl2", "hello| world", |d| d.move_word_left(false)),
15897            "|hello world"
15898        );
15899        assert_eq!(
15900            g("par_wr", "hel|lo world", |d| d.move_word_right(false)),
15901            "hello| world"
15902        );
15903        assert_eq!(
15904            g("par_wr2", "hello| world", |d| d.move_word_right(false)),
15905            "hello world|"
15906        );
15907        assert_eq!(
15908            g("par_punct", "|foo.bar", |d| d.move_word_right(false)),
15909            "foo|.bar"
15910        );
15911        assert_eq!(
15912            g("par_ext", "hello |world", |d| d.move_word_right(true)),
15913            "hello [world|]"
15914        );
15915    }
15916
15917    #[test]
15918    fn word_deletion_agrees_across_the_views_on_plain_prose() {
15919        let g = both_views;
15920        assert_eq!(
15921            g("par_db", "hello world|", |d| d.delete_word_back()),
15922            "hello |"
15923        );
15924        assert_eq!(
15925            g("par_df", "hello |world", |d| d.delete_word_forward()),
15926            "hello |"
15927        );
15928        assert_eq!(
15929            g("par_db2", "foo |bar baz", |d| d.delete_word_back()),
15930            "|bar baz"
15931        );
15932        assert_eq!(g("par_utf8", "café |ok", |d| d.delete_word_back()), "|ok");
15933    }
15934
15935    #[test]
15936    fn character_motion_and_deletion_agree_across_the_views_on_plain_prose() {
15937        let g = both_views;
15938        assert_eq!(g("par_r", "he|llo", |d| d.move_right(false)), "hel|lo");
15939        assert_eq!(g("par_l", "he|llo", |d| d.move_left(false)), "h|ello");
15940        assert_eq!(g("par_bs", "hel|lo", |d| d.backspace()), "he|lo");
15941        assert_eq!(g("par_del", "hel|lo", |d| d.delete_forward()), "hel|o");
15942    }
15943
15944    #[test]
15945    fn wysiwyg_motion_steps_a_grapheme_cluster_the_way_the_source_view_does() {
15946        // The reproduction: the stop table was built one stop per `char`, so
15947        // Right parked the caret 4 bytes into a ZWJ sequence — a place the
15948        // source view, which steps by grapheme, can't reach and backspace can't
15949        // survive. The two views must land on the same offset.
15950        let family = "👨‍👩‍👧"; // three emoji strung together with joiners: one cluster
15951        for (view, tag) in VIEWS {
15952            let mut d = doc_in(view, &format!("cluster_{tag}"), &format!("a{family}b\n"));
15953            d.caret = 1;
15954            d.move_right(false);
15955            assert_eq!(d.caret, 1 + family.len(), "{tag} parked inside the cluster");
15956
15957            // ...and the edit that used to sever a joiner off the front of it.
15958            d.backspace();
15959            assert_eq!(d.source, "ab\n", "{tag} split the cluster");
15960            assert_eq!(d.caret, 1);
15961        }
15962    }
15963
15964    #[test]
15965    fn wysiwyg_motion_treats_a_combining_accent_as_one_character() {
15966        for (view, tag) in VIEWS {
15967            let mut d = doc_in(view, &format!("combining_{tag}"), "e\u{0301}x\n");
15968            d.caret = 0;
15969            d.move_right(false);
15970            assert_eq!(
15971                d.caret,
15972                "e\u{0301}".len(),
15973                "{tag} stopped on the combining mark"
15974            );
15975        }
15976    }
15977
15978    #[test]
15979    fn no_wysiwyg_motion_can_park_the_caret_inside_a_cluster() {
15980        // The general form: whatever route the caret takes through a document
15981        // full of clusters, it never lands between the codepoints of one — so no
15982        // motion-then-backspace sequence can leave a dangling joiner behind.
15983        use unicode_segmentation::UnicodeSegmentation;
15984
15985        let src = "a👨‍👩‍👧b e\u{0301}mo👨‍👩‍👧ji\n\nnext 👩‍🚀 line\n";
15986        let mut d = wysiwyg_doc("cluster_walk", src);
15987        d.caret = 0;
15988        let boundaries: Vec<usize> = src
15989            .grapheme_indices(true)
15990            .map(|(i, _)| i)
15991            .chain(std::iter::once(src.len()))
15992            .collect();
15993        for off in walk_right(&mut d) {
15994            assert!(
15995                boundaries.contains(&off),
15996                "Right stopped at {off}, inside a grapheme cluster"
15997            );
15998        }
15999    }
16000
16001    #[test]
16002    fn wysiwyg_word_motion_stays_out_of_hidden_delimiters() {
16003        // The reproduction: ⌥→ from inside the opening `**` computed its
16004        // boundary over the raw source and landed on byte 8 — inside the
16005        // *closing* `**`, which `caret_pos` draws at column 6, immediately after
16006        // "bold". The caret drew past the bold word and sat inside it.
16007        let mut d = wysiwyg_doc("wys_word_delim", "a **bold** c\n");
16008        d.caret = 2;
16009        d.move_word_right(false);
16010        assert!(
16011            d.vmap.is_stop(d.caret),
16012            "landed at {}, not a caret stop",
16013            d.caret
16014        );
16015        assert_eq!(d.caret, 10, "should land on the space after \"bold\"");
16016        // The rendered row is "a bold c": column 6 is the space just past "bold",
16017        // and now the caret is really there rather than only drawn there.
16018        assert_eq!(d.caret_pos(), (0, 6));
16019
16020        // ...and back again: ⌥← returns to the "b", not into the opening `**`.
16021        d.move_word_left(false);
16022        assert_eq!(d.caret, 4);
16023        assert_eq!(d.caret_pos(), (0, 2));
16024    }
16025
16026    #[test]
16027    fn wysiwyg_word_delete_takes_the_markup_with_the_word() {
16028        // The reproduction: ⌥⌫ from after "bold" walked the raw source, stopped
16029        // inside the closing `**`, and left "a ** c\n" — delimiters with no
16030        // opener. Glyph space covers the word alone, which would leave
16031        // "a **** c": markup wrapped around nothing. The word and the styling
16032        // that was only ever the word's go together.
16033        let mut d = wysiwyg_doc("wys_word_del_back", "a **bold** c\n");
16034        d.caret = 10;
16035        d.delete_word_back();
16036        assert_eq!(d.source, "a  c\n");
16037        assert_eq!(d.caret, 2);
16038
16039        let mut d = wysiwyg_doc("wys_word_del_fwd", "a **bold** c\n");
16040        d.caret = 4; // the "b"
16041        d.delete_word_forward();
16042        assert_eq!(d.source, "a  c\n");
16043    }
16044
16045    #[test]
16046    fn wysiwyg_word_delete_empties_a_nested_mark_and_a_code_span_too() {
16047        let src = "a ***bold*** c\n";
16048        let mut d = wysiwyg_doc("wys_word_del_nest", src);
16049        d.caret = src.find(" c").unwrap();
16050        d.delete_word_back();
16051        assert_eq!(
16052            d.source, "a  c\n",
16053            "the emph inside the strong empties it too"
16054        );
16055
16056        let src = "a `code` c\n";
16057        let mut d = wysiwyg_doc("wys_word_del_code", src);
16058        d.caret = src.find(" c").unwrap();
16059        d.delete_word_back();
16060        assert_eq!(d.source, "a  c\n");
16061    }
16062
16063    #[test]
16064    fn wysiwyg_word_delete_keeps_a_mark_that_still_has_text() {
16065        // Only an *emptied* node goes. Take one word of two and the `**` still
16066        // has a job to do — over the word that's left, with the space the delete
16067        // pushed against the opening delimiter moved out in front of it, or the
16068        // run would be no run at all (`** words**` is literal asterisks — see
16069        // the mark-edge rule on `splice`).
16070        let src = "a **two words** c\n";
16071        let mut d = wysiwyg_doc("wys_word_del_partial", src);
16072        d.caret = src.find(" words").unwrap();
16073        d.delete_word_back();
16074        assert_eq!(d.source, "a  **words** c\n");
16075    }
16076
16077    #[test]
16078    fn source_view_word_motion_still_walks_the_markup() {
16079        // The other half of the decision: in the source view the `**` are
16080        // characters like any other — they're on the screen, so word motion has
16081        // to stop at them and a word-delete has to leave them behind. Only
16082        // WYSIWYG hides them, so only WYSIWYG steps over them.
16083        let g = |n, m, f: fn(&mut Doc)| golden(n, m, f);
16084        assert_eq!(
16085            g("src_word_motion", "a |**bold** c\n", |d| d
16086                .move_word_right(false)),
16087            "a **bold|** c\n"
16088        );
16089        // The same caret as the WYSIWYG reproduction, and the opposite outcome:
16090        // here "a ** c\n" is right, because `bold**` is what's to the left of it.
16091        assert_eq!(
16092            g("src_word_del", "a **bold**| c\n", |d| d.delete_word_back()),
16093            "a **| c\n"
16094        );
16095    }
16096
16097    #[test]
16098    fn every_wysiwyg_motion_lands_on_a_caret_stop() {
16099        // The single invariant both bugs violated: the caret draws and edits at
16100        // the same place only when it's on a stop. `debug_assert_on_a_stop`
16101        // makes the same claim in-place; this pins it from the outside, over a
16102        // document with every kind of thing the map has to be careful about.
16103        // At two widths: the wide one every other test builds at, where no
16104        // fixture folds, and one narrow enough that they all do. A soft wrap is
16105        // where an offset stops being on exactly one row, and testing only the
16106        // width that never wraps is how the caret came to be pinned at the first
16107        // one Down reached.
16108        let src = "# Title\n\na **bold** e\u{0301}mo👨‍👩‍👧ji `x` c\n\n\
16109                   - item one\n\n| A | B |\n|---|---|\n| x | y |\n";
16110        // A table of named operations, which is what it looks like.
16111        #[allow(clippy::type_complexity)]
16112        let motions: [(&str, fn(&mut Doc)); 8] = [
16113            ("right", |d| d.move_right(false)),
16114            ("left", |d| d.move_left(false)),
16115            ("word_right", |d| d.move_word_right(false)),
16116            ("word_left", |d| d.move_word_left(false)),
16117            ("down", |d| d.move_down(false)),
16118            ("up", |d| d.move_up(false)),
16119            ("home", |d| d.move_home(false)),
16120            ("end", |d| d.move_end(false)),
16121        ];
16122        for width in [80, 12] {
16123            let mut d = wysiwyg_doc("stop_invariant", src);
16124            d.build_visual(width);
16125            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
16126            assert!(stops.len() > 20, "fixture should have plenty of stops");
16127            for start in stops {
16128                for (name, motion) in &motions {
16129                    d.caret = start;
16130                    d.anchor = None;
16131                    motion(&mut d);
16132                    assert!(
16133                        d.vmap.is_stop(d.caret),
16134                        "{name} from {start} at width {width} landed at {} — not a caret stop",
16135                        d.caret
16136                    );
16137                }
16138            }
16139        }
16140    }
16141
16142    #[test]
16143    fn no_wysiwyg_motion_is_a_dead_end() {
16144        // Down held to the bottom of a document reaches the bottom, and Up held
16145        // to the top reaches the top — from anywhere, at a width that wraps. The
16146        // invariant above says a motion lands somewhere legal; this one says it
16147        // gets somewhere at all, which is what a caret pinned at a wrap boundary
16148        // was quietly failing to do while every assertion around it held.
16149        let src = "# Title\n\none two three four five six seven eight nine ten\n\n\
16150                   - item one two three four five\n\nlast\n";
16151        for width in [80, 12] {
16152            let mut d = wysiwyg_doc("no_dead_end", src);
16153            d.build_visual(width);
16154            let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
16155            let (first, last) = (stops[0], stops[stops.len() - 1]);
16156            for &start in &stops {
16157                for (name, motion, want) in [
16158                    (
16159                        "down",
16160                        (|d: &mut Doc| d.move_down(false)) as fn(&mut Doc),
16161                        last,
16162                    ),
16163                    ("up", |d: &mut Doc| d.move_up(false), first),
16164                ] {
16165                    d.caret = start;
16166                    d.anchor = None;
16167                    d.goal_col = None;
16168                    // Every row, plus the presses the edges take, plus slack.
16169                    for _ in 0..d.vmap.num_rows() + 4 {
16170                        motion(&mut d);
16171                    }
16172                    assert_eq!(
16173                        d.caret, want,
16174                        "{name} held from {start} at width {width} never arrived"
16175                    );
16176                }
16177            }
16178        }
16179    }
16180    // ── display columns ──────────────────────────────────────────────────────
16181    // A `col` is a terminal cell, not a character. The two are the same number
16182    // for the ASCII the fixtures above are written in, which is how they came
16183    // apart in the first place: `你` is one character drawn in two cells, so a
16184    // column counted in characters names a cell the text isn't in — one earlier
16185    // for every wide character to its left.
16186
16187    #[test]
16188    fn a_wide_character_is_two_columns_wide() {
16189        // The reproduction: `你` is one char and two cells, so the caret just
16190        // past it drew at column 1 — inside the character it had already left.
16191        for (view, tag) in VIEWS {
16192            let mut d = doc_in(view, &format!("wide_col_{tag}"), "你好\n");
16193            d.caret = "你".len();
16194            assert_eq!(d.caret_pos(), (0, 2), "{tag}: caret drew inside 你");
16195            d.caret = "你好".len();
16196            assert_eq!(d.caret_pos(), (0, 4), "{tag}");
16197        }
16198    }
16199
16200    #[test]
16201    fn a_cluster_is_as_wide_as_it_is_drawn_not_as_its_codepoints_measure() {
16202        // `👨‍👩‍👧` is five codepoints — two-cell, joiner, two-cell, joiner,
16203        // two-cell — measuring six cells one at a time, but the character they
16204        // spell is drawn in two. Width belongs to the cluster, not the glyph,
16205        // and the frontends measure it the same way.
16206        let family = "👨‍👩‍👧";
16207        for (view, tag) in VIEWS {
16208            let src = format!("a{family}b\n");
16209            let mut d = doc_in(view, &format!("wide_cluster_{tag}"), &src);
16210            d.caret = 1 + family.len();
16211            assert_eq!(
16212                d.caret_pos(),
16213                (0, 3),
16214                "{tag}: 'a' is one cell, the family two"
16215            );
16216        }
16217    }
16218
16219    #[test]
16220    fn both_cells_of_a_wide_character_mean_the_character() {
16221        // Clicking the far half of `好` is still clicking `好`: half a character
16222        // is not a place the caret can be, so it comes to rest at the
16223        // character's start — the column it would have been drawn at anyway.
16224        for (view, tag) in VIEWS {
16225            let mut d = doc_in(view, &format!("wide_click_{tag}"), "你好\n");
16226            for col in [2, 3] {
16227                d.caret = 0;
16228                d.click(0, col, false);
16229                assert_eq!(d.caret, "你".len(), "{tag}: click at col {col}");
16230                assert_eq!(d.caret_pos(), (0, 2), "{tag}: click at col {col}");
16231            }
16232            // Past the last cell is the line's end, as it is for ASCII.
16233            d.click(0, 9, false);
16234            assert_eq!(d.caret, "你好".len(), "{tag}: click past the end");
16235        }
16236    }
16237
16238    #[test]
16239    fn every_offset_survives_the_trip_out_to_a_column_and_back() {
16240        // The mapping is only a mapping if it inverts: the cell the caret is
16241        // drawn in has to be the cell that brings it back to the same offset.
16242        // Over a fixture where a character may be one cell or two, and one
16243        // codepoint or five.
16244        use unicode_segmentation::UnicodeSegmentation;
16245
16246        let src = "ab 你好 c\n\n👨‍👩‍👧 e\u{0301}x 漢字\n\nplain ascii\n";
16247
16248        let mut d = doc_in(View::Source, "roundtrip_source", src);
16249        // Every offset the source view's caret can occupy: it steps by grapheme
16250        // cluster, so those are its boundaries.
16251        for (off, _) in src
16252            .grapheme_indices(true)
16253            .chain(std::iter::once((src.len(), "")))
16254        {
16255            d.caret = off;
16256            let (row, col) = d.caret_pos();
16257            d.click(row, col, false);
16258            assert_eq!(d.caret, off, "source: {off} → ({row}, {col}) → {}", d.caret);
16259        }
16260
16261        // And in WYSIWYG, where the offsets the caret can occupy are the map's
16262        // stops rather than every boundary.
16263        let mut d = doc_in(View::Wysiwyg, "roundtrip_wysiwyg", src);
16264        let stops: Vec<usize> = (0..=src.len()).filter(|&o| d.vmap.is_stop(o)).collect();
16265        assert!(stops.len() > 20, "fixture should have plenty of stops");
16266        for off in stops {
16267            d.caret = off;
16268            let (row, col) = d.caret_pos();
16269            d.click(row, col, false);
16270            assert_eq!(
16271                d.caret, off,
16272                "wysiwyg: {off} → ({row}, {col}) → {}",
16273                d.caret
16274            );
16275        }
16276    }
16277
16278    #[test]
16279    fn vertical_motion_aims_at_a_column_the_reader_can_see() {
16280        // Down from under `世` lands under the glyph in that cell, not two
16281        // characters further along the line. The goal is a column, so a line of
16282        // wide characters and a line of ASCII line up the way they're drawn.
16283        //
16284        // The gap differs by view: a bare newline inside a paragraph is a soft
16285        // break, which WYSIWYG draws as a space on a single row. The views share
16286        // a grid only where the source's lines are the renderer's rows too.
16287        for (view, tag) in VIEWS {
16288            let gap = if view == View::Source { "\n" } else { "\n\n" };
16289            let src = format!("你好世{gap}abcdef\n");
16290            let mut d = doc_in(view, &format!("goal_wide_{tag}"), &src);
16291            d.caret = "你好".len();
16292            assert_eq!(d.caret_pos().1, 4, "{tag}: `世` is drawn at column 4");
16293            d.move_down(false);
16294            assert_eq!(d.caret_pos().1, 4, "{tag}: goal column lost");
16295            assert!(
16296                d.source[d.caret..].starts_with('e'),
16297                "{tag}: landed on the wrong glyph"
16298            );
16299        }
16300    }
16301
16302    #[test]
16303    fn a_goal_column_landing_inside_a_wide_character_lands_on_it() {
16304        // Down from column 3 onto `你好`, whose characters start at columns 0
16305        // and 2: column 3 is the *second* cell of `好`. There is nowhere to be
16306        // between the cells of one character, so the caret rests on it — and on
16307        // its start, which is the only offset there that is a caret stop.
16308        for (view, tag) in VIEWS {
16309            let gap = if view == View::Source { "\n" } else { "\n\n" };
16310            let src = format!("abcdef{gap}你好\n");
16311            let mut d = doc_in(view, &format!("goal_inside_{tag}"), &src);
16312            let line = src.find('你').unwrap();
16313            d.caret = 3;
16314            d.move_down(false);
16315            assert_eq!(d.caret, line + "你".len(), "{tag}: landed off `好`'s start");
16316            assert_eq!(d.caret_pos().1, 2, "{tag}: drew between `好`'s cells");
16317        }
16318    }
16319
16320    #[test]
16321    fn a_caret_in_a_table_cell_of_wide_text_draws_where_the_text_is() {
16322        // The column the cell's text is laid out in is measured in cells, so the
16323        // caret walking that text has to be too — the two agreeing is the whole
16324        // point of the grid staying square.
16325        let mut d = wysiwyg_doc("table_wide", "| A | B |\n|---|---|\n| 你好 | y |\n");
16326        let at = d.source.find("你").unwrap();
16327        d.caret = at;
16328        let (row, col) = d.caret_pos();
16329        // `│ ` opens the row, so the cell's text starts at column 2; `好` is two
16330        // cells further along.
16331        assert_eq!(col, 2, "the cell's first character");
16332        d.move_right(false);
16333        assert_eq!(
16334            d.caret_pos(),
16335            (row, 4),
16336            "`好` is drawn past `你`'s two cells"
16337        );
16338        assert_eq!(d.caret, at + "你".len());
16339    }
16340
16341    // ── active inline marks ───────────────────────────────────────────────────
16342
16343    /// The marks at a `|`-marked fixture's caret, in `InlineMarks::iter` order.
16344    fn marks(view: View, name: &str, marked: &str) -> Vec<InlineKind> {
16345        let (src, caret) = parse_caret(marked);
16346        let mut d = doc_in(view, name, &src);
16347        d.caret = caret;
16348        d.active_inline_marks().iter().collect()
16349    }
16350
16351    /// The marks over the selection `[start, end)`.
16352    fn marks_over(view: View, name: &str, src: &str, start: usize, end: usize) -> Vec<InlineKind> {
16353        let mut d = doc_in(view, name, src);
16354        d.anchor = Some(start);
16355        d.caret = end;
16356        d.active_inline_marks().iter().collect()
16357    }
16358
16359    #[test]
16360    fn a_caret_in_a_mark_reports_it() {
16361        for (view, tag) in VIEWS {
16362            let m = |marked| marks(view, &format!("marks_in_{tag}"), marked);
16363            assert_eq!(m("a **bo|ld** b"), [InlineKind::Strong], "{tag}");
16364            assert_eq!(m("a *it|alic* b"), [InlineKind::Emph], "{tag}");
16365            assert_eq!(m("a `co|de` b"), [InlineKind::Verbatim], "{tag}");
16366            // Plain text under no mark lights nothing — the toolbar's resting state.
16367            assert_eq!(m("a| **bold** b"), [], "{tag}");
16368            assert!(m("plain t|ext").is_empty(), "{tag}");
16369        }
16370    }
16371
16372    #[test]
16373    fn nested_marks_all_report() {
16374        // Bold *and* italic: a toolbar lights both buttons, so the set has both —
16375        // the ancestor chain is a chain, and every mark on it is in force.
16376        for (view, tag) in VIEWS {
16377            assert_eq!(
16378                marks(
16379                    view,
16380                    &format!("marks_nested_{tag}"),
16381                    "**bold and *bo|th*** end"
16382                ),
16383                [InlineKind::Strong, InlineKind::Emph],
16384                "{tag}"
16385            );
16386        }
16387    }
16388
16389    #[test]
16390    fn the_caret_at_a_marks_edge_reports_it_where_typing_would_extend_it() {
16391        // The offsets a WYSIWYG caret actually reaches at a bold run's edges are
16392        // the first byte of its text and the byte after its last — both inside
16393        // the mark's span, both places typing lands inside the bold. The offset
16394        // past the closing delimiter is the next text, and reports nothing.
16395        let src = "a **bold** b";
16396        let inner_start = src.find("bold").unwrap(); // 4
16397        let inner_end = inner_start + "bold".len(); // 8, on the closing `**`
16398        for (view, tag) in VIEWS {
16399            let mut d = doc_in(view, &format!("marks_edge_{tag}"), src);
16400            for off in [2, 3, inner_start, inner_end, 9] {
16401                d.caret = off;
16402                assert!(
16403                    d.active_inline_marks().contains(InlineKind::Strong),
16404                    "{tag}: offset {off} is inside the strong span"
16405                );
16406            }
16407            for off in [0, 1, 10, 11, 12] {
16408                d.caret = off;
16409                assert!(
16410                    !d.active_inline_marks().contains(InlineKind::Strong),
16411                    "{tag}: offset {off} is outside the strong run"
16412                );
16413            }
16414        }
16415    }
16416
16417    #[test]
16418    fn a_mark_ends_the_same_way_at_the_end_of_the_buffer_as_in_the_middle() {
16419        // Regression: twig resolves an offset that is one node's end and the
16420        // next one's start to the node that *starts* there, so `**bold**|\n`
16421        // isn't bold. With nothing following there's no tie to break and the
16422        // chain still ended at the mark, which made a trailing `\n` — not the
16423        // text — decide whether the caret after a bold word reported bold. It's
16424        // the offset past the mark either way, and typing there is plain either
16425        // way. A blank document typed into is exactly this shape.
16426        for (view, tag) in VIEWS {
16427            let m = |name: String, marked| marks(view, &name, marked);
16428            assert_eq!(
16429                m(format!("marks_eob_{tag}"), "**bold**|"),
16430                [],
16431                "{tag}: no trailing newline"
16432            );
16433            assert_eq!(
16434                m(format!("marks_eol_{tag}"), "**bold**|\n"),
16435                [],
16436                "{tag}: with one"
16437            );
16438            // And the last offset that *is* in the mark still is.
16439            assert_eq!(
16440                m(format!("marks_eob_in_{tag}"), "**bold*|*"),
16441                [InlineKind::Strong],
16442                "{tag}"
16443            );
16444        }
16445    }
16446
16447    #[test]
16448    fn a_selection_reports_a_mark_only_when_it_covers_the_whole_thing() {
16449        let src = "a **bold** b";
16450        let (b, d_) = (src.find("bold").unwrap(), src.find("bold").unwrap() + 4);
16451        for (view, tag) in VIEWS {
16452            let m = |s, e| marks_over(view, &format!("marks_sel_{tag}"), src, s, e);
16453            // The whole bold word, and a slice of it.
16454            assert_eq!(m(b, d_), [InlineKind::Strong], "{tag}: the whole word");
16455            assert_eq!(m(b + 1, d_ - 1), [InlineKind::Strong], "{tag}: a slice");
16456            // Ending exactly at the closing delimiter's start is still all-bold:
16457            // an exclusive end sits *past* the last selected character, so the
16458            // question is asked of the character, not the boundary.
16459            assert_eq!(
16460                m(b, d_ + 2),
16461                [InlineKind::Strong],
16462                "{tag}: through the close"
16463            );
16464            // Half in, half out: Bold lit here would claim a press turns it off.
16465            assert_eq!(m(0, d_), [], "{tag}: leading plain text");
16466            assert_eq!(m(b, src.len()), [], "{tag}: trailing plain text");
16467        }
16468    }
16469
16470    #[test]
16471    fn a_selection_across_two_runs_of_the_same_mark_reports_nothing() {
16472        // Both ends are bold, but the space between them isn't — two runs are two
16473        // nodes, which is exactly what the node id catches and a kind-only
16474        // comparison would not.
16475        let src = "**one** **two**";
16476        for (view, tag) in VIEWS {
16477            let m = marks_over(view, &format!("marks_runs_{tag}"), src, 2, 13);
16478            assert_eq!(m, [], "{tag}: `one** **two` is not all bold");
16479        }
16480    }
16481
16482    #[test]
16483    fn marks_read_the_document_as_it_is_edited() {
16484        // The point of asking twig every frame instead of caching: the answer has
16485        // to follow the toggle that changed it.
16486        let mut d = wysiwyg_doc("marks_live", "one two\n");
16487        d.anchor = Some(0);
16488        d.caret = 3;
16489        assert!(d.active_inline_marks().is_empty(), "plain to start");
16490        d.toggle(InlineKind::Strong);
16491        assert_eq!(d.source, "**one** two\n");
16492        // `toggle` leaves the bolded text selected, so the button it lit stays lit.
16493        assert!(d.active_inline_marks().contains(InlineKind::Strong));
16494        d.toggle(InlineKind::Strong);
16495        assert!(d.active_inline_marks().is_empty(), "and off again");
16496    }
16497
16498    #[test]
16499    fn a_link_is_not_an_inline_mark() {
16500        // `link`/`str` are inline nodes, but nothing on the inline toolbar
16501        // toggles them — a set with a "link mark" in it would have no button.
16502        for (view, tag) in VIEWS {
16503            assert_eq!(
16504                marks(view, &format!("marks_link_{tag}"), "a [te|xt](u) b"),
16505                [],
16506                "{tag}"
16507            );
16508        }
16509    }
16510
16511    // ── blank documents ───────────────────────────────────────────────────────
16512
16513    #[test]
16514    fn a_blank_document_is_untitled_empty_and_markdown() {
16515        let mut d = Doc::blank().unwrap();
16516        assert!(d.is_untitled());
16517        assert_eq!(d.path, PathBuf::new());
16518        assert_eq!(
16519            d.file_name(),
16520            "untitled",
16521            "the header has to show something"
16522        );
16523        assert_eq!(d.format_name(), "markdown");
16524        assert_eq!(d.source, "");
16525        assert!(!d.dirty, "nothing typed yet is nothing to lose");
16526        assert_eq!(d.disk_state(), DiskState::Untitled);
16527        // And it's a document you can be in: the default view renders it.
16528        d.build_visual(80);
16529        assert_eq!(d.caret, 0);
16530    }
16531
16532    #[test]
16533    fn saving_an_untitled_document_asks_for_a_name_instead_of_writing() {
16534        let mut d = Doc::blank().unwrap();
16535        d.insert("hello");
16536        assert!(d.dirty);
16537        d.save();
16538        assert_eq!(d.status.as_deref(), Some("untitled — save as…"));
16539        assert!(d.dirty, "it must not come away believing it saved");
16540        assert!(d.is_untitled(), "and it still has no file");
16541    }
16542
16543    #[test]
16544    fn a_blank_document_becomes_a_real_one_at_the_first_save_as() {
16545        let p = temp_path("blank_save_as");
16546        let mut d = Doc::blank().unwrap();
16547        // Plain text — a blank doc opens in Hidden mode, where a typed `#` would
16548        // be kept literal (`\#`); this test is about save-as, not escaping (which
16549        // has its own test), so it types nothing that escaping would touch.
16550        d.insert("hi");
16551        d.save_as(p.clone());
16552        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi");
16553        assert!(!d.is_untitled());
16554        assert!(!d.dirty);
16555        assert_eq!(d.file_name(), p.file_name().unwrap().to_string_lossy());
16556        assert_eq!(
16557            d.disk_state(),
16558            DiskState::Unchanged,
16559            "the watermark is stamped"
16560        );
16561        // And ⌘S is a plain save from here on.
16562        d.insert("!");
16563        d.save();
16564        assert_eq!(std::fs::read_to_string(&p).unwrap(), "hi!");
16565        let _ = std::fs::remove_file(&p);
16566    }
16567
16568    // ── a file that isn't there yet ───────────────────────────────────────────
16569
16570    /// A unique path in the temp dir with the given extension, guaranteed not to
16571    /// exist — what `leaf notes.md` is handed when the file has never been made.
16572    fn missing_path(name: &str, ext: &str) -> PathBuf {
16573        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
16574        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
16575        let mut p = std::env::temp_dir();
16576        p.push(format!("leaf_test_new_{name}_{seq}.{ext}"));
16577        let _ = std::fs::remove_file(&p);
16578        p
16579    }
16580
16581    #[test]
16582    fn a_file_that_doesnt_exist_opens_as_an_empty_named_document() {
16583        let p = missing_path("named", "md");
16584        let mut d = Doc::open_or_create(p.clone()).unwrap();
16585
16586        assert_eq!(d.source, "", "nothing was read, so there's nothing in it");
16587        assert!(!d.dirty, "an untouched new buffer has nothing to lose");
16588        assert!(
16589            !d.is_untitled(),
16590            "it has the name the user asked for — ^S must not detour to Save As"
16591        );
16592        assert_eq!(d.file_name(), p.file_name().unwrap().to_str().unwrap());
16593        assert!(d.path.is_absolute(), "the same absolute path `open` stores");
16594        assert!(!p.exists(), "and opening it wrote nothing");
16595        // And it's a document you can be in.
16596        d.build_visual(80);
16597        assert_eq!(d.caret, 0);
16598    }
16599
16600    #[test]
16601    fn a_new_file_is_created_by_its_first_save() {
16602        let p = missing_path("first_save", "md");
16603        let mut d = Doc::open_or_create(p.clone()).unwrap();
16604        d.insert("hello\n");
16605        assert!(d.dirty);
16606        d.save();
16607
16608        assert_eq!(
16609            std::fs::read_to_string(&p).unwrap(),
16610            "hello\n",
16611            "a plain ^S wrote it — no Save As, no name to invent"
16612        );
16613        assert!(!d.dirty);
16614        assert_eq!(d.disk_state(), DiskState::Unchanged);
16615        let _ = std::fs::remove_file(&p);
16616    }
16617
16618    #[test]
16619    fn a_new_file_takes_its_format_from_the_extension() {
16620        // The one thing `blank` can't do: with no name it has to assume Markdown,
16621        // and typing djot into a Markdown parse is the wrong buffer.
16622        let dj = missing_path("format", "dj");
16623        assert_eq!(Doc::open_or_create(dj).unwrap().format_name(), "djot");
16624        let md = missing_path("format", "md");
16625        assert_eq!(Doc::open_or_create(md).unwrap().format_name(), "markdown");
16626    }
16627
16628    #[test]
16629    fn a_new_file_reports_itself_missing_until_it_is_saved() {
16630        // Not `Untitled` — that's the answer for a document with no path, and it
16631        // would tell a frontend there is nothing a save could collide with. Here
16632        // there is a path, and the file simply isn't at it yet.
16633        let p = missing_path("disk_state", "md");
16634        let mut d = Doc::open_or_create(p.clone()).unwrap();
16635        assert_eq!(d.disk_state(), DiskState::Missing);
16636
16637        // Somebody else creates it while the buffer is open: that's an overwrite
16638        // the frontend has to be able to prompt about, exactly as for an opened
16639        // file. Their bytes, not ours, so `Changed`.
16640        std::fs::write(&p, "theirs\n").unwrap();
16641        assert_eq!(d.disk_state(), DiskState::Changed);
16642
16643        // Saving makes the file ours and re-stamps the watermark.
16644        d.insert("ours\n");
16645        d.save();
16646        assert_eq!(d.disk_state(), DiskState::Unchanged);
16647        assert_eq!(std::fs::read_to_string(&p).unwrap(), "ours\n");
16648        let _ = std::fs::remove_file(&p);
16649    }
16650
16651    #[test]
16652    fn open_or_create_still_opens_a_file_that_is_there() {
16653        let d = doc_with("open_or_create_existing", "body\n");
16654        let reopened = Doc::open_or_create(d.path.clone()).unwrap();
16655        assert_eq!(reopened.source, "body\n");
16656        assert_eq!(reopened.disk_state(), DiskState::Unchanged);
16657    }
16658
16659    #[test]
16660    fn a_missing_file_with_no_readable_extension_is_still_an_error() {
16661        // A mistyped flag or a stray argument must not become a buffer promising
16662        // to save somewhere — the same refusal `open` gives a real file.
16663        let mut p = std::env::temp_dir();
16664        p.push("leaf_test_new_bad_ext.wat");
16665        assert!(Doc::open_or_create(p).is_err());
16666        let mut none = std::env::temp_dir();
16667        none.push("leaf_test_new_no_ext");
16668        assert!(Doc::open_or_create(none).is_err());
16669    }
16670
16671    #[test]
16672    fn a_new_file_in_a_directory_that_doesnt_exist_opens_but_wont_save() {
16673        // Opening reads nothing, so there is nothing to fail on yet; the write is
16674        // where it fails, and it says so rather than claiming a save.
16675        let p = std::env::temp_dir().join("leaf_test_no_such_dir_c41/doc.md");
16676        let mut d = Doc::open_or_create(p).unwrap();
16677        d.insert("x");
16678        d.save();
16679        assert!(
16680            d.status.as_deref().unwrap().starts_with("save failed:"),
16681            "got {:?}",
16682            d.status
16683        );
16684        assert!(d.dirty, "it must not come away believing it saved");
16685    }
16686
16687    // ── save as ───────────────────────────────────────────────────────────────
16688
16689    /// A unique path in the temp dir that no fixture wrote — a Save As target.
16690    fn temp_path(name: &str) -> PathBuf {
16691        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
16692        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
16693        let mut p = std::env::temp_dir();
16694        p.push(format!("leaf_test_target_{name}_{seq}.md"));
16695        let _ = std::fs::remove_file(&p);
16696        p
16697    }
16698
16699    #[test]
16700    fn save_as_moves_the_document_and_leaves_the_old_file_alone() {
16701        let mut d = doc_with("save_as_move", "original\n");
16702        let old = d.path.clone();
16703        let new = temp_path("save_as_move");
16704        d.insert("edited: ");
16705        d.save_as(new.clone());
16706
16707        assert_eq!(std::fs::read_to_string(&new).unwrap(), "edited: original\n");
16708        assert_eq!(
16709            std::fs::read_to_string(&old).unwrap(),
16710            "original\n",
16711            "Save As doesn't touch the file it came from"
16712        );
16713        assert_eq!(d.path, new, "the document moved");
16714        assert!(!d.dirty);
16715        assert_eq!(
16716            d.status.as_deref(),
16717            Some(&*format!("saved {}", d.file_name()))
16718        );
16719
16720        // Every later save follows it, which is the whole difference from a copy.
16721        d.caret = 0;
16722        d.insert("re-");
16723        d.save();
16724        assert_eq!(
16725            std::fs::read_to_string(&new).unwrap(),
16726            "re-edited: original\n"
16727        );
16728        assert_eq!(std::fs::read_to_string(&old).unwrap(), "original\n");
16729        let _ = std::fs::remove_file(&new);
16730    }
16731
16732    #[test]
16733    fn save_as_overwrites_an_existing_target() {
16734        // The picker already asked; asking again down here is the same question
16735        // twice, and the second one has no way to be answered.
16736        let new = temp_path("save_as_over");
16737        std::fs::write(&new, "theirs\n").unwrap();
16738        let mut d = doc_with("save_as_over", "ours\n");
16739        d.save_as(new.clone());
16740        assert_eq!(std::fs::read_to_string(&new).unwrap(), "ours\n");
16741        let _ = std::fs::remove_file(&new);
16742    }
16743
16744    #[test]
16745    fn a_save_as_that_fails_leaves_the_document_where_it_was() {
16746        let mut d = doc_with("save_as_fail", "body\n");
16747        let old = d.path.clone();
16748        d.insert("x");
16749        // A directory that doesn't exist: the write can't land.
16750        let bad = std::env::temp_dir().join("leaf_test_no_such_dir_9f2/doc.md");
16751        d.save_as(bad);
16752
16753        assert_eq!(
16754            d.path, old,
16755            "the document must not move to a file that isn't there"
16756        );
16757        assert!(d.dirty, "and must not believe it saved");
16758        assert!(
16759            d.status.as_deref().unwrap().starts_with("save failed:"),
16760            "the same failure a plain save reports, got {:?}",
16761            d.status
16762        );
16763        // The original is still the document's file, and still saveable.
16764        d.save();
16765        assert_eq!(std::fs::read_to_string(&old).unwrap(), "xbody\n");
16766        assert!(!d.dirty);
16767    }
16768
16769    #[test]
16770    fn save_as_renames_without_reparsing_the_format() {
16771        // `.dj` on the name doesn't make the buffer djot: it was parsed as
16772        // Markdown and still is, and saying otherwise would be a conversion the
16773        // user never asked for (and an undo history thrown away to do it).
16774        let mut d = doc_with("save_as_format", "**b**\n");
16775        let mut new = temp_path("save_as_format");
16776        new.set_extension("dj");
16777        d.save_as(new.clone());
16778        assert_eq!(d.format_name(), "markdown");
16779        let _ = std::fs::remove_file(&new);
16780    }
16781
16782    // ── external change / reload ──────────────────────────────────────────────
16783
16784    #[test]
16785    fn an_untouched_file_reports_unchanged() {
16786        let mut d = doc_with("disk_clean", "body\n");
16787        assert_eq!(d.disk_state(), DiskState::Unchanged);
16788        // Editing the buffer is not editing the file.
16789        d.insert("x");
16790        assert_eq!(d.disk_state(), DiskState::Unchanged);
16791        assert!(d.dirty);
16792        // Saving re-stamps the watermark rather than reporting our own bytes back.
16793        d.save();
16794        assert_eq!(d.disk_state(), DiskState::Unchanged);
16795    }
16796
16797    #[test]
16798    fn a_file_written_underneath_reports_changed() {
16799        let mut d = doc_with("disk_changed", "body\n");
16800        std::fs::write(&d.path, "someone else\n").unwrap();
16801        assert_eq!(d.disk_state(), DiskState::Changed);
16802        // Dirty *and* changed is the clobber: both halves are readable, and
16803        // leaf-core takes neither side.
16804        d.insert("x");
16805        assert!(d.dirty && d.disk_state() == DiskState::Changed);
16806        // Saving anyway is allowed — the frontend asked, or chose not to.
16807        d.save();
16808        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "xbody\n");
16809        assert_eq!(d.disk_state(), DiskState::Unchanged);
16810    }
16811
16812    #[test]
16813    fn a_file_rewritten_with_the_same_bytes_is_unchanged() {
16814        // The hash is what makes this honest: the file was written (a fresh
16815        // mtime), and nothing about the document is stale.
16816        let d = doc_with("disk_same_bytes", "body\n");
16817        std::fs::write(&d.path, "body\n").unwrap();
16818        assert_eq!(d.disk_state(), DiskState::Unchanged);
16819    }
16820
16821    #[test]
16822    fn a_deleted_file_reports_missing() {
16823        let mut d = doc_with("disk_missing", "body\n");
16824        std::fs::remove_file(&d.path).unwrap();
16825        assert_eq!(d.disk_state(), DiskState::Missing);
16826        // A save recreates it, and the document is whole again.
16827        d.save();
16828        assert_eq!(d.disk_state(), DiskState::Unchanged);
16829        assert_eq!(std::fs::read_to_string(&d.path).unwrap(), "body\n");
16830    }
16831
16832    #[test]
16833    fn reload_replaces_the_document_with_the_file() {
16834        for (view, tag) in VIEWS {
16835            let mut d = doc_in(view, &format!("reload_{tag}"), "one\n\ntwo\n");
16836            d.insert("edited ");
16837            assert!(d.dirty);
16838            std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
16839            d.reload();
16840
16841            assert_eq!(d.source, "one\n\ntwo\n\nthree\n", "{tag}");
16842            assert!(!d.dirty, "{tag}: the file is what we have");
16843            assert_eq!(d.disk_state(), DiskState::Unchanged, "{tag}");
16844            assert_eq!(
16845                d.status.as_deref(),
16846                Some(&*format!("reloaded {}", d.file_name()))
16847            );
16848            // The reloaded tree is live, not the old parse.
16849            d.caret = d.source.find("three").unwrap();
16850            assert_eq!(d.breadcrumb(), "doc › para › str", "{tag}");
16851        }
16852    }
16853
16854    #[test]
16855    fn reload_clamps_the_caret_and_drops_the_selection() {
16856        let mut d = doc_with("reload_caret", "a long first line\n");
16857        d.caret = 12;
16858        d.anchor = Some(4);
16859        std::fs::write(&d.path, "short\n").unwrap();
16860        d.reload();
16861        assert_eq!(d.caret, d.source.len(), "clamped into the shorter file");
16862        assert_eq!(
16863            d.anchor, None,
16864            "a selection over bytes that changed is a lie"
16865        );
16866        assert!(d.selection().is_none());
16867
16868        // A caret the file still has room for stays put.
16869        let mut d = doc_with("reload_caret_keep", "one\n\ntwo\n");
16870        d.caret = 2;
16871        std::fs::write(&d.path, "one\n\ntwo\n\nthree\n").unwrap();
16872        d.reload();
16873        assert_eq!(d.caret, 2);
16874    }
16875
16876    /// A silent reload is something that happened *to* a reader — a formatter,
16877    /// a `git checkout` — so it has to be undoable like anything else that
16878    /// changes the document, and undoable as one step rather than as however
16879    /// many the file happens to differ by.
16880    #[test]
16881    fn reload_is_one_undo_step_and_keeps_the_history_under_it() {
16882        let mut d = doc_with("reload_undo", "body\n");
16883        d.insert("x");
16884        assert_eq!(d.source, "xbody\n");
16885        std::fs::write(&d.path, "replaced\n").unwrap();
16886        d.reload();
16887        assert_eq!(d.source, "replaced\n");
16888        assert!(!d.dirty, "a reload lands clean");
16889
16890        // One ^Z takes the whole swap off, and hands back the unsaved work it
16891        // replaced — which is unsaved again, because the file no longer says it.
16892        d.undo();
16893        assert_eq!(d.source, "xbody\n", "the reload comes off in one step");
16894        assert!(d.dirty, "and what it comes back to is unsaved");
16895        // …and the history under it is still there.
16896        d.undo();
16897        assert_eq!(
16898            d.source, "body\n",
16899            "the typing before the reload undoes too"
16900        );
16901        // Redo walks back up through the reload.
16902        d.redo();
16903        d.redo();
16904        assert_eq!(d.source, "replaced\n");
16905    }
16906
16907    /// A file rewritten with the bytes it already had is not an edit, so it
16908    /// must not leave an undo step behind for something nobody did.
16909    #[test]
16910    fn reloading_identical_bytes_pushes_no_undo_step() {
16911        let mut d = doc_with("reload_same", "body\n");
16912        d.insert("x");
16913        std::fs::write(&d.path, "xbody\n").unwrap();
16914        d.reload();
16915        assert_eq!(d.source, "xbody\n");
16916        assert!(!d.dirty, "the file now says what the buffer does");
16917        d.undo();
16918        assert_eq!(
16919            d.source, "body\n",
16920            "one step back is the typing, not a no-op"
16921        );
16922    }
16923
16924    #[test]
16925    fn a_reload_that_cant_read_leaves_the_document_alone() {
16926        let mut d = doc_with("reload_gone", "body\n");
16927        d.insert("x");
16928        std::fs::remove_file(&d.path).unwrap();
16929        d.reload();
16930        assert_eq!(d.source, "xbody\n", "the unsaved work is still here");
16931        assert!(d.dirty);
16932        assert!(
16933            d.status.as_deref().unwrap().starts_with("reload failed:"),
16934            "{:?}",
16935            d.status
16936        );
16937
16938        // And an untitled document has nothing to reload from.
16939        let mut d = Doc::blank().unwrap();
16940        d.insert("typed");
16941        d.reload();
16942        assert_eq!(d.source, "typed");
16943        assert_eq!(d.status.as_deref(), Some("no file to reload"));
16944    }
16945
16946    #[test]
16947    fn a_read_only_document_refuses_every_door() {
16948        let mut d = doc_with("readonly", "one two three\n");
16949        d.insert("x");
16950        assert!(d.dirty, "writable first, so the undo step exists");
16951        d.set_read_only(true);
16952        let before = d.source.clone();
16953        d.insert("y");
16954        d.backspace();
16955        d.undo();
16956        d.redo();
16957        assert_eq!(d.source, before, "no door moved a byte");
16958        d.set_read_only(false);
16959        d.undo();
16960        assert_ne!(d.source, before, "off again, the same doors work");
16961    }
16962
16963    /// The doors that go to twig's own verbs rather than through the splice.
16964    /// Typed text in the rendered view under the default markup mode is the
16965    /// everyday one — it is what a keystroke in leaf-web or the Apple views
16966    /// becomes — and it walked straight past the gate.
16967    #[test]
16968    fn a_read_only_document_refuses_the_doors_around_the_splice() {
16969        let mut d = wysiwyg_doc(
16970            "readonly-doors",
16971            "one two three\n\n| a | b |\n|---|---|\n| c | d |\n",
16972        );
16973        d.set_markup_mode(MarkupMode::None);
16974        d.set_read_only(true);
16975        let before = d.source.clone();
16976        d.place_caret(3, false);
16977        d.insert("y");
16978        d.insert_link("https://example.com");
16979        d.insert_image("a.png", "alt");
16980        d.insert_thematic_break();
16981        d.insert_footnote();
16982        d.place_caret(0, false);
16983        d.place_caret(3, true);
16984        d.toggle(InlineKind::Strong);
16985        d.toggle_heading(2);
16986        d.set_block(BlockKind::Paragraph);
16987        d.toggle_list(false);
16988        d.toggle_blockquote();
16989        d.toggle_task_item();
16990        d.newline();
16991        d.indent();
16992        d.set_code_language("rust");
16993        let in_cell = d.source.find("| c").unwrap() + 2;
16994        d.place_caret(in_cell, false);
16995        assert!(d.caret_in_table(), "the caret is in the grid");
16996        assert!(!d.cell_line_break(), "the cell break reports the refusal");
16997        assert_eq!(d.source, before, "no door moved a byte");
16998        assert!(!d.dirty, "nothing to save");
16999        d.set_read_only(false);
17000        d.place_caret(3, false);
17001        d.insert("y");
17002        assert_ne!(d.source, before, "off again, the same doors work");
17003    }
17004
17005    #[test]
17006    fn a_selection_quote_carries_its_context_on_char_boundaries() {
17007        let mut d = doc_with("quote", "before 你好 exact 世界 after\n");
17008        let start = d.source.find("exact").unwrap();
17009        d.place_caret(start, false);
17010        d.place_caret(start + "exact".len(), true);
17011        let q = d.selection_quote(3).unwrap();
17012        assert_eq!(q.exact, "exact");
17013        assert_eq!(
17014            q.prefix, "你好 ",
17015            "chars, not bytes — the multibyte pair counts as two"
17016        );
17017        assert_eq!(q.suffix, " 世界");
17018        assert_eq!(&d.source[q.start..q.end], "exact");
17019        // At the edges the context clips rather than erring.
17020        d.place_caret(0, false);
17021        d.place_caret(6, true);
17022        let q = d.selection_quote(40).unwrap();
17023        assert_eq!(q.prefix, "");
17024        assert_eq!(q.exact, "before");
17025        // No selection is no quote.
17026        d.place_caret(0, false);
17027        assert!(d.selection_quote(3).is_none());
17028    }
17029
17030    #[test]
17031    fn highlights_are_kept_sorted_and_answer_point_queries() {
17032        let mut d = doc_with("hl", "one two three\n");
17033        d.set_highlights(vec![
17034            Highlight {
17035                start: 8,
17036                end: 13,
17037                id: "b".into(),
17038                color: None,
17039                marker: None,
17040            },
17041            Highlight {
17042                start: 0,
17043                end: 3,
17044                id: "a".into(),
17045                color: Some("#ffe066".into()),
17046                marker: None,
17047            },
17048            Highlight {
17049                start: 5,
17050                end: 5,
17051                id: "empty".into(),
17052                color: None,
17053                marker: None,
17054            },
17055        ]);
17056        assert_eq!(
17057            d.highlights()
17058                .iter()
17059                .map(|h| h.id.as_str())
17060                .collect::<Vec<_>>(),
17061            ["a", "b"],
17062            "sorted by start, the empty range dropped"
17063        );
17064        assert_eq!(d.highlight_at(1).map(|h| h.id.as_str()), Some("a"));
17065        assert_eq!(d.highlight_at(3), None, "end is exclusive");
17066        assert_eq!(d.highlight_at(8).map(|h| h.id.as_str()), Some("b"));
17067        d.set_highlights(Vec::new());
17068        assert!(d.highlights().is_empty(), "a replace is a replace");
17069    }
17070
17071    /// `Highlight::covering` and the cursor over it are what both painters ask
17072    /// per glyph, so they have to answer the same as the scan they replaced —
17073    /// including in the gaps, which is where most glyphs are.
17074    #[test]
17075    fn covering_answers_from_a_sorted_list_without_scanning_all_of_it() {
17076        let hl = |start: usize, end: usize, id: &str| Highlight {
17077            start,
17078            end,
17079            id: id.into(),
17080            color: None,
17081            marker: None,
17082        };
17083        // Disjoint, as search hits are: in a range, in a gap, and past the end.
17084        let hits: Vec<Highlight> = (0..20).map(|i| hl(i * 10, i * 10 + 3, "hit")).collect();
17085        assert_eq!(Highlight::covering(&hits, 0).map(|h| h.start), Some(0));
17086        assert_eq!(Highlight::covering(&hits, 102).map(|h| h.start), Some(100));
17087        assert_eq!(
17088            Highlight::covering(&hits, 105),
17089            None,
17090            "a gap covers nothing"
17091        );
17092        assert_eq!(Highlight::covering(&hits, 103), None, "end is exclusive");
17093        assert_eq!(Highlight::covering(&hits, 9_999), None);
17094        assert_eq!(Highlight::covering(&[], 0), None);
17095
17096        // Nested: first by start, so a hit inside an annotation still resolves
17097        // to the annotation — and the range that stops short doesn't mask it.
17098        let nested = vec![hl(0, 20, "outer"), hl(5, 10, "inner")];
17099        assert_eq!(
17100            Highlight::covering(&nested, 7).map(|h| h.id.as_str()),
17101            Some("outer")
17102        );
17103        assert_eq!(
17104            Highlight::covering(&nested, 15).map(|h| h.id.as_str()),
17105            Some("outer")
17106        );
17107    }
17108
17109    /// The cursor is an optimisation, so the only thing worth asserting is that
17110    /// it is not also a change of answer — at every offset, over a list with a
17111    /// nest in it, walked forwards and then backwards.
17112    #[test]
17113    fn the_highlight_cursor_answers_exactly_what_a_fresh_scan_would() {
17114        let hl = |start: usize, end: usize, id: &str| Highlight {
17115            start,
17116            end,
17117            id: id.into(),
17118            color: None,
17119            marker: None,
17120        };
17121        let mut list = vec![
17122            hl(0, 20, "outer"),
17123            hl(5, 10, "inner"),
17124            hl(30, 33, "hit"),
17125            hl(40, 43, "hit"),
17126        ];
17127        list.sort_by_key(|h| (h.start, h.end));
17128
17129        let mut cursor = HighlightCursor::new(&list);
17130        for offset in 0..50 {
17131            assert_eq!(
17132                cursor.at(offset).map(|h| h.id.as_str()),
17133                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
17134                "cursor disagrees at {offset}"
17135            );
17136        }
17137        // Backwards: the cursor re-seats rather than answering from where it
17138        // had got to, so a painter that revisits a row is still told the truth.
17139        for offset in (0..50).rev() {
17140            assert_eq!(
17141                cursor.at(offset).map(|h| h.id.as_str()),
17142                Highlight::covering(&list, offset).map(|h| h.id.as_str()),
17143                "cursor disagrees walking back at {offset}"
17144            );
17145        }
17146    }
17147
17148    // ── the presentation vocabulary ─────────────────────────────────────────
17149
17150    /// A document in `format`, for the gesture tests that want more than the
17151    /// Markdown `doc_with` writes.
17152    fn fmt_doc(body: &str, format: Format) -> Doc {
17153        Doc::from_source(body.to_string(), format).unwrap()
17154    }
17155
17156    /// Alignment is a block property, so the gesture is `set_block_attrs` on
17157    /// the caret's block whatever is selected — and each format spells it its
17158    /// own way: djot's `{…}` line above the block, a `<div>` around it in
17159    /// Markdown (the format has nowhere else to put it), the tag in HTML.
17160    #[test]
17161    fn set_alignment_spells_the_class_the_format_s_own_way() {
17162        let mut dj = fmt_doc("hello\n", Format::Djot);
17163        dj.caret = 1;
17164        dj.set_alignment(Some(Align::Center));
17165        assert_eq!(dj.source, "{.center}\nhello\n");
17166        assert!(dj.dirty);
17167        assert_eq!(dj.status, None);
17168
17169        let mut md = fmt_doc("hello\n", Format::Markdown);
17170        md.caret = 1;
17171        md.set_alignment(Some(Align::Right));
17172        assert_eq!(md.source, "<div class=\"right\">\n\nhello\n\n</div>\n");
17173
17174        let mut html = fmt_doc("<p>hello</p>\n", Format::Html);
17175        html.caret = html.source.find("hello").unwrap();
17176        html.set_alignment(Some(Align::Justify));
17177        assert_eq!(html.source, "<p class=\"justify\">hello</p>\n");
17178    }
17179
17180    /// Each gesture edits **one key and keeps the rest** — twig's contract is
17181    /// replace-not-merge, so leaf reads the node's attributes, edits its own
17182    /// key out of them, and passes the list back whole. A document from
17183    /// elsewhere passes through the editor unharmed.
17184    #[test]
17185    fn a_presentation_gesture_keeps_every_attribute_it_did_not_write() {
17186        let mut d = fmt_doc(
17187            "{.lead .center #intro data-line-height=\"1.5\"}\nhello\n",
17188            Format::Djot,
17189        );
17190        d.caret = d.source.find("hello").unwrap();
17191        d.set_alignment(Some(Align::Right));
17192        // `center` goes, `lead` stays, and neither the id nor the spacing is
17193        // touched.
17194        // The serializer picks the order; what matters is which keys survive.
17195        assert!(d.source.contains(".lead"), "{:?}", d.source);
17196        assert!(d.source.contains(".right"), "{:?}", d.source);
17197        assert!(!d.source.contains(".center"), "{:?}", d.source);
17198        assert!(d.source.contains("#intro"), "{:?}", d.source);
17199        assert!(
17200            d.source.contains("data-line-height=\"1.5\""),
17201            "{:?}",
17202            d.source
17203        );
17204        assert_eq!(d.alignment_at_caret(), Some(Align::Right));
17205        assert_eq!(
17206            d.line_spacing_at_caret(),
17207            Some(LineHeight::Step(LineSpacing::OneHalf))
17208        );
17209
17210        // And the other way round: the spacing gesture leaves the classes be.
17211        d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17212        assert!(d.source.contains(".lead"), "{:?}", d.source);
17213        assert!(d.source.contains(".right"), "{:?}", d.source);
17214        assert_eq!(
17215            d.line_spacing_at_caret(),
17216            Some(LineHeight::Step(LineSpacing::Double))
17217        );
17218    }
17219
17220    /// Clearing is the same gesture with `None`: the key goes, the tokens leaf
17221    /// owns go out of `class`, and a block left with nothing at all is spelled
17222    /// bare again — in Markdown by unwrapping the div twig wrapped it in.
17223    #[test]
17224    fn none_clears_a_key_and_an_empty_set_unwraps_the_block() {
17225        let mut dj = fmt_doc("{.lead .center}\nhello\n", Format::Djot);
17226        dj.caret = dj.source.find("hello").unwrap();
17227        dj.set_alignment(None);
17228        assert_eq!(dj.source, "{.lead}\nhello\n", "the foreign class stays");
17229        assert_eq!(dj.alignment_at_caret(), None);
17230
17231        let mut bare = fmt_doc("{.center}\nhello\n", Format::Djot);
17232        bare.caret = bare.source.find("hello").unwrap();
17233        bare.set_alignment(None);
17234        assert_eq!(
17235            bare.source, "hello\n",
17236            "the last key takes the line with it"
17237        );
17238
17239        let mut md = fmt_doc("hello\n", Format::Markdown);
17240        md.caret = 1;
17241        md.set_alignment(Some(Align::Center));
17242        assert_eq!(md.source, "<div class=\"center\">\n\nhello\n\n</div>\n");
17243        md.caret = md.source.find("hello").unwrap();
17244        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneFifteen)));
17245        assert_eq!(
17246            md.source, "<div class=\"center\" data-line-height=\"1.15\">\n\nhello\n\n</div>\n",
17247            "the second key rewrites the div rather than nesting a second"
17248        );
17249        md.caret = md.source.find("hello").unwrap();
17250        md.set_alignment(None);
17251        md.caret = md.source.find("hello").unwrap();
17252        md.set_line_spacing(None);
17253        assert_eq!(md.source, "hello\n", "an empty set unwraps the div");
17254    }
17255
17256    /// Size, face and colour are the run's over a selection and the block's
17257    /// with none — so "make this paragraph larger" is a click with the caret in
17258    /// it rather than a select-all first.
17259    #[test]
17260    fn a_run_gesture_wraps_a_selection_and_sets_the_block_without_one() {
17261        // With a selection: a span, in each format's own spelling.
17262        let mut dj = fmt_doc("a big b\n", Format::Djot);
17263        dj.anchor = Some(2);
17264        dj.caret = 5;
17265        dj.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17266        assert_eq!(dj.source, "a [big]{data-size=\"large\"} b\n");
17267        assert_eq!(
17268            dj.font_size_at_caret(),
17269            Some(FontSize::Step(SizeStep::Large))
17270        );
17271
17272        let mut md = fmt_doc("a big b\n", Format::Markdown);
17273        md.anchor = Some(2);
17274        md.caret = 5;
17275        md.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
17276        assert_eq!(md.source, "a <span data-color=\"blue\">big</span> b\n");
17277        assert_eq!(
17278            md.text_color_at_caret(),
17279            Some(TextColor::Named(MarkColor::Blue))
17280        );
17281
17282        // Without one: the caret's block, through the block gesture.
17283        let mut block = fmt_doc("a big b\n", Format::Djot);
17284        block.caret = 3;
17285        block.set_font_family(Some(FontFace::Generic(FontFamily::Monospace)));
17286        assert_eq!(block.source, "{data-font=\"monospace\"}\na big b\n");
17287        assert_eq!(
17288            block.font_family_at_caret(),
17289            Some(FontFace::Generic(FontFamily::Monospace))
17290        );
17291    }
17292
17293    /// The *Other…* row of each of the four menus: a value goes into the
17294    /// document in its canonical spelling and comes back out of the query as
17295    /// the same value. One round trip per property, because the four go out
17296    /// through different doors — two block gestures, and the run three through
17297    /// the span that `wrap_range_attrs` mints.
17298    #[test]
17299    fn an_exact_value_round_trips_through_the_gesture_and_the_query() {
17300        // Size: the run three, over a selection.
17301        let mut d = fmt_doc("a big b\n", Format::Djot);
17302        d.anchor = Some(2);
17303        d.caret = 5;
17304        d.set_font_size(FontSize::points(14.0));
17305        assert_eq!(d.source, "a [big]{data-size=\"14pt\"} b\n");
17306        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
17307
17308        // Colour, onto the same span — the gesture keeps the size it finds.
17309        d.set_text_color(Some(TextColor::Rgb {
17310            r: 0xc0,
17311            g: 0x30,
17312            b: 0x30,
17313        }));
17314        assert_eq!(
17315            d.source,
17316            "a [big]{data-size=\"14pt\" data-color=\"#c03030\"} b\n"
17317        );
17318        assert_eq!(
17319            d.text_color_at_caret(),
17320            Some(TextColor::Rgb {
17321                r: 0xc0,
17322                g: 0x30,
17323                b: 0x30
17324            })
17325        );
17326
17327        // Face: a family name, as given.
17328        d.set_font_family(Some(FontFace::Named("Garamond".into())));
17329        assert!(
17330            d.source.contains("data-font=\"Garamond\""),
17331            "{:?}",
17332            d.source
17333        );
17334        assert_eq!(
17335            d.font_family_at_caret(),
17336            Some(FontFace::Named("Garamond".into()))
17337        );
17338
17339        // Line spacing: a block gesture, and an exact ratio.
17340        let mut block = fmt_doc("hello\n", Format::Djot);
17341        block.caret = 1;
17342        block.set_line_spacing(LineHeight::ratio(1.3));
17343        assert_eq!(block.source, "{data-line-height=\"1.3\"}\nhello\n");
17344        assert_eq!(block.line_spacing_at_caret(), LineHeight::ratio(1.3));
17345
17346        // And a value spelled long is written back short, so the same press
17347        // twice writes the same bytes: `14.0pt` in, `14pt` out.
17348        let mut long = fmt_doc("{data-size=\"14.0pt\"}\nhello\n", Format::Djot);
17349        long.caret = long.source.find("hello").unwrap();
17350        assert_eq!(long.font_size_at_caret(), FontSize::points(14.0));
17351        let in_force = long.font_size_at_caret();
17352        long.set_font_size(in_force);
17353        assert_eq!(long.source, "{data-size=\"14pt\"}\nhello\n");
17354    }
17355
17356    /// A value the grammar does not cover is what it was before the vocabulary
17357    /// opened: carried untouched by the document, answered `None` by the query
17358    /// so the menu ticks *Default*, and rewritten only by a gesture on its own
17359    /// key. leaf is not going to grow a CSS parser to guess at `1.3em`.
17360    #[test]
17361    fn a_value_outside_the_grammar_is_carried_and_the_menu_ticks_the_default() {
17362        let src =
17363            "{data-size=\"huge\" data-color=\"rgb(1,2,3)\" data-line-height=\"1.3em\"}\nhello\n";
17364        let mut d = fmt_doc(src, Format::Djot);
17365        d.caret = d.source.find("hello").unwrap();
17366        assert_eq!(d.font_size_at_caret(), None);
17367        assert_eq!(d.text_color_at_caret(), None);
17368        assert_eq!(d.line_spacing_at_caret(), None);
17369
17370        // The keys are still there, untouched, after a gesture on a *different*
17371        // key — "edit one key and keep the rest" holds for a value it cannot
17372        // read as readily as for one it can.
17373        d.set_alignment(Some(Align::Center));
17374        assert!(d.source.contains("data-size=\"huge\""), "{:?}", d.source);
17375        assert!(
17376            d.source.contains("data-color=\"rgb(1,2,3)\""),
17377            "{:?}",
17378            d.source
17379        );
17380        assert!(
17381            d.source.contains("data-line-height=\"1.3em\""),
17382            "{:?}",
17383            d.source
17384        );
17385        // And the gesture on its *own* key replaces it, which is the one way a
17386        // carried value ever changes.
17387        d.caret = d.source.find("hello").unwrap();
17388        d.set_font_size(FontSize::points(12.0));
17389        assert!(d.source.contains("data-size=\"12pt\""), "{:?}", d.source);
17390        assert!(!d.source.contains("huge"), "{:?}", d.source);
17391    }
17392
17393    /// The nearest node wins whichever *form* either node wrote: a value inside
17394    /// a name, a name inside a value. The fold has one rule and does not learn
17395    /// a second one for exact values.
17396    #[test]
17397    fn the_nearest_node_wins_whether_it_named_a_size_or_measured_one() {
17398        // A value inside a name: the block says `small`, the span says `14pt`.
17399        let mut d = fmt_doc(
17400            "{data-size=\"small\"}\nx [y]{data-size=\"14pt\"} z\n",
17401            Format::Djot,
17402        );
17403        d.caret = d.source.find('y').unwrap();
17404        assert_eq!(d.font_size_at_caret(), FontSize::points(14.0));
17405        d.caret = d.source.find('x').unwrap();
17406        assert_eq!(
17407            d.font_size_at_caret(),
17408            Some(FontSize::Step(SizeStep::Small))
17409        );
17410
17411        // And a name inside a value, which is the same rule read the other way.
17412        let mut e = fmt_doc(
17413            "{data-size=\"14pt\" data-color=\"#c03030\"}\nx [y]{data-size=\"small\"} z\n",
17414            Format::Djot,
17415        );
17416        e.caret = e.source.find('y').unwrap();
17417        assert_eq!(
17418            e.font_size_at_caret(),
17419            Some(FontSize::Step(SizeStep::Small))
17420        );
17421        assert_eq!(
17422            e.text_color_at_caret(),
17423            Some(TextColor::Rgb {
17424                r: 0xc0,
17425                g: 0x30,
17426                b: 0x30
17427            }),
17428            "the block's colour still reaches the span"
17429        );
17430        e.caret = e.source.find('x').unwrap();
17431        assert_eq!(e.font_size_at_caret(), FontSize::points(14.0));
17432    }
17433
17434    /// twig re-styles the span a range already lies in rather than nesting a
17435    /// second, and an empty set unwraps it — so a second press of the menu
17436    /// fixes the size instead of building `[[big]{.a}]{.b}`, and the entry that
17437    /// means "the theme's own" takes the span away.
17438    #[test]
17439    fn a_second_run_gesture_re_styles_the_span_and_none_unwraps_it() {
17440        let mut d = fmt_doc("a big b\n", Format::Djot);
17441        d.anchor = Some(2);
17442        d.caret = 5;
17443        d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17444        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
17445
17446        // The selection `wrap_range_attrs` left behind covers the whole span;
17447        // colouring it now keeps the size, because the gesture reads the span's
17448        // attributes before it edits its own key.
17449        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
17450        assert_eq!(
17451            d.source, "a [big]{data-size=\"large\" data-color=\"red\"} b\n",
17452            "one span, both keys"
17453        );
17454        assert_eq!(
17455            d.font_size_at_caret(),
17456            Some(FontSize::Step(SizeStep::Large))
17457        );
17458        assert_eq!(
17459            d.text_color_at_caret(),
17460            Some(TextColor::Named(MarkColor::Red))
17461        );
17462
17463        d.set_text_color(None);
17464        assert_eq!(d.source, "a [big]{data-size=\"large\"} b\n");
17465        d.set_font_size(None);
17466        assert_eq!(d.source, "a big b\n", "the last key unwraps the span");
17467        assert_eq!(d.font_size_at_caret(), None);
17468    }
17469
17470    /// The queries read the nearest node that names the property: the span the
17471    /// caret is in, then its block, then the `div`s around it.
17472    #[test]
17473    fn a_presentation_query_reads_the_nearest_node_that_names_it() {
17474        let mut d = fmt_doc(
17475            "{.center data-size=\"small\" data-font=\"serif\"}\nx [y]{data-size=\"xx-large\"} z\n",
17476            Format::Djot,
17477        );
17478        // In the span: its own size, the block's face and alignment.
17479        d.caret = d.source.find('y').unwrap();
17480        assert_eq!(
17481            d.font_size_at_caret(),
17482            Some(FontSize::Step(SizeStep::XxLarge))
17483        );
17484        assert_eq!(
17485            d.font_family_at_caret(),
17486            Some(FontFace::Generic(FontFamily::Serif))
17487        );
17488        assert_eq!(d.alignment_at_caret(), Some(Align::Center));
17489        assert_eq!(d.line_spacing_at_caret(), None);
17490        assert_eq!(d.text_color_at_caret(), None);
17491
17492        // Outside it: the block's size.
17493        d.caret = d.source.find('x').unwrap();
17494        assert_eq!(
17495            d.font_size_at_caret(),
17496            Some(FontSize::Step(SizeStep::Small))
17497        );
17498
17499        // And through a Markdown div, which is where a Markdown block's
17500        // attributes live.
17501        let mut md = fmt_doc(
17502            "<div class=\"center\" data-size=\"large\">\n\nhello\n\n</div>\n",
17503            Format::Markdown,
17504        );
17505        md.caret = md.source.find("hello").unwrap();
17506        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17507        assert_eq!(
17508            md.font_size_at_caret(),
17509            Some(FontSize::Step(SizeStep::Large))
17510        );
17511
17512        // A document that names none of it answers `None` everywhere, which is
17513        // "the theme's own" and what every toolbar draws unlit.
17514        let mut plain = doc_with("plain_presentation", "hello\n");
17515        plain.caret = 1;
17516        assert_eq!(plain.alignment_at_caret(), None);
17517        assert_eq!(plain.line_spacing_at_caret(), None);
17518        assert_eq!(plain.font_size_at_caret(), None);
17519        assert_eq!(plain.font_family_at_caret(), None);
17520        assert_eq!(plain.text_color_at_caret(), None);
17521    }
17522
17523    /// A djot fenced div is anonymous the way an attributed span is, and is a
17524    /// block all the same — the *form* is the whole of what tells them apart.
17525    /// Read as a span it poisoned both halves: the run gesture copied the div's
17526    /// entire attribute set onto the span it minted, duplicating the `id`, and
17527    /// the run and block queries answered off a node the walker draws nothing
17528    /// for.
17529    #[test]
17530    fn a_djot_fenced_div_is_not_an_attributed_span() {
17531        let src = "{.center data-size=\"small\" #box}\n:::\nhello world\n:::\n";
17532        let mut d = fmt_doc(src, Format::Djot);
17533        let at = d.source.find("world").unwrap();
17534        d.anchor = Some(at);
17535        d.caret = at + "world".len();
17536        d.set_text_color(Some(TextColor::Named(MarkColor::Red)));
17537        assert_eq!(
17538            d.source,
17539            "{.center data-size=\"small\" #box}\n:::\nhello [world]{data-color=\"red\"}\n:::\n",
17540            "the span carries its own key and nothing of the div's"
17541        );
17542
17543        // And the queries stop at the block: a djot div is not a `<div>`, the
17544        // walker lends its keys to nothing inside it, and a query that said
17545        // otherwise would tick a menu entry no glyph on screen obeys.
17546        assert_eq!(
17547            d.text_color_at_caret(),
17548            Some(TextColor::Named(MarkColor::Red))
17549        );
17550        assert_eq!(d.font_size_at_caret(), None);
17551        assert_eq!(d.alignment_at_caret(), None);
17552    }
17553
17554    /// Clearing a property the block does not name and a `div` around it does
17555    /// would write nothing and change nothing — twig's `set_block_attrs`
17556    /// reaches one node, and the div is not it. The gesture says so instead of
17557    /// leaving the author pressing an entry that never ticks.
17558    #[test]
17559    fn clearing_a_property_an_enclosing_div_names_says_so_and_writes_nothing() {
17560        // Markdown, two paragraphs in one div: not the sole-child shape twig
17561        // writes, so `block_attrs_at_caret` reads the paragraph and the
17562        // paragraph names none of it.
17563        let src = "<div class=\"center\" data-line-height=\"1.5\" data-size=\"large\">\n\nhello\n\nworld\n\n</div>\n";
17564        let mut md = fmt_doc(src, Format::Markdown);
17565        md.caret = md.source.find("hello").unwrap();
17566        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17567
17568        md.set_alignment(None);
17569        assert_eq!(md.source, src, "nothing written");
17570        assert!(!md.dirty);
17571        assert_eq!(
17572            md.status.as_deref(),
17573            Some("alignment: set on the div around the block")
17574        );
17575        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17576
17577        // The same for a `data-` key, at both levels — the block pair and the
17578        // run three, the run three at a bare caret being the block gesture.
17579        md.set_line_spacing(None);
17580        assert_eq!(md.source, src);
17581        assert_eq!(
17582            md.status.as_deref(),
17583            Some("line spacing: set on the div around the block")
17584        );
17585        md.set_font_size(None);
17586        assert_eq!(md.source, src);
17587        assert_eq!(
17588            md.status.as_deref(),
17589            Some("size: set on the div around the block")
17590        );
17591
17592        // HTML has no sole-child fold at all: a block's attributes go on the
17593        // block, so the div around one is always out of reach.
17594        let html_src = "<div class=\"center\"><p>hi</p></div>\n";
17595        let mut html = fmt_doc(html_src, Format::Html);
17596        html.caret = html.source.find("hi").unwrap();
17597        assert_eq!(html.alignment_at_caret(), Some(Align::Center));
17598        html.set_alignment(None);
17599        assert_eq!(html.source, html_src);
17600        assert!(!html.dirty);
17601        assert_eq!(
17602            html.status.as_deref(),
17603            Some("alignment: set on the div around the block")
17604        );
17605
17606        // And it is a refusal, not a rule against clearing: a block that names
17607        // the property itself still loses it, div or no div.
17608        let mut own = fmt_doc(
17609            "<div class=\"center\"><p class=\"right\">hi</p></div>\n",
17610            Format::Html,
17611        );
17612        own.caret = own.source.find("hi").unwrap();
17613        own.set_alignment(None);
17614        assert_eq!(own.source, "<div class=\"center\"><p>hi</p></div>\n");
17615        assert_eq!(own.status, None);
17616    }
17617
17618    /// An edited key is rewritten **where it stands**. The proposal's worked
17619    /// example is the test: a paragraph that came in as `id="intro"
17620    /// class="lead center" data-line-height="1.5"` and is right-aligned goes
17621    /// out as the same list with one token changed. Removing the key and
17622    /// pushing it back shuffled a document's attributes on every press.
17623    #[test]
17624    fn an_edited_key_keeps_its_place_among_the_attributes() {
17625        let mut html = fmt_doc(
17626            "<p id=\"intro\" class=\"lead center\" data-line-height=\"1.5\">hello</p>\n",
17627            Format::Html,
17628        );
17629        html.caret = html.source.find("hello").unwrap();
17630        html.set_alignment(Some(Align::Right));
17631        assert_eq!(
17632            html.source,
17633            "<p id=\"intro\" class=\"lead right\" data-line-height=\"1.5\">hello</p>\n"
17634        );
17635
17636        // A `data-` key the same way, and a key the block did not have still
17637        // goes on the end.
17638        html.caret = html.source.find("hello").unwrap();
17639        html.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17640        assert_eq!(
17641            html.source,
17642            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\">hello</p>\n"
17643        );
17644        html.caret = html.source.find("hello").unwrap();
17645        html.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17646        assert_eq!(
17647            html.source,
17648            "<p id=\"intro\" class=\"lead right\" data-line-height=\"2\" data-size=\"large\">hello</p>\n"
17649        );
17650
17651        // Djot writes the same list in its own spelling, and the order is the
17652        // author's there too.
17653        let mut dj = fmt_doc(
17654            "{#intro .lead .center data-line-height=\"1.5\"}\nhello\n",
17655            Format::Djot,
17656        );
17657        dj.caret = dj.source.find("hello").unwrap();
17658        dj.set_alignment(Some(Align::Right));
17659        assert_eq!(
17660            dj.source,
17661            "{#intro .lead .right data-line-height=\"1.5\"}\nhello\n"
17662        );
17663    }
17664
17665    /// A page break is a block, so twig alone lands one after the caret's whole
17666    /// block; the paragraph is parted at the caret first, exactly as
17667    /// `insert_thematic_break` parts it, and each format spells the directive
17668    /// its own way.
17669    #[test]
17670    fn insert_page_break_parts_the_paragraph_and_spells_the_directive() {
17671        let mut md = doc_with("page_break_md", "hello world\n");
17672        md.caret = 5;
17673        md.insert_page_break();
17674        assert_eq!(md.source, "hello\n\n::page-break\n\nworld\n");
17675        assert!(md.dirty);
17676        assert_eq!(md.status, None);
17677
17678        let mut dj = fmt_doc("hello world\n", Format::Djot);
17679        dj.caret = 5;
17680        dj.insert_page_break();
17681        assert_eq!(dj.source, "hello\n\n::: page-break\n:::\n\nworld\n");
17682
17683        // At a block's end there is no second half to mint, so the break simply
17684        // follows the block — the rule the rule button already has.
17685        let mut end = doc_with("page_break_end", "hello\n");
17686        end.caret = 5;
17687        end.insert_page_break();
17688        assert_eq!(end.source, "hello\n\n::page-break\n");
17689
17690        // And it reaches the map as the placeholder row a frontend paginates on.
17691        end.view = View::Wysiwyg;
17692        end.build_visual(80);
17693        assert_eq!(
17694            end.vmap
17695                .rows
17696                .iter()
17697                .find_map(|r| r.leaf_directive.as_ref())
17698                .map(|m| m.name.as_str()),
17699            Some(PAGE_BREAK)
17700        );
17701    }
17702
17703    /// The vocabulary's capabilities, per format. The two block properties are
17704    /// `SetBlockAttrs` and the three run ones `WrapRangeAttrs`, which is why
17705    /// AsciiDoc can align a paragraph and not size a run: its `[#id.role]#text#`
17706    /// keeps an id and a role and has no slot for a `data-` key.
17707    #[test]
17708    fn the_presentation_capabilities_are_ragged_per_format() {
17709        for fmt in [Format::Markdown, Format::Djot, Format::Html] {
17710            let c = Capabilities::of(fmt);
17711            assert!(c.alignment, "{fmt:?} alignment");
17712            assert!(c.line_spacing, "{fmt:?} line spacing");
17713            assert!(c.font_size, "{fmt:?} size");
17714            assert!(c.font_family, "{fmt:?} face");
17715            assert!(c.text_color, "{fmt:?} colour");
17716        }
17717        // Markdown spells both only under the extensions leaf parses with — a
17718        // `<div>` and a `<span>` read back as containers under `html_elements`,
17719        // and `::page-break` as a directive under `directives`. Ask twig's own
17720        // defaults and the answer is no, which is why `Capabilities` is built
17721        // with `supports_with`.
17722        assert!(!Format::Markdown.supports(Gesture::SetBlockAttrs));
17723        assert!(!Format::Markdown.supports(Gesture::WrapRangeAttrs));
17724        assert!(!Format::Markdown.supports(Gesture::InsertDirective));
17725
17726        let adoc = Capabilities::of(Format::Asciidoc);
17727        assert!(adoc.alignment && adoc.line_spacing, "AsciiDoc's `[…]` line");
17728        assert!(
17729            !adoc.font_size && !adoc.font_family && !adoc.text_color,
17730            "AsciiDoc has no inline spelling that keeps a data- key"
17731        );
17732
17733        // XML spells none of it, and neither page break — nor has it blocks a
17734        // caret could name for a move.
17735        let xml = Capabilities::of(Format::Xml);
17736        assert!(!xml.alignment && !xml.font_size && !xml.page_break && !xml.move_block);
17737        for f in [Format::Markdown, Format::Djot, Format::Html] {
17738            assert!(Capabilities::of(f).move_block, "{f:?} moves blocks");
17739        }
17740        assert!(Capabilities::of(Format::Markdown).page_break);
17741        assert!(Capabilities::of(Format::Djot).page_break);
17742
17743        // And those two *only*, though twig spells the gesture in HTML and
17744        // AsciiDoc as well: it spells it differently there —
17745        // `<page-break></page-break>` and `<<<` — and the walker reads neither,
17746        // so the button would write a break that draws as nothing at all in
17747        // HTML and as an empty unlabelled row in AsciiDoc. The flag describes
17748        // what leaf can show, not what twig can write. See
17749        // `docs/tasks/page-break-in-html-and-asciidoc.md`.
17750        let exts = parse_extensions();
17751        assert!(Format::Html.supports_with(exts, Gesture::InsertDirective));
17752        assert!(Format::Asciidoc.supports_with(exts, Gesture::InsertDirective));
17753        assert!(!Capabilities::of(Format::Html).page_break);
17754        assert!(!Capabilities::of(Format::Asciidoc).page_break);
17755    }
17756
17757    /// A format that cannot spell a property refuses in its own words and
17758    /// writes nothing — the guard every other gesture has.
17759    #[test]
17760    fn a_presentation_gesture_a_format_cannot_spell_is_refused_with_a_reason() {
17761        let src = "<doc><p>hello</p></doc>\n";
17762        #[allow(clippy::type_complexity)]
17763        let ops: [(&str, &dyn Fn(&mut Doc)); 6] = [
17764            ("alignment", &|d: &mut Doc| {
17765                d.set_alignment(Some(Align::Center))
17766            }),
17767            ("line spacing", &|d: &mut Doc| {
17768                d.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)))
17769            }),
17770            ("size", &|d: &mut Doc| {
17771                d.set_font_size(Some(FontSize::Step(SizeStep::Large)))
17772            }),
17773            ("face", &|d: &mut Doc| {
17774                d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)))
17775            }),
17776            ("colour", &|d: &mut Doc| {
17777                d.set_text_color(Some(TextColor::Named(MarkColor::Red)))
17778            }),
17779            ("page break", &|d: &mut Doc| d.insert_page_break()),
17780        ];
17781        for (name, op) in ops {
17782            let mut d = fmt_doc(src, Format::Xml);
17783            let at = d.source.find("hello").unwrap();
17784            d.caret = at;
17785            d.anchor = Some(at + 5);
17786            op(&mut d);
17787            assert_eq!(d.source, src, "{name} edited an XML document");
17788            assert!(!d.dirty, "{name} marked the document dirty");
17789            let status = d.status.as_deref().unwrap_or("");
17790            assert!(
17791                status.contains("xml"),
17792                "{name}: the refusal should name the format, got {status:?}"
17793            );
17794        }
17795
17796        // AsciiDoc is the ragged one: the block gesture works where the run
17797        // gesture does not, and a *selection* is what tells the two apart.
17798        let mut adoc = fmt_doc("hello world\n", Format::Asciidoc);
17799        adoc.anchor = Some(0);
17800        adoc.caret = 5;
17801        adoc.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17802        assert_eq!(adoc.source, "hello world\n", "no inline spelling");
17803        assert!(adoc.status.is_some());
17804    }
17805
17806    /// A read-only document takes none of it, and a caret on a blank line has
17807    /// no block to carry an attribute — both say so rather than writing.
17808    #[test]
17809    fn a_presentation_gesture_respects_read_only_and_a_blank_line() {
17810        let mut ro = fmt_doc("hello\n", Format::Djot);
17811        ro.read_only = true;
17812        ro.caret = 1;
17813        ro.set_alignment(Some(Align::Center));
17814        assert_eq!(ro.source, "hello\n");
17815
17816        let mut blank = fmt_doc("a\n\n\nb\n", Format::Djot);
17817        blank.caret = 2; // the empty line between the two paragraphs
17818        blank.set_alignment(Some(Align::Center));
17819        assert_eq!(blank.source, "a\n\n\nb\n");
17820        assert!(
17821            blank.status.as_deref().unwrap_or("").contains("no block"),
17822            "got {:?}",
17823            blank.status
17824        );
17825    }
17826
17827    /// A block attribute gesture keeps the caret on the **text** it was on, not
17828    /// on the byte offset it had. Markdown has nowhere to put a paragraph's
17829    /// attributes but a `<div>` around it, and twig splices the div and the
17830    /// block it wraps as one region — so a caret that kept its offset landed in
17831    /// the markup, and every press after the first answered "no block at the
17832    /// caret" with the toolbar's queries reading nothing.
17833    #[test]
17834    fn a_markdown_block_gesture_keeps_the_caret_on_its_text() {
17835        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
17836        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
17837        md.caret = md.source.find("brown").unwrap() + 2; // "br|own"
17838
17839        // Wrapping: the div and two blank lines open above the block.
17840        md.set_alignment(Some(Align::Center));
17841        assert_eq!(
17842            md.source,
17843            "<div class=\"center\">\n\nthe quick brown fox\n\n</div>\n"
17844        );
17845        assert_eq!(word(&md), 2, "the caret left its word: {}", md.caret);
17846        assert_eq!(md.alignment_at_caret(), Some(Align::Center));
17847
17848        // Re-styling: the attribute line changes length under the same caret,
17849        // and the second press reaches the same block rather than nothing.
17850        md.set_alignment(Some(Align::Right));
17851        assert_eq!(
17852            md.source, "<div class=\"right\">\n\nthe quick brown fox\n\n</div>\n",
17853            "a second press re-styles the div"
17854        );
17855        assert_eq!(md.status, None);
17856        assert_eq!(word(&md), 2);
17857
17858        // A second key on the same div — the line grows, the caret rides it.
17859        md.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17860        assert_eq!(
17861            md.source,
17862            "<div class=\"right\" data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
17863        );
17864        assert_eq!(word(&md), 2);
17865        assert_eq!(
17866            md.line_spacing_at_caret(),
17867            Some(LineHeight::Step(LineSpacing::Double))
17868        );
17869
17870        // Unwrapping: the line shrinks, and then the div goes altogether.
17871        md.set_alignment(None);
17872        assert_eq!(
17873            md.source,
17874            "<div data-line-height=\"2\">\n\nthe quick brown fox\n\n</div>\n"
17875        );
17876        assert_eq!(word(&md), 2);
17877        md.set_line_spacing(None);
17878        assert_eq!(md.source, "the quick brown fox\n", "the last key unwraps");
17879        assert_eq!(word(&md), 2, "the caret came back down with the block");
17880        assert_eq!(md.alignment_at_caret(), None);
17881        assert_eq!(md.status, None);
17882    }
17883
17884    /// The same rule in djot, where the spelling is a `{…}` line *above* the
17885    /// block rather than a wrapper around it: inserting it pushes the block
17886    /// down, re-styling it changes the line's length, and clearing the last key
17887    /// takes the line away again. The caret rides all three.
17888    #[test]
17889    fn a_djot_attribute_line_keeps_the_caret_on_its_text() {
17890        let word = |d: &Doc| d.caret - d.source.find("brown").unwrap();
17891        let mut dj = fmt_doc("the quick brown fox\n", Format::Djot);
17892        dj.caret = dj.source.find("brown").unwrap() + 2;
17893
17894        dj.set_alignment(Some(Align::Center));
17895        assert_eq!(dj.source, "{.center}\nthe quick brown fox\n");
17896        assert_eq!(word(&dj), 2);
17897        assert_eq!(dj.alignment_at_caret(), Some(Align::Center));
17898
17899        dj.set_line_spacing(Some(LineHeight::Step(LineSpacing::Double)));
17900        assert_eq!(
17901            dj.source, "{.center data-line-height=\"2\"}\nthe quick brown fox\n",
17902            "a second press edits the line the first wrote"
17903        );
17904        assert_eq!(word(&dj), 2);
17905
17906        dj.set_alignment(None);
17907        assert_eq!(dj.source, "{data-line-height=\"2\"}\nthe quick brown fox\n");
17908        assert_eq!(word(&dj), 2);
17909
17910        dj.set_line_spacing(None);
17911        assert_eq!(dj.source, "the quick brown fox\n");
17912        assert_eq!(word(&dj), 2);
17913        assert_eq!(dj.status, None);
17914    }
17915
17916    /// The run gestures with no selection are the block gesture, so they keep
17917    /// the caret the same way — and a heading keeps it inside the heading's own
17918    /// text, past the `# ` its content span starts after. A selection rides
17919    /// along whole: a block gesture is not a run gesture, and what was selected
17920    /// before the press is still selected after it.
17921    #[test]
17922    fn a_block_gesture_carries_a_selection_and_a_heading_caret_too() {
17923        // No selection: the run gesture goes through the block door.
17924        let mut md = fmt_doc("the quick brown fox\n", Format::Markdown);
17925        md.caret = md.source.find("brown").unwrap() + 2;
17926        md.set_font_size(Some(FontSize::Step(SizeStep::Large)));
17927        assert_eq!(
17928            md.source,
17929            "<div data-size=\"large\">\n\nthe quick brown fox\n\n</div>\n"
17930        );
17931        assert_eq!(md.caret - md.source.find("brown").unwrap(), 2);
17932        assert_eq!(
17933            md.font_size_at_caret(),
17934            Some(FontSize::Step(SizeStep::Large))
17935        );
17936        md.set_font_size(Some(FontSize::Step(SizeStep::Small)));
17937        assert_eq!(
17938            md.font_size_at_caret(),
17939            Some(FontSize::Step(SizeStep::Small)),
17940            "the second press reached the same block"
17941        );
17942
17943        // A selection: alignment is the block's whatever is selected, and the
17944        // words stay selected.
17945        let mut sel = fmt_doc("the quick brown fox\n", Format::Markdown);
17946        let at = sel.source.find("brown").unwrap();
17947        sel.anchor = Some(at);
17948        sel.caret = at + 5;
17949        sel.set_alignment(Some(Align::Center));
17950        let now = sel.source.find("brown").unwrap();
17951        assert_eq!(sel.selection(), Some((now, now + 5)), "the words moved out");
17952
17953        // A heading: the content span starts past the `# `.
17954        let mut h = fmt_doc("# hi there\n\nbody\n", Format::Markdown);
17955        h.caret = h.source.find("there").unwrap() + 1;
17956        h.set_alignment(Some(Align::Right));
17957        assert_eq!(
17958            h.source,
17959            "<div class=\"right\">\n\n# hi there\n\n</div>\n\nbody\n"
17960        );
17961        assert_eq!(h.caret, h.source.find("there").unwrap() + 1);
17962        assert_eq!(h.alignment_at_caret(), Some(Align::Right));
17963    }
17964
17965    /// A djot document open in the rich view, with its map built as
17966    /// [`wysiwyg_doc`] builds a Markdown one's.
17967    fn wysiwyg_djot(body: &str) -> Doc {
17968        let mut d = fmt_doc(body, Format::Djot);
17969        d.view = View::Wysiwyg;
17970        d.build_visual(80);
17971        d
17972    }
17973
17974    /// Backspace at the start of a block whose presentation is spelled as
17975    /// hidden markup before it strips that presentation, the way Backspace at
17976    /// a heading's start strips its `#`. The ordinary delete fused djot's
17977    /// `{.center}` line onto the text and took the blank line a Markdown div
17978    /// needs between its tag and its paragraph.
17979    #[test]
17980    fn backspace_at_the_start_of_a_centred_paragraph_strips_its_attributes() {
17981        let mut md = wysiwyg_doc(
17982            "wys_attr_bksp",
17983            "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
17984        );
17985        md.caret = md.source.find("hello").unwrap();
17986        md.backspace();
17987        assert_eq!(
17988            md.source, "above\n\nhello\n\nbelow\n",
17989            "the div is unwrapped"
17990        );
17991        assert_eq!(md.caret, 7, "the caret stays at the start of its text");
17992        md.backspace();
17993        assert_eq!(
17994            md.source, "above\nhello\n\nbelow\n",
17995            "the next press joins the paragraphs, as it always did"
17996        );
17997
17998        let mut dj = wysiwyg_djot("above\n\n{.center}\nhello\n\nbelow\n");
17999        dj.caret = dj.source.find("hello").unwrap();
18000        dj.backspace();
18001        assert_eq!(
18002            dj.source, "above\n\nhello\n\nbelow\n",
18003            "the attribute line goes"
18004        );
18005        assert_eq!(dj.caret, 7);
18006
18007        // A heading's own marker is the nearer hidden markup, and goes first;
18008        // the attributes are the next press's.
18009        let mut dj = wysiwyg_djot("{.center}\n# Title\n");
18010        dj.caret = dj.source.find("Title").unwrap();
18011        dj.backspace();
18012        assert_eq!(dj.source, "{.center}\nTitle\n", "the `#` first");
18013        dj.build_visual(80);
18014        dj.backspace();
18015        assert_eq!(dj.source, "Title\n", "then the attributes");
18016        assert_eq!(dj.caret, 0);
18017    }
18018
18019    /// A div around several blocks has no sole child for twig to unwrap, so
18020    /// at its first block the caret steps back to the stop before rather than
18021    /// taking the div apart; a later block has an ordinary paragraph above it
18022    /// and joins as any paragraph does.
18023    #[test]
18024    fn backspace_at_the_first_of_a_div_s_blocks_steps_back_and_a_later_one_joins() {
18025        let src = "above\n\n<div class=\"center\">\n\nhello\n\nworld\n\n</div>\n";
18026        let mut d = wysiwyg_doc("wys_div_first", src);
18027        d.caret = d.source.find("hello").unwrap();
18028        d.backspace();
18029        assert_eq!(d.source, src, "nothing is deleted");
18030        assert_eq!(d.caret, 5, "the caret steps back to the end of `above`");
18031
18032        let mut d = wysiwyg_doc("wys_div_later", src);
18033        d.caret = d.source.find("world").unwrap();
18034        d.backspace();
18035        assert_eq!(
18036            d.source, "above\n\n<div class=\"center\">\n\nhello\nworld\n\n</div>\n",
18037            "a later block joins the one above it"
18038        );
18039    }
18040
18041    /// Backspace at the start of the paragraph after a Markdown div joins it
18042    /// into the div's last paragraph — the join any two paragraphs make, with
18043    /// the hidden `</div>` carried past the joined text. The ordinary delete
18044    /// took the newline under the tag, which drew nothing different, and the
18045    /// next press took the `>` and left the div unclosed.
18046    #[test]
18047    fn backspace_after_a_div_joins_the_paragraph_into_it() {
18048        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
18049        let mut d = wysiwyg_doc("wys_div_join", src);
18050        d.caret = d.source.find("below").unwrap();
18051        d.backspace();
18052        assert_eq!(
18053            d.source,
18054            "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n"
18055        );
18056        assert_eq!(
18057            d.caret,
18058            d.source.find("below").unwrap(),
18059            "the caret stays at the start of the joined text"
18060        );
18061        d.build_visual(80);
18062        assert_eq!(
18063            d.alignment_at_caret(),
18064            Some(Align::Center),
18065            "and is centred now"
18066        );
18067        d.undo();
18068        assert_eq!(d.source, src, "one undo step");
18069
18070        // A list closes the div: the paragraph joins the last item's text,
18071        // under the item's continuation indent, inside the div.
18072        let src = "<div class=\"center\">\n\n- item\n\n</div>\n\nbelow\n";
18073        let mut d = wysiwyg_doc("wys_div_list", src);
18074        d.caret = d.source.find("below").unwrap();
18075        d.backspace();
18076        assert_eq!(
18077            d.source, "<div class=\"center\">\n\n- item\n  below\n\n</div>\n",
18078            "the paragraph joins the item"
18079        );
18080        assert_eq!(d.caret, d.source.find("below").unwrap());
18081
18082        // And where twig has nothing to join into — a code block above — the
18083        // caret steps back to the stop before, and nothing is deleted.
18084        let src = "```\ncode\n```\n\nbelow\n";
18085        let mut d = wysiwyg_doc("wys_code_then_para", src);
18086        d.caret = d.source.find("below").unwrap();
18087        d.backspace();
18088        assert_eq!(d.source, src, "nothing is deleted");
18089        assert!(
18090            d.caret < d.source.find("below").unwrap(),
18091            "the caret stepped back"
18092        );
18093    }
18094
18095    /// Backspace on a blank line collapses to the stop before it — but not
18096    /// across hidden markup, which that collapse deleted whole: a `</div>`,
18097    /// or a comment between two blocks. There the blank line goes alone, and
18098    /// the caret lands where the collapse would have put it.
18099    #[test]
18100    fn backspace_on_a_blank_line_after_hidden_markup_keeps_the_markup() {
18101        let mut d = wysiwyg_doc(
18102            "wys_div_blank",
18103            "<div class=\"center\">\n\nhello\n\n</div>\n\n\n\nbelow\n",
18104        );
18105        d.caret = d.source.find("below").unwrap() - 2; // the empty paragraph
18106        assert!(
18107            d.vmap.is_stop(d.caret),
18108            "the empty paragraph is a caret home"
18109        );
18110        d.backspace();
18111        assert_eq!(
18112            d.source, "<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n",
18113            "the blank line goes and the div stays closed"
18114        );
18115        assert_eq!(
18116            d.caret,
18117            d.source.find("hello").unwrap() + 5,
18118            "onto the end of `hello`"
18119        );
18120
18121        let mut d = wysiwyg_doc("wys_comment_blank", "above\n\n<!-- note -->\n\n\n\nbelow\n");
18122        d.caret = d.source.find("below").unwrap() - 2;
18123        assert!(d.vmap.is_stop(d.caret));
18124        d.backspace();
18125        assert_eq!(
18126            d.source, "above\n\n<!-- note -->\n\nbelow\n",
18127            "the comment stays"
18128        );
18129        assert_eq!(d.caret, 5);
18130    }
18131
18132    /// Backspace at the end of an attributed span steps inside its hidden
18133    /// closing tag the way it steps inside a `**`, and takes the span with
18134    /// its last letter. Before, the byte-step took the `>` of `</span>`,
18135    /// which left the paragraph unparseable: it vanished from the rich view,
18136    /// and the Backspace after that joined the next block into the wreck.
18137    #[test]
18138    fn backspace_walks_into_a_sized_span_and_takes_the_emptied_span_with_its_space() {
18139        let src = "above\n\nThis <span data-size=\"x-large\">is</span> a test\n\nTest 2\n";
18140        let mut d = wysiwyg_doc("wys_span_bs", src);
18141        d.caret = d.source.find("a test").unwrap() + 6;
18142        for _ in 0..7 {
18143            d.backspace();
18144        }
18145        assert_eq!(
18146            d.source,
18147            "above\n\nThis <span data-size=\"x-large\">is</span>\n\nTest 2\n"
18148        );
18149        d.backspace();
18150        assert_eq!(
18151            d.source, "above\n\nThis <span data-size=\"x-large\">i</span>\n\nTest 2\n",
18152            "the first Backspace after the tag takes the letter, not the `>`"
18153        );
18154        d.backspace();
18155        assert_eq!(
18156            d.source, "above\n\nThis \n\nTest 2\n",
18157            "the last letter takes the span with it"
18158        );
18159        assert_eq!(d.caret, 12, "the caret is where the letter was");
18160        d.backspace();
18161        assert_eq!(d.source, "above\n\nThis\n\nTest 2\n");
18162        assert_eq!(d.caret, 11);
18163        d.build_visual(80);
18164        assert!(
18165            d.vmap
18166                .rows
18167                .iter()
18168                .any(|r| r.glyphs.iter().map(|g| g.ch).collect::<String>() == "This"),
18169            "the paragraph is still drawn"
18170        );
18171    }
18172
18173    #[test]
18174    fn backspace_walks_into_a_djot_sized_span_too() {
18175        let mut d = wysiwyg_djot("This [is]{data-size=\"x-large\"}\n\nTest 2\n");
18176        d.caret = d.source.find("\n\nTest 2").unwrap();
18177        // The caret home at the paragraph's end is inside the span, before
18178        // its `]`: the map offers no stop after `]{…}`.
18179        d.build_visual(80);
18180        assert_eq!(d.vmap.stop_before(31), Some(8));
18181        d.caret = 8;
18182        d.backspace();
18183        assert_eq!(d.source, "This [i]{data-size=\"x-large\"}\n\nTest 2\n");
18184        d.backspace();
18185        assert_eq!(
18186            d.source, "This \n\nTest 2\n",
18187            "the attribute block outside the span goes with it"
18188        );
18189        d.backspace();
18190        assert_eq!(d.source, "This\n\nTest 2\n");
18191        assert_eq!(d.caret, 4);
18192    }
18193
18194    /// A span that is empty as the file was written has no stop of its own;
18195    /// Backspace reaching it from behind takes it with the character before
18196    /// it, the character the key looked aimed at.
18197    #[test]
18198    fn backspace_over_an_already_empty_span_takes_it_with_the_character_before() {
18199        let src = "This <span data-size=\"x-large\"></span> a test\n";
18200        let mut d = wysiwyg_doc("wys_span_empty", src);
18201        d.caret = d.source.find(" a test").unwrap();
18202        d.backspace();
18203        assert_eq!(d.source, "This a test\n");
18204        assert_eq!(d.caret, 4);
18205    }
18206
18207    /// The mirror: Delete in front of a span's opening tag takes its first
18208    /// letter, and the span with its last.
18209    #[test]
18210    fn delete_walks_into_a_sized_span_and_takes_the_span_with_its_last_letter() {
18211        let src = "This <span data-size=\"x-large\">is</span> a test\n";
18212        let mut d = wysiwyg_doc("wys_span_del", src);
18213        d.caret = 5;
18214        d.delete_forward();
18215        assert_eq!(
18216            d.source,
18217            "This <span data-size=\"x-large\">s</span> a test\n"
18218        );
18219        d.delete_forward();
18220        assert_eq!(d.source, "This  a test\n");
18221        assert_eq!(d.caret, 5);
18222        d.delete_forward();
18223        assert_eq!(d.source, "This a test\n");
18224        assert_eq!(d.caret, 5);
18225    }
18226
18227    /// The block version of the span's emptying rule. Centre a one-letter
18228    /// paragraph — Markdown spells that as a `<div>` around it — and
18229    /// Backspace the letter: the div goes with it, leaving a plain blank line
18230    /// the caret is at home on, in the incremental map and the from-scratch
18231    /// one alike. Before, the letter went alone; the emptied div drew a
18232    /// caret home only the stale map had, and the next Backspace collapsed
18233    /// the line and left `<div class="center">\n\n</div>` standing invisibly
18234    /// in the file.
18235    #[test]
18236    fn backspace_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
18237        let mut d = wysiwyg_doc("wys_div_empty", "Try the toolbar.\n\nT\n");
18238        d.caret = d.source.len() - 1;
18239        d.set_alignment(Some(Align::Center));
18240        assert_eq!(
18241            d.source,
18242            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n"
18243        );
18244        d.backspace();
18245        assert_eq!(d.source, "Try the toolbar.\n\n\n");
18246        assert_eq!(d.caret, 18, "on the blank line where the letter was");
18247        // The host rebuilds the map after every key; the spliced map and a
18248        // fresh one both give the line a caret home.
18249        d.build_visual_unwrapped();
18250        assert!(d.vmap.is_stop(18));
18251        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
18252        d.backspace();
18253        assert_eq!(
18254            d.source, "Try the toolbar.\n",
18255            "then the blank line collapses"
18256        );
18257        assert_eq!(d.caret, 16);
18258
18259        // With a block after the div the blank line keeps a gap each side.
18260        let mut d = wysiwyg_doc(
18261            "wys_div_empty_mid",
18262            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n\nbelow\n",
18263        );
18264        d.caret = d.source.find("T\n").unwrap() + 1;
18265        d.backspace();
18266        assert_eq!(d.source, "Try the toolbar.\n\n\n\nbelow\n");
18267        assert_eq!(d.caret, 18);
18268        d.build_visual_unwrapped();
18269        assert!(d.vmap.is_stop(18));
18270        wysiwyg::assert_maps_eq(&d.vmap, &reference_map(&d.source), "after the div goes");
18271    }
18272
18273    /// djot spells the same paragraph as a `{.center}` line above it, and
18274    /// twig has no node at all for that line once the paragraph is gone —
18275    /// so the line goes with the letter too.
18276    #[test]
18277    fn backspace_that_empties_a_centred_paragraph_takes_its_djot_attrs_line_too() {
18278        let mut d = fmt_doc("Try the toolbar.\n\nT\n", Format::Djot);
18279        d.build_visual(80);
18280        d.caret = d.source.len() - 1;
18281        d.set_alignment(Some(Align::Center));
18282        assert_eq!(d.source, "Try the toolbar.\n\n{.center}\nT\n");
18283        d.backspace();
18284        assert_eq!(d.source, "Try the toolbar.\n\n\n");
18285        assert_eq!(d.caret, 18);
18286        d.build_visual(80);
18287        assert!(d.vmap.is_stop(18));
18288    }
18289
18290    /// The rule is for a block that would be no block: a div holding more
18291    /// keeps its tags, and an emptied heading is still a heading.
18292    #[test]
18293    fn emptying_a_paragraph_keeps_a_div_that_holds_more_and_a_heading_its_marker() {
18294        let mut d = wysiwyg_doc(
18295            "wys_div_more",
18296            "<div class=\"center\">\n\nText\n\nT\n\n</div>\n",
18297        );
18298        d.caret = d.source.find("T\n").unwrap() + 1;
18299        d.backspace();
18300        assert_eq!(d.source, "<div class=\"center\">\n\nText\n\n\n\n</div>\n");
18301        assert_eq!(d.caret, 28, "the blank line inside the div, as after Enter");
18302
18303        let mut d = wysiwyg_doc(
18304            "wys_div_heading",
18305            "<div class=\"center\">\n\n# T\n\n</div>\n",
18306        );
18307        d.caret = d.source.find("T\n").unwrap() + 1;
18308        d.backspace();
18309        assert_eq!(d.source, "<div class=\"center\">\n\n# \n\n</div>\n");
18310        assert_eq!(d.caret, 24);
18311        d.build_visual(80);
18312        assert!(
18313            d.vmap.is_stop(24),
18314            "the empty heading is still a caret home"
18315        );
18316    }
18317
18318    /// The mirror: Delete in front of the letter takes the div with it.
18319    #[test]
18320    fn delete_that_empties_a_centred_paragraph_takes_its_div_with_the_letter() {
18321        let mut d = wysiwyg_doc(
18322            "wys_div_empty_del",
18323            "Try the toolbar.\n\n<div class=\"center\">\n\nT\n\n</div>\n",
18324        );
18325        d.caret = d.source.find("T\n").unwrap();
18326        d.delete_forward();
18327        assert_eq!(d.source, "Try the toolbar.\n\n\n");
18328        assert_eq!(d.caret, 18);
18329        d.build_visual(80);
18330        assert!(d.vmap.is_stop(18));
18331
18332        let mut d = fmt_doc("Try the toolbar.\n\n{.center}\nT\n", Format::Djot);
18333        d.build_visual(80);
18334        d.caret = d.source.find("T\n").unwrap();
18335        d.delete_forward();
18336        assert_eq!(d.source, "Try the toolbar.\n\n\n");
18337        assert_eq!(d.caret, 18);
18338    }
18339
18340    /// Backspace at a block's start is twig's join, spelled per format — so
18341    /// the cases the one-newline delete got wrong come out right: a
18342    /// paragraph joins onto a heading's line, HTML's `</p><p>` goes as one,
18343    /// and a quote's prefix is written on the joined line.
18344    #[test]
18345    fn backspace_at_a_block_start_joins_it_the_format_s_way() {
18346        let mut d = wysiwyg_doc("wys_join_heading", "# Title\n\nbelow\n");
18347        d.caret = d.source.find("below").unwrap();
18348        d.backspace();
18349        assert_eq!(d.source, "# Title below\n", "onto the heading's line");
18350        assert_eq!(d.caret, d.source.find("below").unwrap());
18351        d.undo();
18352        assert_eq!(d.source, "# Title\n\nbelow\n", "one undo step");
18353
18354        let mut d = wysiwyg_doc("wys_join_quote", "> a\n\nb\n");
18355        d.caret = d.source.find('b').unwrap();
18356        d.backspace();
18357        assert_eq!(d.source, "> a\n> b\n", "into the quote, with its prefix");
18358        assert_eq!(d.caret, d.source.find('b').unwrap());
18359
18360        let mut h = fmt_doc("<p>above</p>\n<p class=\"x\">below</p>\n", Format::Html);
18361        h.view = View::Wysiwyg;
18362        h.build_visual(80);
18363        h.caret = h.source.find("below").unwrap();
18364        h.backspace();
18365        assert_eq!(
18366            h.source, "<p>above\nbelow</p>\n",
18367            "one paragraph, the tag gone whole"
18368        );
18369        assert_eq!(h.caret, h.source.find("below").unwrap());
18370    }
18371
18372    /// Delete at the end of a block's content is the same join aimed at the
18373    /// block after it, and the caret stays where the joined text now begins.
18374    #[test]
18375    fn delete_at_a_block_end_joins_the_next_block_into_it() {
18376        let src = "above\n\n<div class=\"center\">\n\nhello\n\n</div>\n\nbelow\n";
18377        let mut d = wysiwyg_doc("wys_del_join", src);
18378        d.caret = d.source.find("hello").unwrap() + 5;
18379        d.delete_forward();
18380        assert_eq!(
18381            d.source, "above\n\n<div class=\"center\">\n\nhello\nbelow\n\n</div>\n",
18382            "below joins hello inside the div"
18383        );
18384        assert_eq!(
18385            d.caret,
18386            d.source.find("hello").unwrap() + 5,
18387            "the caret stays"
18388        );
18389
18390        let mut d = wysiwyg_doc("wys_del_join_head", "above\n\n# Title\n");
18391        d.caret = 5;
18392        d.delete_forward();
18393        assert_eq!(
18394            d.source, "above\nTitle\n",
18395            "the heading's marker goes with the join"
18396        );
18397        assert_eq!(d.caret, 5);
18398
18399        // A code block after the paragraph: nothing to join, the caret steps
18400        // forward onto the next stop and nothing is deleted.
18401        let src = "above\n\n```\ncode\n```\n";
18402        let mut d = wysiwyg_doc("wys_del_code", src);
18403        d.caret = 5;
18404        d.delete_forward();
18405        assert_eq!(d.source, src);
18406        assert!(d.caret > 5, "the caret stepped forward");
18407    }
18408
18409    // ── Text statistics ──────────────────────────────────────────────────────
18410
18411    /// The counts of `body`, from a document open in the WYSIWYG view — the
18412    /// shape every case below starts from.
18413    fn counts_of(name: &str, body: &str) -> TextCounts {
18414        doc_in(View::Wysiwyg, name, body).counts()
18415    }
18416
18417    #[test]
18418    fn counts_tally_plain_prose() {
18419        let c = counts_of(
18420            "counts_prose",
18421            "The quick brown fox jumps over the lazy dog.\n",
18422        );
18423        assert_eq!(
18424            c,
18425            TextCounts {
18426                words: 9,
18427                characters: 44,
18428                characters_without_spaces: 36,
18429                paragraphs: 1,
18430            }
18431        );
18432    }
18433
18434    #[test]
18435    fn counts_read_the_text_and_not_the_markup() {
18436        // The `**` are four bytes of source and no part of the word.
18437        assert_eq!(
18438            counts_of("counts_marks", "a **bold** word\n"),
18439            TextCounts {
18440                words: 3,
18441                characters: 11,
18442                characters_without_spaces: 9,
18443                paragraphs: 1,
18444            }
18445        );
18446        // A link is its label; the destination is plumbing, however long.
18447        assert_eq!(
18448            counts_of(
18449                "counts_link",
18450                "see [the label](https://example.com/a/b/c) here\n"
18451            ),
18452            TextCounts {
18453                words: 4,
18454                characters: 18,
18455                characters_without_spaces: 15,
18456                paragraphs: 1,
18457            }
18458        );
18459    }
18460
18461    #[test]
18462    fn counts_spend_nothing_on_a_picture() {
18463        // A block image renders as a `🖼 alt` placeholder — a picture, not a
18464        // sentence, and not a paragraph either.
18465        assert_eq!(
18466            counts_of("counts_image", "![a long caption](pic.png)\n"),
18467            TextCounts::default()
18468        );
18469        // And it adds nothing to the prose around it.
18470        assert_eq!(
18471            counts_of("counts_image_prose", "text\n\n![a long caption](pic.png)\n"),
18472            TextCounts {
18473                words: 1,
18474                characters: 4,
18475                characters_without_spaces: 4,
18476                paragraphs: 1,
18477            }
18478        );
18479    }
18480
18481    #[test]
18482    fn counts_spend_nothing_on_drawn_furniture() {
18483        // A thematic break is drawn, not written, and an empty paragraph has
18484        // nothing in it — neither is a paragraph of the document.
18485        assert_eq!(
18486            counts_of("counts_rule", "one\n\n---\n\ntwo\n"),
18487            TextCounts {
18488                words: 2,
18489                characters: 6,
18490                characters_without_spaces: 6,
18491                paragraphs: 2,
18492            }
18493        );
18494    }
18495
18496    #[test]
18497    fn counts_measure_characters_as_a_reader_does() {
18498        // Four Han characters (each its own word under UAX#29), one ZWJ emoji
18499        // family that is a single grapheme cluster, and two letters.
18500        let c = counts_of(
18501            "counts_graphemes",
18502            "你好世界 👩\u{200d}👩\u{200d}👧\u{200d}👦 ok\n",
18503        );
18504        assert_eq!(
18505            c,
18506            TextCounts {
18507                words: 5,
18508                characters: 9,
18509                characters_without_spaces: 7,
18510                paragraphs: 1,
18511            }
18512        );
18513    }
18514
18515    #[test]
18516    fn counts_take_a_code_block_as_one_paragraph() {
18517        let c = counts_of("counts_code", "```rust\nlet x = 1;\n\nlet y = 2;\n```\n");
18518        assert_eq!(
18519            c,
18520            TextCounts {
18521                words: 6,
18522                characters: 20,
18523                characters_without_spaces: 14,
18524                paragraphs: 1,
18525            }
18526        );
18527    }
18528
18529    #[test]
18530    fn counts_take_a_table_as_one_paragraph() {
18531        // The box-drawn borders and the column padding are the renderer's, not
18532        // the author's; the cells are what was written.
18533        let c = counts_of("counts_table", "| a b | c |\n| - | - |\n| d | e |\n");
18534        assert_eq!(
18535            c,
18536            TextCounts {
18537                words: 5,
18538                characters: 6,
18539                characters_without_spaces: 5,
18540                paragraphs: 1,
18541            }
18542        );
18543    }
18544
18545    #[test]
18546    fn counts_give_every_item_and_every_quoted_paragraph_its_own_paragraph() {
18547        let c = counts_of(
18548            "counts_blocks",
18549            "- one\n- two\n- three\n\n> first quoted\n>\n> second quoted\n",
18550        );
18551        assert_eq!(
18552            c,
18553            TextCounts {
18554                words: 7,
18555                characters: 36,
18556                characters_without_spaces: 34,
18557                paragraphs: 5,
18558            }
18559        );
18560    }
18561
18562    #[test]
18563    fn counts_leave_the_frontmatter_out() {
18564        // The WYSIWYG view doesn't render it and a writer didn't write it.
18565        let c = counts_of(
18566            "counts_frontmatter",
18567            "---\ntitle: Hidden\n---\n\nvisible words here\n",
18568        );
18569        assert_eq!(
18570            c,
18571            TextCounts {
18572                words: 3,
18573                characters: 18,
18574                characters_without_spaces: 16,
18575                paragraphs: 1,
18576            }
18577        );
18578    }
18579
18580    #[test]
18581    fn counts_of_an_empty_document_are_all_zero() {
18582        assert_eq!(counts_of("counts_empty", ""), TextCounts::default());
18583    }
18584
18585    /// UAX#29 puts a boundary at the hyphen, so a hyphenated compound is two
18586    /// words. Recorded rather than corrected: it is what the algorithm says,
18587    /// and what every other UAX#29 counter reports.
18588    #[test]
18589    fn counts_split_a_hyphenated_compound_in_two() {
18590        let c = counts_of("counts_hyphen", "well-known example\n");
18591        assert_eq!(c.words, 3);
18592        assert_eq!(c.characters, 18);
18593        // Punctuation on its own is no word, and an apostrophe doesn't split one.
18594        assert_eq!(counts_of("counts_punct", "don't ... stop\n").words, 2);
18595    }
18596
18597    #[test]
18598    fn selection_counts_measure_the_selection_and_nothing_without_one() {
18599        let src = "alpha beta\n\ngamma delta\n";
18600        let mut d = doc_in(View::Wysiwyg, "counts_sel", src);
18601        assert_eq!(d.selection_counts(), None, "no selection, no counts");
18602
18603        // From the `b` of `beta` to the end of `gamma`: two blocks clipped.
18604        d.select_range(6, 17);
18605        assert_eq!(
18606            d.selection_counts(),
18607            Some(TextCounts {
18608                words: 2,
18609                characters: 9,
18610                characters_without_spaces: 9,
18611                paragraphs: 2,
18612            })
18613        );
18614    }
18615
18616    /// The count is of the document, not of the window it is shown in — so
18617    /// ⌘E must not move it, and neither must a resize or an edit made with no
18618    /// map built at all.
18619    #[test]
18620    fn counts_agree_across_the_views() {
18621        let src =
18622            "# Head\n\nA **bold** word, a [label](http://x), and more.\n\n- item one\n- item two\n";
18623        let mut d = doc_in(View::Wysiwyg, "counts_views", src);
18624        let wysiwyg = d.counts();
18625        assert!(wysiwyg.words > 0 && wysiwyg.paragraphs == 4);
18626
18627        d.toggle_view();
18628        assert_eq!(d.view, View::Source);
18629        d.build_source();
18630        assert_eq!(d.counts(), wysiwyg, "the source view counts the same text");
18631
18632        // A narrower measure is a narrower window, not a shorter document.
18633        d.toggle_view();
18634        d.build_visual(24);
18635        assert_eq!(d.counts(), wysiwyg, "wrapping is not an input");
18636
18637        // And an edit made in the source view, with the visual map left stale,
18638        // still counts the document as it now stands.
18639        d.toggle_view();
18640        d.caret = d.source.len();
18641        d.insert("\n\ntail words\n");
18642        let after = d.counts();
18643        assert_eq!(after.paragraphs, wysiwyg.paragraphs + 1);
18644        assert_eq!(after.words, wysiwyg.words + 2);
18645    }
18646
18647    // ── math ─────────────────────────────────────────────────────────────────
18648
18649    #[test]
18650    fn a_formula_reveals_on_the_caret_line_in_the_hidden_modes() {
18651        // The rule the proposal states: a formula's content is its TeX, not
18652        // its picture, so it reveals on the caret's line in *every* mode —
18653        // and nothing else on that line does outside `Full`.
18654        let mut d = doc_in(
18655            View::Wysiwyg,
18656            "math_reveal",
18657            "*one* $x+y$ here\n\ntwo there\n",
18658        );
18659        d.set_inline_pictures(true);
18660        assert_eq!(d.markup_mode(), MarkupMode::None);
18661
18662        // Away from the formula's line: the atom, and no reveal at all.
18663        caret_at(&mut d, "two");
18664        assert!(
18665            drawn_rows(&d).iter().any(|r| r == "one ∑ here"),
18666            "{:?}",
18667            drawn_rows(&d)
18668        );
18669        assert_eq!(d.vmap.math.len(), 1);
18670        assert_eq!(d.reveal_line(), None, "a line with no math keys nothing");
18671
18672        // On it: the formula is its source, the emphasis is still resolved.
18673        caret_at(&mut d, "here");
18674        assert!(
18675            drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"),
18676            "{:?}",
18677            drawn_rows(&d)
18678        );
18679        assert!(d.vmap.math.is_empty());
18680        assert_eq!(d.reveal_line(), Some(Reveal::math(0..16)));
18681
18682        // Off again, and the picture is back.
18683        caret_at(&mut d, "two");
18684        assert!(drawn_rows(&d).iter().any(|r| r == "one ∑ here"));
18685
18686        // The same in Shortcuts; and Full reveals the emphasis too.
18687        d.set_markup_mode(MarkupMode::Shortcuts);
18688        caret_at(&mut d, "here");
18689        assert!(drawn_rows(&d).iter().any(|r| r == "one $x+y$ here"));
18690        d.set_markup_mode(MarkupMode::Full);
18691        caret_at(&mut d, "here");
18692        assert!(drawn_rows(&d).iter().any(|r| r == "*one* $x+y$ here"));
18693    }
18694
18695    #[test]
18696    fn a_formula_closed_by_typing_reveals_at_once() {
18697        // The reveal line is decided from the last build's layout, which
18698        // across an edit is stale: the keystroke that closes a `$…$` asks a
18699        // layout that knew no math. `build_map` asks again once the new
18700        // layout is in, so the formula does not snap to its picture under
18701        // the caret.
18702        let mut d = doc_in(View::Wysiwyg, "math_typed", "say \n");
18703        d.set_inline_pictures(true);
18704        d.set_markup_mode(MarkupMode::Shortcuts);
18705        d.caret = 4;
18706        for ch in ["$", "x", "$"] {
18707            d.insert(ch);
18708            d.build_visual(80);
18709        }
18710        assert_eq!(d.source, "say $x$\n");
18711        assert_eq!(
18712            drawn_rows(&d)[0],
18713            "say $x$",
18714            "source, not a picture, under the caret"
18715        );
18716        assert!(d.vmap.math.is_empty());
18717        // Leaving the line folds it — there is only one line, so add one.
18718        d.newline();
18719        d.insert("more");
18720        d.build_visual(80);
18721        assert_eq!(drawn_rows(&d)[0], "say ∑");
18722        assert_eq!(d.vmap.math.len(), 1);
18723        // And deleting the formula while revealed drops the reveal with it.
18724        d.caret = 7;
18725        d.build_visual(80);
18726        assert_eq!(drawn_rows(&d)[0], "say $x$");
18727        for _ in 0..3 {
18728            d.backspace();
18729        }
18730        d.build_visual(80);
18731        assert_eq!(d.source, "say \n\nmore\n");
18732        assert_eq!(d.reveal_line(), None);
18733    }
18734
18735    #[test]
18736    fn a_display_block_is_edited_where_it_stands() {
18737        let mut d = doc_in(
18738            View::Wysiwyg,
18739            "math_block",
18740            "intro\n\n$$\n\\int_0^1 x\n$$\n\nend\n",
18741        );
18742        caret_at(&mut d, "end");
18743        assert_eq!(
18744            drawn_rows(&d),
18745            vec!["intro", "", "∑ \\int_0^1 x", "", "end"]
18746        );
18747        // Up from `end` lands on the placeholder, whose glyphs all carry the
18748        // block's start — which is on its `$$` line, so the block reveals.
18749        d.move_up(false);
18750        d.build_visual(80);
18751        assert_eq!(d.caret, 7);
18752        assert_eq!(
18753            drawn_rows(&d),
18754            vec!["intro", "", "$$", "\\int_0^1 x", "$$", "", "end"]
18755        );
18756        // Down walks the source lines, still revealed; typing edits the TeX.
18757        d.move_down(false);
18758        d.build_visual(80);
18759        assert_eq!(d.caret, 10);
18760        d.move_end(false);
18761        d.insert("^2");
18762        d.build_visual(80);
18763        assert_eq!(d.source, "intro\n\n$$\n\\int_0^1 x^2\n$$\n\nend\n");
18764        assert_eq!(drawn_rows(&d)[3], "\\int_0^1 x^2");
18765        // Out below, and it folds to the placeholder with the new TeX.
18766        d.move_down(false);
18767        d.move_down(false);
18768        d.move_down(false);
18769        d.build_visual(80);
18770        assert_eq!(drawn_rows(&d)[2], "∑ \\int_0^1 x^2");
18771        assert_eq!(d.vmap.math[0].tex, "\n\\int_0^1 x^2\n");
18772    }
18773
18774    #[test]
18775    fn set_math_rows_reserves_filler_rows_by_tex() {
18776        let mut d = doc_in(View::Wysiwyg, "math_rows", "$$\nx\n$$\n\nend\n");
18777        caret_at(&mut d, "end");
18778        assert_eq!(d.vmap.math[0].rows_span, 0..1);
18779        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
18780        d.build_visual(80);
18781        assert_eq!(d.vmap.math[0].rows_span, 0..3);
18782        assert_eq!(drawn_rows(&d)[..3], ["∑ x", "", ""]);
18783        // Cheap when nothing changed.
18784        let key = d.visual_key();
18785        d.set_math_rows(HashMap::from([(d.vmap.math[0].tex.clone(), 3)]));
18786        d.build_visual(80);
18787        assert_eq!(d.visual_key(), key);
18788    }
18789
18790    #[test]
18791    fn a_dollar_typed_in_shortcuts_authors_math() {
18792        // (In `None` the same keystrokes *also* mint a formula for now: twig's
18793        // `insert_literal` does not yet escape `$` under the math extension —
18794        // see `docs/tasks/a-typed-dollar-mints-math-in-the-hidden-mode.md`.)
18795        let mut d = doc_in(View::Wysiwyg, "math_dollar_sc", "\n");
18796        d.set_markup_mode(MarkupMode::Shortcuts);
18797        d.caret = 0;
18798        d.insert("$x$");
18799        assert_eq!(d.source, "$x$\n");
18800        d.caret = 1;
18801        assert_eq!(d.breadcrumb(), "doc › para › inline_math");
18802    }
18803
18804    #[test]
18805    fn counts_see_a_formula_as_a_picture_however_it_is_written() {
18806        let c = counts_of(
18807            "counts_math",
18808            "the sum $\\sum_i x_i$ and\n\n$$\ny = mx + c\n$$\n",
18809        );
18810        // `the sum … and` is three words; neither formula counts, and the
18811        // display block is not a paragraph of text.
18812        assert_eq!(c.words, 3);
18813        assert_eq!(c.paragraphs, 1);
18814    }
18815}